Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-3530] browser extension view cache #10437

Merged
merged 49 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
dbbf307
[PM-8589] persist dirty form data (wip)
willmartian Jun 9, 2024
c887bab
[PM-8589] serialize value comparsion in CL select components
willmartian Jun 9, 2024
96a71fc
[PM-8589] test implementation in send-add-edit and vault-popup-list-fโ€ฆ
willmartian Jun 9, 2024
8f9bb2d
[PM-8588] persist extension route history (wip)
willmartian Jun 9, 2024
64e6bab
temp: override feature flag
willmartian Jun 9, 2024
4185010
gate behind FeatureFlag.ExtensionRefresh
willmartian Jun 9, 2024
286f909
share single StateDefinition
willmartian Jun 9, 2024
0c25260
use large object storage; use global state provider with manual clearโ€ฆ
willmartian Jun 10, 2024
38a6c32
add new feature flag
willmartian Jun 19, 2024
9b4ac8f
wip
willmartian Jun 21, 2024
e432d28
wip; add tests for guard
willmartian Jun 25, 2024
6d4d97b
resolve merge conflict
willmartian Jun 25, 2024
1a6eb9f
add flag to service back method
willmartian Jun 25, 2024
13d3e40
wip
willmartian Jul 2, 2024
d88e7e8
add tests; fix bug
willmartian Jul 2, 2024
f274a02
rename
willmartian Jul 2, 2024
ff93cd0
resolve merge conflicts
willmartian Jul 2, 2024
b033a70
remove feature flag override
willmartian Jul 2, 2024
d6ef9c0
revert usage examples
willmartian Jul 2, 2024
6a0d99a
fix merge error
willmartian Jul 2, 2024
6a97de6
resolve merge conflicts
willmartian Jul 18, 2024
76b4c94
remove unecessary call to clear state
willmartian Jul 26, 2024
8d2aab9
fix account switch clear state after latest merge commit
willmartian Jul 26, 2024
e2a3863
save route history to URL param when building popout url
willmartian Jul 26, 2024
aded1e5
resolve merge conflicts
willmartian Jul 26, 2024
28431e2
cr: remove promise in subscribe; remove comment from test
willmartian Jul 26, 2024
c07b840
vicki cr changes
willmartian Jul 26, 2024
33f86d0
add back method test
willmartian Jul 26, 2024
2541915
fix popup utils tests
willmartian Jul 26, 2024
a732ef5
remove 'T' prefix from type names
willmartian Jul 31, 2024
0081a4e
Merge branch 'main' into ps/PM-3530/popup-view-persistence
willmartian Aug 2, 2024
30e3997
rename view cache service
willmartian Aug 2, 2024
9ad6259
rename files
willmartian Aug 3, 2024
88d235a
add default implementation to libs/angular
willmartian Aug 3, 2024
5a3337f
move & rename files
willmartian Aug 3, 2024
025e771
remove view cache references to split PR
willmartian Aug 7, 2024
6674b20
Revert "remove view cache references to split PR"
willmartian Aug 7, 2024
da7a7cf
merge in main and resolve conflicts
willmartian Aug 13, 2024
431d942
revert browser-popup-utils and pop-out component
willmartian Aug 13, 2024
afd12db
add to services module
willmartian Aug 20, 2024
1ff8b48
remove DS change
willmartian Aug 20, 2024
a557ec4
remove temp changes
willmartian Aug 20, 2024
a8e2af7
Merge branch 'main' into ps/PM-3530/view-cache
willmartian Aug 20, 2024
8ac50e4
update comment
willmartian Aug 21, 2024
811761b
add feature flag
willmartian Aug 22, 2024
86b05f8
split noop implementation and abstract class
willmartian Aug 22, 2024
556683a
add comments
willmartian Aug 22, 2024
1f22860
add to jslib services module
willmartian Aug 22, 2024
ff6e42a
add flag to update state
willmartian Aug 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/browser/src/background/main.background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ export default class MainBackground {
);

this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService(
messageListener,
this.globalStateProvider,
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
DestroyRef,
effect,
inject,
Injectable,
Injector,
signal,
WritableSignal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormGroup } from "@angular/forms";
import { NavigationEnd, Router } from "@angular/router";
import { filter, firstValueFrom, skip } from "rxjs";
import { Jsonify } from "type-fest";

import {
FormCacheOptions,
SignalCacheOptions,
ViewCacheService,
} from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";

