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:
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user