mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
fix update loop when overwriting state from buffer (#8834)
This commit is contained in:
parent
fffef95c5e
commit
1e67014158
@ -87,9 +87,13 @@ export class BufferedKeyDefinition<Input, Output = Input, Dependency = true> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Checks whether the input type can be converted to the output type.
|
/** Checks whether the input type can be converted to the output type.
|
||||||
* @returns `true` if the definition is valid, otherwise `false`.
|
* @returns `true` if the definition is defined and valid, otherwise `false`.
|
||||||
*/
|
*/
|
||||||
isValid(input: Input, dependency: Dependency) {
|
isValid(input: Input, dependency: Dependency) {
|
||||||
|
if (input === null) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
const isValid = this.options?.isValid;
|
const isValid = this.options?.isValid;
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
return isValid(input, dependency);
|
return isValid(input, dependency);
|
||||||
|
@ -75,14 +75,16 @@ describe("BufferedState", () => {
|
|||||||
it("rolls over pending values from the buffered state immediately by default", async () => {
|
it("rolls over pending values from the buffered state immediately by default", async () => {
|
||||||
const provider = new FakeStateProvider(accountService);
|
const provider = new FakeStateProvider(accountService);
|
||||||
const outputState = provider.getUser(SomeUser, SOME_KEY);
|
const outputState = provider.getUser(SomeUser, SOME_KEY);
|
||||||
await outputState.update(() => ({ foo: true, bar: false }));
|
const initialValue = { foo: true, bar: false };
|
||||||
|
await outputState.update(() => initialValue);
|
||||||
const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState);
|
const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState);
|
||||||
const bufferedValue = { foo: true, bar: true };
|
const bufferedValue = { foo: true, bar: true };
|
||||||
await provider.setUserState(BUFFER_KEY.toKeyDefinition(), bufferedValue, SomeUser);
|
await provider.setUserState(BUFFER_KEY.toKeyDefinition(), bufferedValue, SomeUser);
|
||||||
|
|
||||||
const result = await firstValueFrom(bufferedState.state$);
|
const result = await trackEmissions(bufferedState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
expect(result).toEqual(bufferedValue);
|
expect(result).toEqual([initialValue, bufferedValue]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// also important for data migrations
|
// also important for data migrations
|
||||||
@ -131,14 +133,16 @@ describe("BufferedState", () => {
|
|||||||
});
|
});
|
||||||
const provider = new FakeStateProvider(accountService);
|
const provider = new FakeStateProvider(accountService);
|
||||||
const outputState = provider.getUser(SomeUser, SOME_KEY);
|
const outputState = provider.getUser(SomeUser, SOME_KEY);
|
||||||
await outputState.update(() => ({ foo: true, bar: false }));
|
const initialValue = { foo: true, bar: false };
|
||||||
|
await outputState.update(() => initialValue);
|
||||||
const bufferedState = new BufferedState(provider, bufferedKey, outputState);
|
const bufferedState = new BufferedState(provider, bufferedKey, outputState);
|
||||||
const bufferedValue = { foo: true, bar: true };
|
const bufferedValue = { foo: true, bar: true };
|
||||||
await provider.setUserState(bufferedKey.toKeyDefinition(), bufferedValue, SomeUser);
|
await provider.setUserState(bufferedKey.toKeyDefinition(), bufferedValue, SomeUser);
|
||||||
|
|
||||||
const result = await firstValueFrom(bufferedState.state$);
|
const result = await trackEmissions(bufferedState.state$);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
expect(result).toEqual(bufferedValue);
|
expect(result).toEqual([initialValue, bufferedValue]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads from the output state when shouldOverwrite returns a falsy value", async () => {
|
it("reads from the output state when shouldOverwrite returns a falsy value", async () => {
|
||||||
@ -274,7 +278,7 @@ describe("BufferedState", () => {
|
|||||||
await bufferedState.buffer(bufferedValue);
|
await bufferedState.buffer(bufferedValue);
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
|
||||||
expect(result).toEqual([firstValue, firstValue]);
|
expect(result).toEqual([firstValue]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces the output state when its dependency becomes true", async () => {
|
it("replaces the output state when its dependency becomes true", async () => {
|
||||||
@ -296,7 +300,7 @@ describe("BufferedState", () => {
|
|||||||
dependency.next(true);
|
dependency.next(true);
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
|
||||||
expect(result).toEqual([firstValue, firstValue, bufferedValue]);
|
expect(result).toEqual([firstValue, bufferedValue]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([[null], [undefined]])("ignores `%p`", async (bufferedValue) => {
|
it.each([[null], [undefined]])("ignores `%p`", async (bufferedValue) => {
|
||||||
@ -325,11 +329,13 @@ describe("BufferedState", () => {
|
|||||||
await outputState.update(() => firstValue);
|
await outputState.update(() => firstValue);
|
||||||
const bufferedState = new BufferedState(provider, bufferedKey, outputState);
|
const bufferedState = new BufferedState(provider, bufferedKey, outputState);
|
||||||
|
|
||||||
const result = trackEmissions(bufferedState.state$);
|
const stateResult = trackEmissions(bufferedState.state$);
|
||||||
await bufferedState.buffer({ foo: true, bar: true });
|
await bufferedState.buffer({ foo: true, bar: true });
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
const bufferedResult = await firstValueFrom(bufferedState.bufferedState$);
|
||||||
|
|
||||||
expect(result).toEqual([firstValue, firstValue]);
|
expect(stateResult).toEqual([firstValue]);
|
||||||
|
expect(bufferedResult).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("overwrites the output when isValid returns true", async () => {
|
it("overwrites the output when isValid returns true", async () => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Observable, combineLatest, concatMap, filter, map, of } from "rxjs";
|
import { Observable, combineLatest, concatMap, filter, map, of, concat, merge } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
StateProvider,
|
StateProvider,
|
||||||
@ -33,68 +33,53 @@ export class BufferedState<Input, Output, Dependency> implements SingleUserState
|
|||||||
private output: SingleUserState<Output>,
|
private output: SingleUserState<Output>,
|
||||||
dependency$: Observable<Dependency> = null,
|
dependency$: Observable<Dependency> = null,
|
||||||
) {
|
) {
|
||||||
this.bufferState = provider.getUser(output.userId, key.toKeyDefinition());
|
this.bufferedState = provider.getUser(output.userId, key.toKeyDefinition());
|
||||||
|
|
||||||
const watching = [
|
// overwrite the output value
|
||||||
this.bufferState.state$,
|
const hasValue$ = concat(of(null), this.bufferedState.state$).pipe(
|
||||||
this.output.state$,
|
map((buffer) => (buffer ?? null) !== null),
|
||||||
dependency$ ?? of(true as unknown as Dependency),
|
);
|
||||||
] as const;
|
const overwriteDependency$ = (dependency$ ?? of(true as unknown as Dependency)).pipe(
|
||||||
|
map((dependency) => [key.shouldOverwrite(dependency), dependency] as const),
|
||||||
this.state$ = combineLatest(watching).pipe(
|
);
|
||||||
concatMap(async ([input, output, dependency]) => {
|
const overwrite$ = combineLatest([hasValue$, overwriteDependency$]).pipe(
|
||||||
const normalized = input ?? null;
|
concatMap(async ([hasValue, [shouldOverwrite, dependency]]) => {
|
||||||
|
if (hasValue && shouldOverwrite) {
|
||||||
const canOverwrite = normalized !== null && key.shouldOverwrite(dependency);
|
await this.overwriteOutput(dependency);
|
||||||
if (canOverwrite) {
|
|
||||||
await this.updateOutput(dependency);
|
|
||||||
|
|
||||||
// prevent duplicate updates by suppressing the update
|
|
||||||
return [false, output] as const;
|
|
||||||
}
|
}
|
||||||
|
return [false, null] as const;
|
||||||
return [true, output] as const;
|
|
||||||
}),
|
}),
|
||||||
filter(([updated]) => updated),
|
);
|
||||||
|
|
||||||
|
// drive overwrites only when there's a subscription;
|
||||||
|
// the output state determines when emissions occur
|
||||||
|
const output$ = this.output.state$.pipe(map((output) => [true, output] as const));
|
||||||
|
this.state$ = merge(overwrite$, output$).pipe(
|
||||||
|
filter(([emit]) => emit),
|
||||||
map(([, output]) => output),
|
map(([, output]) => output),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.combinedState$ = this.state$.pipe(map((state) => [this.output.userId, state]));
|
this.combinedState$ = this.state$.pipe(map((state) => [this.output.userId, state]));
|
||||||
|
|
||||||
this.bufferState$ = this.bufferState.state$;
|
this.bufferedState$ = this.bufferedState.state$;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bufferState: SingleUserState<Input>;
|
private bufferedState: SingleUserState<Input>;
|
||||||
|
|
||||||
private async updateOutput(dependency: Dependency) {
|
private async overwriteOutput(dependency: Dependency) {
|
||||||
// retrieve the latest input value
|
// take the latest value from the buffer
|
||||||
let input: Input;
|
let buffered: Input;
|
||||||
await this.bufferState.update((state) => state, {
|
await this.bufferedState.update((state) => {
|
||||||
shouldUpdate: (state) => {
|
buffered = state ?? null;
|
||||||
input = state;
|
return null;
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// bail if this update lost the race with the last update
|
// update the output state
|
||||||
if (input === null) {
|
const isValid = await this.key.isValid(buffered, dependency);
|
||||||
return;
|
if (isValid) {
|
||||||
|
const output = await this.key.map(buffered, dependency);
|
||||||
|
await this.output.update(() => output);
|
||||||
}
|
}
|
||||||
|
|
||||||
// destroy invalid data and bail
|
|
||||||
if (!(await this.key.isValid(input, dependency))) {
|
|
||||||
await this.bufferState.update(() => null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// overwrite anything left to the output; the updates need to be awaited with `Promise.all`
|
|
||||||
// so that `inputState.update(() => null)` runs before `shouldUpdate` reads the value (above).
|
|
||||||
// This lets the emission from `this.outputState.update` renter the `concatMap`. If the
|
|
||||||
// awaits run in sequence, it can win the race and cause a double emission.
|
|
||||||
const output = await this.key.map(input, dependency);
|
|
||||||
await Promise.all([this.output.update(() => output), this.bufferState.update(() => null)]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@link SingleUserState.userId} */
|
/** {@link SingleUserState.userId} */
|
||||||
@ -119,14 +104,14 @@ export class BufferedState<Input, Output, Dependency> implements SingleUserState
|
|||||||
async buffer(value: Input): Promise<void> {
|
async buffer(value: Input): Promise<void> {
|
||||||
const normalized = value ?? null;
|
const normalized = value ?? null;
|
||||||
if (normalized !== null) {
|
if (normalized !== null) {
|
||||||
await this.bufferState.update(() => normalized);
|
await this.bufferedState.update(() => normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The data presently being buffered. This emits the pending value each time
|
/** The data presently being buffered. This emits the pending value each time
|
||||||
* new buffer data is provided. It emits null when the buffer is empty.
|
* new buffer data is provided. It emits null when the buffer is empty.
|
||||||
*/
|
*/
|
||||||
readonly bufferState$: Observable<Input>;
|
readonly bufferedState$: Observable<Input>;
|
||||||
|
|
||||||
/** Updates the output state.
|
/** Updates the output state.
|
||||||
* @param configureState a callback that returns an updated output
|
* @param configureState a callback that returns an updated output
|
||||||
|
Loading…
Reference in New Issue
Block a user