Ps/run-foreground-derived-state-in-zone (#7861)

* Sync derived state through memory storage

* Run foreground derived state in NgZone

* fix tests
This commit is contained in:
Matt Gibson 2024-02-08 14:04:38 -05:00 committed by GitHub
parent 304c492f24
commit b0edcb81af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 153 additions and 91 deletions

View File

@ -1,11 +1,10 @@
import { Observable, Subject, Subscription } from "rxjs"; import { Observable, Subscription } from "rxjs";
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { import {
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DeriveDefinition } from "@bitwarden/common/platform/state"; import { DeriveDefinition } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client // eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedState } from "@bitwarden/common/platform/state/implementations/default-derived-state"; import { DefaultDerivedState } from "@bitwarden/common/platform/state/implementations/default-derived-state";
@ -18,10 +17,7 @@ export class BackgroundDerivedState<
TTo, TTo,
TDeps extends DerivedStateDependencies, TDeps extends DerivedStateDependencies,
> extends DefaultDerivedState<TFrom, TTo, TDeps> { > extends DefaultDerivedState<TFrom, TTo, TDeps> {
private portSubscriptions: Map< private portSubscriptions: Map<chrome.runtime.Port, Subscription> = new Map();
chrome.runtime.Port,
{ subscription: Subscription; delaySubject: Subject<void> }
> = new Map();
constructor( constructor(
parentState$: Observable<TFrom>, parentState$: Observable<TFrom>,
@ -40,34 +36,15 @@ export class BackgroundDerivedState<
const listenerCallback = this.onMessageFromForeground.bind(this); const listenerCallback = this.onMessageFromForeground.bind(this);
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
const { subscription, delaySubject } = this.portSubscriptions.get(port) ?? { this.portSubscriptions.get(port)?.unsubscribe();
subscription: null,
delaySubject: null,
};
subscription?.unsubscribe();
delaySubject?.complete();
this.portSubscriptions.delete(port); this.portSubscriptions.delete(port);
port.onMessage.removeListener(listenerCallback); port.onMessage.removeListener(listenerCallback);
}); });
port.onMessage.addListener(listenerCallback); port.onMessage.addListener(listenerCallback);
const delaySubject = new Subject<void>(); const stateSubscription = this.state$.subscribe();
const stateSubscription = this.state$.subscribe((state) => {
// delay to allow the foreground to connect. This may just be needed for testing
setTimeout(() => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.sendNewMessage(
{
action: "nextState",
data: JSON.stringify(state),
},
port,
);
}, 0);
});
this.portSubscriptions.set(port, { subscription: stateSubscription, delaySubject }); this.portSubscriptions.set(port, stateSubscription);
}); });
} }
@ -93,22 +70,6 @@ export class BackgroundDerivedState<
} }
} }
private async sendNewMessage(
message: Omit<DerivedStateMessage, "originator" | "id">,
port: chrome.runtime.Port,
) {
const id = Utils.newGuid();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.sendMessage(
{
...message,
id: id,
},
port,
);
}
private async sendResponse( private async sendResponse(
originalMessage: DerivedStateMessage, originalMessage: DerivedStateMessage,
response: Omit<DerivedStateMessage, "originator" | "id">, response: Omit<DerivedStateMessage, "originator" | "id">,

View File

@ -3,8 +3,10 @@
* @jest-environment ../../libs/shared/test.environment.ts * @jest-environment ../../libs/shared/test.environment.ts
*/ */
import { NgZone } from "@angular/core";
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service"; import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec/utils"; import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec/utils";
import { mock } from "jest-mock-extended";
import { Subject, firstValueFrom } from "rxjs"; import { Subject, firstValueFrom } from "rxjs";
import { DeriveDefinition } from "@bitwarden/common/platform/state"; import { DeriveDefinition } from "@bitwarden/common/platform/state";
@ -22,12 +24,20 @@ const deriveDefinition = new DeriveDefinition(stateDefinition, "test", {
deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)), deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)),
}); });
// Mock out the runInsideAngular operator so we don't have to deal with zone.js
jest.mock("../browser/run-inside-angular.operator", () => {
return {
runInsideAngular: (ngZone: any) => (source: any) => source,
};
});
describe("foreground background derived state interactions", () => { describe("foreground background derived state interactions", () => {
let foreground: ForegroundDerivedState<Date>; let foreground: ForegroundDerivedState<Date>;
let background: BackgroundDerivedState<string, Date, Record<string, unknown>>; let background: BackgroundDerivedState<string, Date, Record<string, unknown>>;
let parentState$: Subject<string>; let parentState$: Subject<string>;
let memoryStorage: FakeStorageService; let memoryStorage: FakeStorageService;
const initialParent = "2020-01-01"; const initialParent = "2020-01-01";
const ngZone = mock<NgZone>();
beforeEach(() => { beforeEach(() => {
mockPorts(); mockPorts();
@ -35,7 +45,7 @@ describe("foreground background derived state interactions", () => {
memoryStorage = new FakeStorageService(); memoryStorage = new FakeStorageService();
background = new BackgroundDerivedState(parentState$, deriveDefinition, memoryStorage, {}); background = new BackgroundDerivedState(parentState$, deriveDefinition, memoryStorage, {});
foreground = new ForegroundDerivedState(deriveDefinition); foreground = new ForegroundDerivedState(deriveDefinition, memoryStorage, ngZone);
}); });
afterEach(() => { afterEach(() => {
@ -50,12 +60,12 @@ describe("foreground background derived state interactions", () => {
parentState$.next(initialParent); parentState$.next(initialParent);
await awaitAsync(10); await awaitAsync(10);
expect(foregroundEmissions).toEqual([new Date(initialParent)]);
expect(backgroundEmissions).toEqual([new Date(initialParent)]); expect(backgroundEmissions).toEqual([new Date(initialParent)]);
expect(foregroundEmissions).toEqual([new Date(initialParent)]);
}); });
it("should initialize a late-connected foreground", async () => { it("should initialize a late-connected foreground", async () => {
const newForeground = new ForegroundDerivedState(deriveDefinition); const newForeground = new ForegroundDerivedState(deriveDefinition, memoryStorage, ngZone);
const backgroundEmissions = trackEmissions(background.state$); const backgroundEmissions = trackEmissions(background.state$);
parentState$.next(initialParent); parentState$.next(initialParent);
await awaitAsync(); await awaitAsync();
@ -74,7 +84,7 @@ describe("foreground background derived state interactions", () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
foreground.forceValue(new Date(dateString)); await foreground.forceValue(new Date(dateString));
await awaitAsync(); await awaitAsync();
expect(emissions).toEqual([new Date(dateString)]); expect(emissions).toEqual([new Date(dateString)]);

View File

@ -1,5 +1,10 @@
import { NgZone } from "@angular/core";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state"; import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client // eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider"; import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
@ -8,11 +13,17 @@ import { DerivedStateDependencies } from "@bitwarden/common/src/types/state";
import { ForegroundDerivedState } from "./foreground-derived-state"; import { ForegroundDerivedState } from "./foreground-derived-state";
export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider { export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider {
constructor(
memoryStorage: AbstractStorageService & ObservableStorageService,
private ngZone: NgZone,
) {
super(memoryStorage);
}
override buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>( override buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>(
_parentState$: Observable<TFrom>, _parentState$: Observable<TFrom>,
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
_dependencies: TDeps, _dependencies: TDeps,
): DerivedState<TTo> { ): DerivedState<TTo> {
return new ForegroundDerivedState(deriveDefinition); return new ForegroundDerivedState(deriveDefinition, this.memoryStorage, this.ngZone);
} }
} }

View File

@ -1,4 +1,12 @@
import { awaitAsync } from "@bitwarden/common/../spec/utils"; /**
* need to update test environment so structuredClone works appropriately
* @jest-environment ../../libs/shared/test.environment.ts
*/
import { NgZone } from "@angular/core";
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec";
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
import { mock } from "jest-mock-extended";
import { DeriveDefinition } from "@bitwarden/common/platform/state"; import { DeriveDefinition } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition // eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition
@ -15,12 +23,23 @@ const deriveDefinition = new DeriveDefinition(stateDefinition, "test", {
cleanupDelayMs: 1, cleanupDelayMs: 1,
}); });
// Mock out the runInsideAngular operator so we don't have to deal with zone.js
jest.mock("../browser/run-inside-angular.operator", () => {
return {
runInsideAngular: (ngZone: any) => (source: any) => source,
};
});
describe("ForegroundDerivedState", () => { describe("ForegroundDerivedState", () => {
let sut: ForegroundDerivedState<Date>; let sut: ForegroundDerivedState<Date>;
let memoryStorage: FakeStorageService;
const ngZone = mock<NgZone>();
beforeEach(() => { beforeEach(() => {
memoryStorage = new FakeStorageService();
memoryStorage.internalUpdateValuesRequireDeserialization(true);
mockPorts(); mockPorts();
sut = new ForegroundDerivedState(deriveDefinition); sut = new ForegroundDerivedState(deriveDefinition, memoryStorage, ngZone);
}); });
afterEach(() => { afterEach(() => {
@ -48,14 +67,17 @@ describe("ForegroundDerivedState", () => {
expect(sut["port"]).toBeNull(); expect(sut["port"]).toBeNull();
}); });
it("should complete its replay subject when torn down", async () => { it("should emit when the memory storage updates", async () => {
const subscription = sut.state$.subscribe(); const dateString = "2020-01-01";
const emissions = trackEmissions(sut.state$);
const completeSpy = jest.spyOn(sut["replaySubject"], "complete"); await memoryStorage.save(deriveDefinition.storageKey, {
subscription.unsubscribe(); derived: true,
// wait for the cleanup delay value: new Date(dateString),
await awaitAsync(deriveDefinition.cleanupDelayMs * 2); });
expect(completeSpy).toHaveBeenCalled(); await awaitAsync();
expect(emissions).toEqual([new Date(dateString)]);
}); });
}); });

View File

@ -1,3 +1,4 @@
import { NgZone } from "@angular/core";
import { import {
Observable, Observable,
ReplaySubject, ReplaySubject,
@ -5,36 +6,67 @@ import {
filter, filter,
firstValueFrom, firstValueFrom,
map, map,
merge,
of,
share, share,
switchMap,
tap, tap,
timer, timer,
} from "rxjs"; } from "rxjs";
import { Jsonify, JsonObject } from "type-fest";
import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state"; import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
import { DerivedStateDependencies } from "@bitwarden/common/types/state"; import { DerivedStateDependencies } from "@bitwarden/common/types/state";
import { fromChromeEvent } from "../browser/from-chrome-event"; import { fromChromeEvent } from "../browser/from-chrome-event";
import { runInsideAngular } from "../browser/run-inside-angular.operator";
export class ForegroundDerivedState<TTo> implements DerivedState<TTo> { export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
private storageKey: string;
private port: chrome.runtime.Port; private port: chrome.runtime.Port;
// For testing purposes
private replaySubject: ReplaySubject<TTo>;
private backgroundResponses$: Observable<DerivedStateMessage>; private backgroundResponses$: Observable<DerivedStateMessage>;
state$: Observable<TTo>; state$: Observable<TTo>;
constructor(private deriveDefinition: DeriveDefinition<unknown, TTo, DerivedStateDependencies>) { constructor(
this.state$ = defer(() => this.initializePort()).pipe( private deriveDefinition: DeriveDefinition<unknown, TTo, DerivedStateDependencies>,
filter((message) => message.action === "nextState"), private memoryStorage: AbstractStorageService & ObservableStorageService,
map((message) => this.hydrateNext(message.data)), private ngZone: NgZone,
share({ ) {
connector: () => { this.storageKey = deriveDefinition.storageKey;
this.replaySubject = new ReplaySubject<TTo>(1);
return this.replaySubject; const initialStorageGet$ = defer(() => {
}, return this.getStoredValue();
resetOnRefCountZero: () => }).pipe(
timer(this.deriveDefinition.cleanupDelayMs).pipe(tap(() => this.tearDown())), filter((s) => s.derived),
map((s) => s.value),
);
const latestStorage$ = this.memoryStorage.updates$.pipe(
filter((s) => s.key === this.storageKey),
switchMap(async (storageUpdate) => {
if (storageUpdate.updateType === "remove") {
return null;
}
return await this.getStoredValue();
}), }),
filter((s) => s.derived),
map((s) => s.value),
);
this.state$ = defer(() => of(this.initializePort())).pipe(
switchMap(() => merge(initialStorageGet$, latestStorage$)),
share({
connector: () => new ReplaySubject<TTo>(1),
resetOnRefCountZero: () =>
timer(this.deriveDefinition.cleanupDelayMs).pipe(tap(() => this.tearDownPort())),
}),
runInsideAngular(this.ngZone),
); );
} }
@ -51,7 +83,7 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
return value; return value;
} }
private initializePort(): Observable<DerivedStateMessage> { private initializePort() {
if (this.port != null) { if (this.port != null) {
return; return;
} }
@ -88,11 +120,6 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
}); });
} }
private hydrateNext(value: string): TTo {
const jsonObj = JSON.parse(value);
return this.deriveDefinition.deserialize(jsonObj);
}
private tearDownPort() { private tearDownPort() {
if (this.port == null) { if (this.port == null) {
return; return;
@ -103,8 +130,27 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
this.backgroundResponses$ = null; this.backgroundResponses$ = null;
} }
private tearDown() { protected async getStoredValue(): Promise<{ derived: boolean; value: TTo | null }> {
this.tearDownPort(); if (this.memoryStorage.valuesRequireDeserialization) {
this.replaySubject.complete(); const storedJson = await this.memoryStorage.get<
Jsonify<{ derived: true; value: JsonObject }>
>(this.storageKey);
if (!storedJson?.derived) {
return { derived: false, value: null };
}
const value = this.deriveDefinition.deserialize(storedJson.value as any);
return { derived: true, value };
} else {
const stored = await this.memoryStorage.get<{ derived: true; value: TTo }>(this.storageKey);
if (!stored?.derived) {
return { derived: false, value: null };
}
return { derived: true, value: stored.value };
}
} }
} }

View File

@ -1,4 +1,4 @@
import { APP_INITIALIZER, LOCALE_ID, NgModule } from "@angular/core"; import { APP_INITIALIZER, LOCALE_ID, NgModule, NgZone } from "@angular/core";
import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards";
import { ThemingService } from "@bitwarden/angular/platform/services/theming/theming.service"; import { ThemingService } from "@bitwarden/angular/platform/services/theming/theming.service";
@ -554,7 +554,7 @@ function getBgService<T>(service: keyof MainBackground) {
{ {
provide: DerivedStateProvider, provide: DerivedStateProvider,
useClass: ForegroundDerivedStateProvider, useClass: ForegroundDerivedStateProvider,
deps: [OBSERVABLE_MEMORY_STORAGE], deps: [OBSERVABLE_MEMORY_STORAGE, NgZone],
}, },
], ],
}) })

View File

@ -73,12 +73,12 @@ describe("DefaultDerivedState", () => {
await awaitAsync(); await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual( expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
new Date(dateString), derivedValue(new Date(dateString)),
); );
const calls = memoryStorage.mock.save.mock.calls; const calls = memoryStorage.mock.save.mock.calls;
expect(calls.length).toBe(1); expect(calls.length).toBe(1);
expect(calls[0][0]).toBe(deriveDefinition.buildCacheKey()); expect(calls[0][0]).toBe(deriveDefinition.buildCacheKey());
expect(calls[0][1]).toEqual(new Date(dateString)); expect(calls[0][1]).toEqual(derivedValue(new Date(dateString)));
}); });
describe("forceValue", () => { describe("forceValue", () => {
@ -94,7 +94,9 @@ describe("DefaultDerivedState", () => {
it("should store the forced value", async () => { it("should store the forced value", async () => {
await sut.forceValue(forced); await sut.forceValue(forced);
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(forced); expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
derivedValue(forced),
);
}); });
}); });
@ -107,7 +109,9 @@ describe("DefaultDerivedState", () => {
it("should store the forced value", async () => { it("should store the forced value", async () => {
await sut.forceValue(forced); await sut.forceValue(forced);
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(forced); expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
derivedValue(forced),
);
}); });
it("should force the value", async () => { it("should force the value", async () => {
@ -150,7 +154,7 @@ describe("DefaultDerivedState", () => {
await awaitAsync(); await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual( expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
new Date(newDate), derivedValue(new Date(newDate)),
); );
subscription.unsubscribe(); subscription.unsubscribe();
@ -168,7 +172,7 @@ describe("DefaultDerivedState", () => {
await awaitAsync(); await awaitAsync();
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual( expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
new Date(newDate), derivedValue(new Date(newDate)),
); );
subscription.unsubscribe(); subscription.unsubscribe();
@ -176,7 +180,7 @@ describe("DefaultDerivedState", () => {
await awaitAsync(cleanupDelayMs * 2); await awaitAsync(cleanupDelayMs * 2);
expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual( expect(memoryStorage.internalStore[deriveDefinition.buildCacheKey()]).toEqual(
new Date(newDate), derivedValue(new Date(newDate)),
); );
}); });
@ -256,3 +260,7 @@ describe("DefaultDerivedState", () => {
}); });
}); });
}); });
function derivedValue<T>(value: T) {
return { derived: true, value };
}

View File

@ -34,7 +34,7 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
derivedStateOrPromise = await derivedStateOrPromise; derivedStateOrPromise = await derivedStateOrPromise;
} }
const derivedState = derivedStateOrPromise; const derivedState = derivedStateOrPromise;
await this.memoryStorage.save(this.storageKey, derivedState); await this.storeValue(derivedState);
return derivedState; return derivedState;
}), }),
); );
@ -58,8 +58,12 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
} }
async forceValue(value: TTo) { async forceValue(value: TTo) {
await this.memoryStorage.save(this.storageKey, value); await this.storeValue(value);
this.forcedValueSubject.next(value); this.forcedValueSubject.next(value);
return value; return value;
} }
private storeValue(value: TTo) {
return this.memoryStorage.save(this.storageKey, { derived: true, value });
}
} }