import {
ClEAR_VIEW_CACHE_COMMAND,
POPUP_VIEW_CACHE_KEY,
SAVE_VIEW_CACHE_COMMAND,
} from "../../services/popup-view-cache-background.service";

/**
* Popup implementation of {@link ViewCacheService}.
*
* Persists user changes between popup open and close
*/
@Injectable({
providedIn: "root",
})
export class PopupViewCacheService implements ViewCacheService {
private configService = inject(ConfigService);
private globalStateProvider = inject(GlobalStateProvider);
private messageSender = inject(MessageSender);
private router = inject(Router);

private featureEnabled: boolean;

private _cache: Record<string, string>;
private get cache(): Record<string, string> {
if (!this._cache) {
throw new Error("Dirty View Cache not initialized");

Check warning on line 51 in apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts

View check run for this annotation

Codecov / codecov/patch

apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts#L51

Added line #L51 was not covered by tests
}
return this._cache;
}

/**
* Initialize the service. This should only be called once.
*/
async init() {
this.featureEnabled = await this.configService.getFeatureFlag(FeatureFlag.PersistPopupView);
const initialState = this.featureEnabled
? await firstValueFrom(this.globalStateProvider.get(POPUP_VIEW_CACHE_KEY).state$)
: {};
this._cache = Object.freeze(initialState ?? {});

this.router.events
.pipe(
filter((e) => e instanceof NavigationEnd),
/** Skip the first navigation triggered by `popupRouterCacheGuard` */
skip(1),
)
.subscribe(() => this.clearState());
}

/**
* @see {@link ViewCacheService.signal}
*/
signal<T>(options: SignalCacheOptions<T>): WritableSignal<T> {
const {
deserializer = (v: Jsonify<T>): T => v as T,
key,
injector = inject(Injector),
initialValue,
} = options;
const cachedValue = this.cache[key] ? deserializer(JSON.parse(this.cache[key])) : initialValue;
const _signal = signal(cachedValue);

effect(
() => {
this.updateState(key, JSON.stringify(_signal()));
},
{ injector },
);

return _signal;
}

/**
* @see {@link ViewCacheService.formGroup}
*/
formGroup<TFormGroup extends FormGroup>(options: FormCacheOptions<TFormGroup>): TFormGroup {
const { control, injector } = options;

const _signal = this.signal({
...options,
initialValue: control.getRawValue(),
});

const value = _signal();
if (value !== undefined && JSON.stringify(value) !== JSON.stringify(control.getRawValue())) {
control.setValue(value);
control.markAsDirty();
}

control.valueChanges.pipe(takeUntilDestroyed(injector?.get(DestroyRef))).subscribe(() => {
_signal.set(control.getRawValue());
});

return control;
}

private updateState(key: string, value: string) {
if (!this.featureEnabled) {
return;

Check warning on line 124 in apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts

View check run for this annotation

Codecov / codecov/patch

apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts#L124

Added line #L124 was not covered by tests
}

this.messageSender.send(SAVE_VIEW_CACHE_COMMAND, {
key,
value,
});
}

private clearState() {
this.messageSender.send(ClEAR_VIEW_CACHE_COMMAND, {});
}
}
224 changes: 224 additions & 0 deletions apps/browser/src/platform/popup/view-cache/popup-view-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { Component, inject, Injector } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { FormControl, FormGroup } from "@angular/forms";
import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";

import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/common/spec";

import {
ClEAR_VIEW_CACHE_COMMAND,
POPUP_VIEW_CACHE_KEY,
SAVE_VIEW_CACHE_COMMAND,
} from "../../services/popup-view-cache-background.service";

import { PopupViewCacheService } from "./popup-view-cache.service";

@Component({ template: "" })
export class EmptyComponent {}

@Component({ template: "" })
export class TestComponent {
private viewCacheService = inject(PopupViewCacheService);

formGroup = this.viewCacheService.formGroup({
key: "test-form-cache",
control: new FormGroup({
name: new FormControl("initial name"),
}),
});

signal = this.viewCacheService.signal({
key: "test-signal",
initialValue: "initial signal",
});
}

