Remove ctor initialization in session sync (#4755)

* Remove ctor initialization in session sync

* Fix error message

* Prefer messaging over storage for syncing

We still need to use storage for instances where we open a popup or
worker and need to populate from a cache. However, in MV2, this is only
ever stored in a background service, ensuring that all data is stored in
from a long-lived context (mv2) or serialized to storage (mv3).

* Test new storage scheme
This commit is contained in:
Matt Gibson 2023-02-16 10:36:50 -05:00 committed by GitHub
parent b140d5f1b6
commit a5759ee22a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 135 additions and 75 deletions

View File

@ -115,6 +115,10 @@ export class BrowserApi {
return chrome.extension.getBackgroundPage(); return chrome.extension.getBackgroundPage();
} }
static isBackgroundPage(window: Window & typeof globalThis): boolean {
return window === chrome.extension.getBackgroundPage();
}
static getApplicationVersion(): string { static getApplicationVersion(): string {
return chrome.runtime.getManifest().version; return chrome.runtime.getManifest().version;
} }

View File

@ -4,11 +4,10 @@ import { sessionSync } from "./session-sync.decorator";
describe("sessionSync decorator", () => { describe("sessionSync decorator", () => {
const initializer = (s: string) => "test"; const initializer = (s: string) => "test";
const ctor = String;
class TestClass { class TestClass {
@sessionSync({ ctor: ctor, initializer: initializer }) @sessionSync({ initializer: initializer })
private testProperty = new BehaviorSubject(""); private testProperty = new BehaviorSubject("");
@sessionSync({ ctor: ctor, initializer: initializer, initializeAs: "array" }) @sessionSync({ initializer: initializer, initializeAs: "array" })
private secondTestProperty = new BehaviorSubject(""); private secondTestProperty = new BehaviorSubject("");
complete() { complete() {
@ -23,13 +22,11 @@ describe("sessionSync decorator", () => {
expect.objectContaining({ expect.objectContaining({
propertyKey: "testProperty", propertyKey: "testProperty",
sessionKey: "testProperty_0", sessionKey: "testProperty_0",
ctor: ctor,
initializer: initializer, initializer: initializer,
}), }),
expect.objectContaining({ expect.objectContaining({
propertyKey: "secondTestProperty", propertyKey: "secondTestProperty",
sessionKey: "secondTestProperty_1", sessionKey: "secondTestProperty_1",
ctor: ctor,
initializer: initializer, initializer: initializer,
initializeAs: "array", initializeAs: "array",
}), }),
@ -38,7 +35,7 @@ describe("sessionSync decorator", () => {
}); });
class TestClass2 { class TestClass2 {
@sessionSync({ ctor: ctor, initializer: initializer }) @sessionSync({ initializer: initializer })
private testProperty = new BehaviorSubject(""); private testProperty = new BehaviorSubject("");
complete() { complete() {
@ -52,7 +49,6 @@ describe("sessionSync decorator", () => {
expect.objectContaining({ expect.objectContaining({
propertyKey: "testProperty", propertyKey: "testProperty",
sessionKey: "testProperty_2", sessionKey: "testProperty_2",
ctor: ctor,
initializer: initializer, initializer: initializer,
}), }),
]); ]);

View File

@ -4,7 +4,6 @@ import { SessionStorable } from "./session-storable";
import { InitializeOptions } from "./sync-item-metadata"; import { InitializeOptions } from "./sync-item-metadata";
class BuildOptions<T, TJson = Jsonify<T>> { class BuildOptions<T, TJson = Jsonify<T>> {
ctor?: new () => T;
initializer?: (keyValuePair: TJson) => T; initializer?: (keyValuePair: TJson) => T;
initializeAs?: InitializeOptions; initializeAs?: InitializeOptions;
} }
@ -48,7 +47,6 @@ export function sessionSync<T>(buildOptions: BuildOptions<T>) {
p.__syncedItemMetadata.push({ p.__syncedItemMetadata.push({
propertyKey, propertyKey,
sessionKey: `${propertyKey}_${index++}`, sessionKey: `${propertyKey}_${index++}`,
ctor: buildOptions.ctor,
initializer: buildOptions.initializer, initializer: buildOptions.initializer,
initializeAs: buildOptions.initializeAs ?? "object", initializeAs: buildOptions.initializeAs ?? "object",
}); });

View File

@ -53,8 +53,8 @@ describe("session syncer", () => {
new SessionSyncer(behaviorSubject, storageService, { new SessionSyncer(behaviorSubject, storageService, {
propertyKey, propertyKey,
sessionKey, sessionKey,
ctor: String,
initializeAs: "object", initializeAs: "object",
initializer: () => null,
}) })
).toBeDefined(); ).toBeDefined();
expect( expect(
@ -72,8 +72,9 @@ describe("session syncer", () => {
propertyKey, propertyKey,
sessionKey, sessionKey,
initializeAs: "object", initializeAs: "object",
initializer: null,
}); });
}).toThrowError("ctor or initializer must be provided"); }).toThrowError("initializer must be provided");
}); });
}); });
@ -106,7 +107,7 @@ describe("session syncer", () => {
it("should grab an initial value from storage if it exists", async () => { it("should grab an initial value from storage if it exists", async () => {
storageService.has.mockResolvedValue(true); storageService.has.mockResolvedValue(true);
//Block a call to update //Block a call to update
const updateSpy = jest.spyOn(sut as any, "update").mockImplementation(); const updateSpy = jest.spyOn(sut as any, "updateFromMemory").mockImplementation();
sut.init(); sut.init();
await awaitAsync(); await awaitAsync();
@ -128,20 +129,15 @@ describe("session syncer", () => {
describe("a value is emitted on the observable", () => { describe("a value is emitted on the observable", () => {
let sendMessageSpy: jest.SpyInstance; let sendMessageSpy: jest.SpyInstance;
const value = "test";
const serializedValue = JSON.stringify(value);
beforeEach(() => { beforeEach(() => {
sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage");
sut.init(); sut.init();
behaviorSubject.next("test"); behaviorSubject.next(value);
});
it("should update the session memory", async () => {
// await finishing of fire-and-forget operation
await new Promise((resolve) => setTimeout(resolve, 100));
expect(storageService.save).toHaveBeenCalledTimes(1);
expect(storageService.save).toHaveBeenCalledWith(sessionKey, "test");
}); });
it("should update sessionSyncers in other contexts", async () => { it("should update sessionSyncers in other contexts", async () => {
@ -149,7 +145,10 @@ describe("session syncer", () => {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
expect(sendMessageSpy).toHaveBeenCalledTimes(1); expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy).toHaveBeenCalledWith(`${sessionKey}_update`, { id: sut.id }); expect(sendMessageSpy).toHaveBeenCalledWith(`${sessionKey}_update`, {
id: sut.id,
serializedValue,
});
}); });
}); });
@ -185,26 +184,104 @@ describe("session syncer", () => {
it("should update from message on emit from another instance", async () => { it("should update from message on emit from another instance", async () => {
const builder = jest.fn(); const builder = jest.fn();
jest.spyOn(SyncedItemMetadata, "builder").mockReturnValue(builder); jest.spyOn(SyncedItemMetadata, "builder").mockReturnValue(builder);
storageService.getBypassCache.mockResolvedValue("test"); const value = "test";
const serializedValue = JSON.stringify(value);
builder.mockReturnValue(value);
// Expect no circular messaging // Expect no circular messaging
await awaitAsync(); await awaitAsync();
expect(sendMessageSpy).toHaveBeenCalledTimes(0); expect(sendMessageSpy).toHaveBeenCalledTimes(0);
await sut.updateFromMessage({ command: `${sessionKey}_update`, id: "different_id" }); await sut.updateFromMessage({
command: `${sessionKey}_update`,
id: "different_id",
serializedValue,
});
await awaitAsync(); await awaitAsync();
expect(storageService.getBypassCache).toHaveBeenCalledTimes(1); expect(storageService.getBypassCache).toHaveBeenCalledTimes(0);
expect(storageService.getBypassCache).toHaveBeenCalledWith(sessionKey, {
deserializer: builder,
});
expect(nextSpy).toHaveBeenCalledTimes(1); expect(nextSpy).toHaveBeenCalledTimes(1);
expect(nextSpy).toHaveBeenCalledWith("test"); expect(nextSpy).toHaveBeenCalledWith(value);
expect(behaviorSubject.value).toBe("test"); expect(behaviorSubject.value).toBe(value);
// Expect no circular messaging // Expect no circular messaging
expect(sendMessageSpy).toHaveBeenCalledTimes(0); expect(sendMessageSpy).toHaveBeenCalledTimes(0);
}); });
}); });
describe("memory storage", () => {
const value = "test";
const serializedValue = JSON.stringify(value);
let saveSpy: jest.SpyInstance;
const builder = jest.fn().mockReturnValue(value);
const manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
const isBackgroundPageSpy = jest.spyOn(BrowserApi, "isBackgroundPage");
beforeEach(async () => {
jest.spyOn(SyncedItemMetadata, "builder").mockReturnValue(builder);
saveSpy = jest.spyOn(storageService, "save");
sut.init();
await awaitAsync();
});
afterEach(() => {
jest.resetAllMocks();
});
it("should always store on observed next for manifest version 3", async () => {
manifestVersionSpy.mockReturnValue(3);
isBackgroundPageSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
behaviorSubject.next(value);
await awaitAsync();
behaviorSubject.next(value);
await awaitAsync();
expect(saveSpy).toHaveBeenCalledTimes(2);
});
it("should not store on message receive for manifest version 3", async () => {
manifestVersionSpy.mockReturnValue(3);
isBackgroundPageSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
await sut.updateFromMessage({
command: `${sessionKey}_update`,
id: "different_id",
serializedValue,
});
await awaitAsync();
expect(saveSpy).toHaveBeenCalledTimes(0);
});
it("should store on message receive for manifest version 2 for background page only", async () => {
manifestVersionSpy.mockReturnValue(2);
isBackgroundPageSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
await sut.updateFromMessage({
command: `${sessionKey}_update`,
id: "different_id",
serializedValue,
});
await awaitAsync();
await sut.updateFromMessage({
command: `${sessionKey}_update`,
id: "different_id",
serializedValue,
});
await awaitAsync();
expect(saveSpy).toHaveBeenCalledTimes(1);
});
it("should store on observed next for manifest version 2 for background page only", async () => {
manifestVersionSpy.mockReturnValue(2);
isBackgroundPageSpy.mockReturnValueOnce(true).mockReturnValueOnce(false);
behaviorSubject.next(value);
await awaitAsync();
behaviorSubject.next(value);
await awaitAsync();
expect(saveSpy).toHaveBeenCalledTimes(1);
});
});
}); });

View File

@ -23,8 +23,8 @@ export class SessionSyncer {
throw new Error("subject must inherit from Subject"); throw new Error("subject must inherit from Subject");
} }
if (metaData.ctor == null && metaData.initializer == null) { if (metaData.initializer == null) {
throw new Error("ctor or initializer must be provided"); throw new Error("initializer must be provided");
} }
} }
@ -45,7 +45,7 @@ export class SessionSyncer {
// must be synchronous // must be synchronous
const hasInSessionMemory = await this.memoryStorageService.has(this.metaData.sessionKey); const hasInSessionMemory = await this.memoryStorageService.has(this.metaData.sessionKey);
if (hasInSessionMemory) { if (hasInSessionMemory) {
await this.update(); await this.updateFromMemory();
} }
this.listenForUpdates(); this.listenForUpdates();
@ -83,21 +83,31 @@ export class SessionSyncer {
if (message.command != this.updateMessageCommand || message.id === this.id) { if (message.command != this.updateMessageCommand || message.id === this.id) {
return; return;
} }
this.update(); await this.update(message.serializedValue);
} }
async update() { async updateFromMemory() {
const value = await this.memoryStorageService.getBypassCache(this.metaData.sessionKey);
await this.update(value);
}
async update(serializedValue: any) {
const unBuiltValue = JSON.parse(serializedValue);
if (BrowserApi.manifestVersion !== 3 && BrowserApi.isBackgroundPage(self)) {
await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue);
}
const builder = SyncedItemMetadata.builder(this.metaData); const builder = SyncedItemMetadata.builder(this.metaData);
const value = await this.memoryStorageService.getBypassCache(this.metaData.sessionKey, { const value = builder(unBuiltValue);
deserializer: builder,
});
this.ignoreNUpdates = 1; this.ignoreNUpdates = 1;
this.subject.next(value); this.subject.next(value);
} }
private async updateSession(value: any) { private async updateSession(value: any) {
await this.memoryStorageService.save(this.metaData.sessionKey, value); const serializedValue = JSON.stringify(value);
await BrowserApi.sendMessage(this.updateMessageCommand, { id: this.id }); if (BrowserApi.manifestVersion === 3 || BrowserApi.isBackgroundPage(self)) {
await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue);
}
await BrowserApi.sendMessage(this.updateMessageCommand, { id: this.id, serializedValue });
} }
private get updateMessageCommand() { private get updateMessageCommand() {

View File

@ -3,15 +3,11 @@ export type InitializeOptions = "array" | "record" | "object";
export class SyncedItemMetadata { export class SyncedItemMetadata {
propertyKey: string; propertyKey: string;
sessionKey: string; sessionKey: string;
ctor?: new () => any; initializer: (keyValuePair: any) => any;
initializer?: (keyValuePair: any) => any;
initializeAs: InitializeOptions; initializeAs: InitializeOptions;
static builder(metadata: SyncedItemMetadata): (o: any) => any { static builder(metadata: SyncedItemMetadata): (o: any) => any {
const itemBuilder = const itemBuilder = metadata.initializer;
metadata.initializer != null
? metadata.initializer
: (o: any) => Object.assign(new metadata.ctor(), o);
if (metadata.initializeAs === "array") { if (metadata.initializeAs === "array") {
return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o)); return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o));
} else if (metadata.initializeAs === "record") { } else if (metadata.initializeAs === "record") {

View File

@ -4,10 +4,8 @@ describe("builder", () => {
const propertyKey = "propertyKey"; const propertyKey = "propertyKey";
const key = "key"; const key = "key";
const initializer = (s: any) => "used initializer"; const initializer = (s: any) => "used initializer";
class TestClass {}
const ctor = TestClass;
it("should use initializer if provided", () => { it("should use initializer", () => {
const metadata: SyncedItemMetadata = { const metadata: SyncedItemMetadata = {
propertyKey, propertyKey,
sessionKey: key, sessionKey: key,
@ -18,29 +16,6 @@ describe("builder", () => {
expect(builder({})).toBe("used initializer"); expect(builder({})).toBe("used initializer");
}); });
it("should use ctor if initializer is not provided", () => {
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
ctor,
initializeAs: "object",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({})).toBeInstanceOf(TestClass);
});
it("should prefer initializer over ctor", () => {
const metadata: SyncedItemMetadata = {
propertyKey,
sessionKey: key,
ctor,
initializer,
initializeAs: "object",
};
const builder = SyncedItemMetadata.builder(metadata);
expect(builder({})).toBe("used initializer");
});
it("should honor initialize as array", () => { it("should honor initialize as array", () => {
const metadata: SyncedItemMetadata = { const metadata: SyncedItemMetadata = {
propertyKey, propertyKey,

View File

@ -8,7 +8,7 @@ import I18nService from "./i18n.service";
@browserSession @browserSession
export class BrowserI18nService extends I18nService { export class BrowserI18nService extends I18nService {
@sessionSync({ ctor: String }) @sessionSync({ initializer: (s: string) => s })
protected _locale: ReplaySubject<string>; protected _locale: ReplaySubject<string>;
constructor(systemLanguage: string, private stateService: StateService) { constructor(systemLanguage: string, private stateService: StateService) {

View File

@ -1,4 +1,5 @@
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { Jsonify } from "type-fest";
import { Policy } from "@bitwarden/common/models/domain/policy"; import { Policy } from "@bitwarden/common/models/domain/policy";
import { PolicyService } from "@bitwarden/common/services/policy/policy.service"; import { PolicyService } from "@bitwarden/common/services/policy/policy.service";
@ -7,6 +8,9 @@ import { browserSession, sessionSync } from "../decorators/session-sync-observab
@browserSession @browserSession
export class BrowserPolicyService extends PolicyService { export class BrowserPolicyService extends PolicyService {
@sessionSync({ ctor: Policy, initializeAs: "array" }) @sessionSync({
initializer: (obj: Jsonify<Policy>) => Object.assign(new Policy(), obj),
initializeAs: "array",
})
protected _policies: BehaviorSubject<Policy[]>; protected _policies: BehaviorSubject<Policy[]>;
} }

View File

@ -22,9 +22,9 @@ export class BrowserStateService
initializeAs: "record", initializeAs: "record",
}) })
protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>; protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>;
@sessionSync({ ctor: String }) @sessionSync({ initializer: (s: string) => s })
protected activeAccountSubject: BehaviorSubject<string>; protected activeAccountSubject: BehaviorSubject<string>;
@sessionSync({ ctor: Boolean }) @sessionSync({ initializer: (b: boolean) => b })
protected activeAccountUnlockedSubject: BehaviorSubject<boolean>; protected activeAccountUnlockedSubject: BehaviorSubject<boolean>;
@sessionSync({ @sessionSync({
initializer: Account.fromJSON as any, // TODO: Remove this any when all any types are removed from Account initializer: Account.fromJSON as any, // TODO: Remove this any when all any types are removed from Account