1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 09:13:33 +00:00

Refactor State Providers (#8273)

* Delete A Lot Of Code

* Fix Tests

* Create SingleUserState Provider Once

* Update Manual Instantiations

* Fix Service Factory

* Delete More

* Delete Unused `updatePromise`

* `postStorageSave` -> `doStorageSave`

* Update Comment

* Fix jslib-services
This commit is contained in:
Justin Baur
2024-03-14 16:38:22 -05:00
committed by GitHub
parent 4f8fa57b9d
commit 1d76e80afb
14 changed files with 243 additions and 456 deletions

View File

@@ -1,118 +1,27 @@
import {
Observable,
map,
switchMap,
firstValueFrom,
filter,
timeout,
merge,
share,
ReplaySubject,
timer,
tap,
throwError,
distinctUntilChanged,
withLatestFrom,
} from "rxjs";
import { Observable, map, switchMap, firstValueFrom, timeout, throwError, NEVER } from "rxjs";
import { AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import {
AbstractStorageService,
ObservableStorageService,
} from "../../abstractions/storage.service";
import { StateEventRegistrarService } from "../state-event-registrar.service";
import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options";
import { StateUpdateOptions } from "../state-update-options";
import { UserKeyDefinition } from "../user-key-definition";
import { ActiveUserState, CombinedState, activeMarker } from "../user-state";
import { getStoredValue } from "./util";
const FAKE = Symbol("fake");
import { SingleUserStateProvider } from "../user-state.provider";
export class DefaultActiveUserState<T> implements ActiveUserState<T> {
[activeMarker]: true;
private updatePromise: Promise<[UserId, T]> | null = null;
private activeUserId$: Observable<UserId | null>;
combinedState$: Observable<CombinedState<T>>;
state$: Observable<T>;
constructor(
protected keyDefinition: UserKeyDefinition<T>,
private accountService: AccountService,
private chosenStorageLocation: AbstractStorageService & ObservableStorageService,
private stateEventRegistrarService: StateEventRegistrarService,
private activeUserId$: Observable<UserId | null>,
private singleUserStateProvider: SingleUserStateProvider,
) {
this.activeUserId$ = this.accountService.activeAccount$.pipe(
// We only care about the UserId but we do want to know about no user as well.
map((a) => a?.id),
// To avoid going to storage when we don't need to, only get updates when there is a true change.
distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal
);
const userChangeAndInitial$ = this.activeUserId$.pipe(
// If the user has changed, we no longer need to lock an update call
// since that call will be for a user that is no longer active.
tap(() => (this.updatePromise = null)),
switchMap(async (userId) => {
// We've switched or started off with no active user. So,
// emit a fake value so that we can fill our share buffer.
if (userId == null) {
return FAKE;
}
const fullKey = this.keyDefinition.buildKey(userId);
const data = await getStoredValue(
fullKey,
this.chosenStorageLocation,
this.keyDefinition.deserializer,
);
return [userId, data] as CombinedState<T>;
}),
);
const latestStorage$ = this.chosenStorageLocation.updates$.pipe(
// Use withLatestFrom so that we do NOT emit when activeUserId changes because that
// is taken care of above, but we do want to have the latest user id
// when we get a storage update so we can filter the full key
withLatestFrom(
this.activeUserId$.pipe(
// Null userId is already taken care of through the userChange observable above
filter((u) => u != null),
// Take the userId and build the fullKey that we can now create
map((userId) => [userId, this.keyDefinition.buildKey(userId)] as const),
),
this.combinedState$ = this.activeUserId$.pipe(
switchMap((userId) =>
userId != null
? this.singleUserStateProvider.get(userId, this.keyDefinition).combinedState$
: NEVER,
),
// Filter to only storage updates that pertain to our key
filter(([storageUpdate, [_userId, fullKey]]) => storageUpdate.key === fullKey),
switchMap(async ([storageUpdate, [userId, fullKey]]) => {
// We can shortcut on updateType of "remove"
// and just emit null.
if (storageUpdate.updateType === "remove") {
return [userId, null] as CombinedState<T>;
}
return [
userId,
await getStoredValue(
fullKey,
this.chosenStorageLocation,
this.keyDefinition.deserializer,
),
] as CombinedState<T>;
}),
);
this.combinedState$ = merge(userChangeAndInitial$, latestStorage$).pipe(
share({
connector: () => new ReplaySubject<CombinedState<T> | typeof FAKE>(1),
resetOnRefCountZero: () => timer(this.keyDefinition.cleanupDelayMs),
}),
// Filter out FAKE AFTER the share so that we can fill the ReplaySubjects
// buffer with something and avoid emitting when there is no active user.
filter<CombinedState<T>>((d) => d !== (FAKE as unknown)),
);
// State should just be combined state without the user id
@@ -123,52 +32,17 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine> = {},
): Promise<[UserId, T]> {
options = populateOptionsWithDefault(options);
try {
if (this.updatePromise != null) {
await this.updatePromise;
}
this.updatePromise = this.internalUpdate(configureState, options);
const [userId, newState] = await this.updatePromise;
return [userId, newState];
} finally {
this.updatePromise = null;
}
}
private async internalUpdate<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options: StateUpdateOptions<T, TCombine>,
): Promise<[UserId, T]> {
const [userId, key, currentState] = await this.getStateForUpdate();
const combinedDependencies =
options.combineLatestWith != null
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
: null;
if (!options.shouldUpdate(currentState, combinedDependencies)) {
return [userId, currentState];
}
const newState = configureState(currentState, combinedDependencies);
await this.saveToStorage(key, newState);
if (newState != null && currentState == null) {
// Only register this state as something clearable on the first time it saves something
// worth deleting. This is helpful in making sure there is less of a race to adding events.
await this.stateEventRegistrarService.registerEvents(this.keyDefinition);
}
return [userId, newState];
}
/** For use in update methods, does not wait for update to complete before yielding state.
* The expectation is that that await is already done
*/
protected async getStateForUpdate() {
const userId = await firstValueFrom(
this.activeUserId$.pipe(
timeout({
first: 1000,
with: () => throwError(() => new Error("Timeout while retrieving active user.")),
with: () =>
throwError(
() =>
new Error(
`Timeout while retrieving active user for key ${this.keyDefinition.fullName}.`,
),
),
}),
),
);
@@ -177,15 +51,12 @@ export class DefaultActiveUserState<T> implements ActiveUserState<T> {
`Error storing ${this.keyDefinition.fullName} for the active user: No active user at this time.`,
);
}
const fullKey = this.keyDefinition.buildKey(userId);
return [
userId,
fullKey,
await getStoredValue(fullKey, this.chosenStorageLocation, this.keyDefinition.deserializer),
] as const;
}
protected saveToStorage(key: string, data: T): Promise<void> {
return this.chosenStorageLocation.save(key, data);
await this.singleUserStateProvider
.get(userId, this.keyDefinition)
.update(configureState, options),
];
}
}