describe("popup view cache", () => {
const configServiceMock = mock<ConfigService>();
let testBed: TestBed;
let service: PopupViewCacheService;
let fakeGlobalState: FakeGlobalState<Record<string, string>>;
let messageSenderMock: MockProxy<MessageSender>;
let router: Router;

const initServiceWithState = async (state: Record<string, string>) => {
await fakeGlobalState.update(() => state);
await service.init();
};

beforeEach(async () => {
jest.spyOn(configServiceMock, "getFeatureFlag").mockResolvedValue(true);
messageSenderMock = mock<MessageSender>();

const fakeGlobalStateProvider = new FakeGlobalStateProvider();
fakeGlobalState = fakeGlobalStateProvider.getFake(POPUP_VIEW_CACHE_KEY);

testBed = TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "a", component: EmptyComponent },
{ path: "b", component: EmptyComponent },
]),
],
providers: [
{ provide: GlobalStateProvider, useValue: fakeGlobalStateProvider },
{ provide: MessageSender, useValue: messageSenderMock },
{ provide: ConfigService, useValue: configServiceMock },
],
});

await testBed.compileComponents();

router = testBed.inject(Router);
service = testBed.inject(PopupViewCacheService);
});

it("should initialize signal when ran within an injection context", async () => {
await initServiceWithState({});

const signal = TestBed.runInInjectionContext(() =>
service.signal({
key: "foo-123",
initialValue: "foo",
}),
);

expect(signal()).toBe("foo");
});

it("should initialize signal when provided an injector", async () => {
await initServiceWithState({});

const injector = TestBed.inject(Injector);

const signal = service.signal({
key: "foo-123",
initialValue: "foo",
injector,
});

expect(signal()).toBe("foo");
});

it("should initialize signal from state", async () => {
await initServiceWithState({ "foo-123": JSON.stringify("bar") });

const injector = TestBed.inject(Injector);

const signal = service.signal({
key: "foo-123",
initialValue: "foo",
injector,
});

expect(signal()).toBe("bar");
});

it("should initialize form from state", async () => {
await initServiceWithState({ "test-form-cache": JSON.stringify({ name: "baz" }) });

const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentRef.instance;
expect(component.formGroup.value.name).toBe("baz");
expect(component.formGroup.dirty).toBe(true);
});

it("should not modify form when empty", async () => {
await initServiceWithState({});

const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentRef.instance;
expect(component.formGroup.value.name).toBe("initial name");
expect(component.formGroup.dirty).toBe(false);
});

it("should utilize deserializer", async () => {
await initServiceWithState({ "foo-123": JSON.stringify("bar") });

const injector = TestBed.inject(Injector);

const signal = service.signal({
key: "foo-123",
initialValue: "foo",
injector,
deserializer: (jsonValue) => "test",
});

expect(signal()).toBe("test");
});

it("should not utilize deserializer when empty", async () => {
await initServiceWithState({});

const injector = TestBed.inject(Injector);

const signal = service.signal({
key: "foo-123",
initialValue: "foo",
injector,
deserializer: (jsonValue) => "test",
});

expect(signal()).toBe("foo");
});

it("should send signal updates to message sender", async () => {
await initServiceWithState({});

const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentRef.instance;
component.signal.set("Foobar");
fixture.detectChanges();

expect(messageSenderMock.send).toHaveBeenCalledWith(SAVE_VIEW_CACHE_COMMAND, {
key: "test-signal",
value: JSON.stringify("Foobar"),
});
});

it("should send form updates to message sender", async () => {
await initServiceWithState({});

const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentRef.instance;
component.formGroup.controls.name.setValue("Foobar");
fixture.detectChanges();

expect(messageSenderMock.send).toHaveBeenCalledWith(SAVE_VIEW_CACHE_COMMAND, {
key: "test-form-cache",
value: JSON.stringify({ name: "Foobar" }),
});
});

it("should clear on 2nd navigation", async () => {
await initServiceWithState({});

await router.navigate(["a"]);
expect(messageSenderMock.send).toHaveBeenCalledTimes(0);

await router.navigate(["b"]);
expect(messageSenderMock.send).toHaveBeenCalledWith(ClEAR_VIEW_CACHE_COMMAND, {});
});

it("should ignore cached values when feature flag is off", async () => {
jest.spyOn(configServiceMock, "getFeatureFlag").mockResolvedValue(false);

await initServiceWithState({ "foo-123": JSON.stringify("bar") });

const injector = TestBed.inject(Injector);

const signal = service.signal({
key: "foo-123",
initialValue: "foo",
injector,
});

// The cached state is ignored
expect(signal()).toBe("foo");
});
});
Loading
Loading