1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

chore(view-cache): [PM-21154] Move view-cache its own feature package and adjust imports

* Moved view-cache services to directory

* Fixed DI for browser extension.

* Fixed tests.
This commit is contained in:
Todd Martin
2025-05-12 14:26:52 -04:00
committed by GitHub
parent a10b9530af
commit eed18c9294
21 changed files with 155 additions and 26 deletions

View File

@@ -0,0 +1 @@
export { ViewCacheService, FormCacheOptions, SignalCacheOptions } from "./view-cache.service";

View File

@@ -0,0 +1 @@
export { NoopViewCacheService } from "./noop-view-cache.service";

View File

@@ -1,11 +1,7 @@
import { Injectable, signal, WritableSignal } from "@angular/core";
import type { FormGroup } from "@angular/forms";
import {
FormCacheOptions,
SignalCacheOptions,
ViewCacheService,
} from "../abstractions/view-cache.service";
import { FormCacheOptions, SignalCacheOptions, ViewCacheService } from "./view-cache.service";
/**
* The functionality of the {@link ViewCacheService} is only needed in the browser extension popup,

View File

@@ -0,0 +1,130 @@
# Extension Persistence
By default, when the browser extension popup closes, the user's current view and any data entered
without saving is lost. This introduces friction in several workflows within our client, such as:
- Performing actions that require email OTP entry, since the user must navigate from the popup to
get to their email inbox
- Entering information to create a new vault item from a browser tab
- And many more
Previously, we have recommended that users "pop out" the extension into its own window to persist
the extension context, but this introduces additional user actions and may leave the extension open
(and unlocked) for longer than a user intends.
In order to provide a better user experience, we have introduced two levels of persistence to the
Bitwarden extension client:
- We persist the route history, allowing us to re-open the last route when the popup re-opens, and
- We offer a service for teams to use to persist component-specific form data or state to survive a
popup close/re-open cycle
## Persistence lifetime
Since we are persisting data, it is important that the lifetime of that data be well-understood and
well-constrained. The cache of route history and form data is cleared when any of the following
events occur:
- The account is locked
- The account is logged out
- Account switching is used to switch the active account
- The extension popup has been closed for 2 minutes
In addition, cached form data is cleared when a browser extension navigation event occurs (e.g.
switching between tabs in the extension).
## Types of persistence
### Route history persistence
Route history is persisted on the extension automatically, with no specific implementation required
on any component.
The persistence layer ensures that the popup will open at the same route as was active when it
closed, provided that none of the lifetime expiration events have occurred.
:::tip Excluding a route
If a particular route should be excluded from the history and not persisted, add
`doNotSaveUrl: true` to the `data` property on the route.
:::
### View data persistence
Route persistence ensures that the user will land back on the route that they were on when the popup
closed, but it does not persist any state or form data that the user may have modified. In order to
persist that data, the component is responsible for registering that data with the
[`ViewCacheService`](./view-cache.service.ts).
This is done prescriptively to ensure that only necessary data is cached and that it is done with
intention by the component.
The `ViewCacheService` provides an interface for caching both individual state and `FormGroup`s.
#### Caching individual data elements
For individual pieces of state, use the `signal()` method on the `ViewCacheService` to create a
writeable [signal](https://angular.dev/guide/signals) wrapper around the desired state.
```typescript
const mySignal = this.viewCacheService.signal({
key: "my-state-key"
initialValue: null
});
```
If a cached value exists, the returned signal will contain the cached data.
Setting the value should be done through the signal's `set()` method:
```typescript
const mySignal = this.viewCacheService.signal({
key: "my-state-key"
initialValue: null
});
mySignal.set("value")
```
:::note Equality comparison
By default, signals use `Object.is` to determine equality, and `set()` will only trigger updates if
the updated value is not equal to the current signal state. See documentation
[here](https://angular.dev/guide/signals#signal-equality-functions).
:::
Putting this together, the most common implementation pattern would be:
1. **Register the signal** using `ViewCacheService.signal()` on initialization of the component or
service responsible for the state being persisted.
2. **Restore state from the signal:** If cached data exists, the signal will contain that data. The
component or service should use this data to re-create the state from prior to the popup closing.
3. **Set new state** in the cache when it changes. Ensure that any updates to the data are persisted
to the cache with `set()`, so that the cache reflects the latest state.
#### Caching form data
For persisting form data, the `ViewCacheService` supplies a `formGroup()` method, which manages the
persistence of any entered form data to the cache and the initialization of the form from the cached
data. You can supply the `FormGroup` in the `control` parameter of the method, and the
`ViewCacheService` will:
- Initialize the form the a cached value, if it exists
- Save form value to cache when it changes
- Mark the form dirty if the restored value is not `undefined`.
```typescript
this.loginDetailsForm = this.viewCacheService.formGroup({
key: "my-form",
control: this.formBuilder.group({
username: [""],
email: [""],
}),
});
```
## What about other clients?
The `ViewCacheService` is designed to be injected into shared, client-agnostic components. A
`NoopViewCacheService` is provided and injected for non-extension clients, preserving a single
interface for your components.

View File

@@ -42,6 +42,8 @@ export type FormCacheOptions<TFormGroup extends FormGroup> = BaseCacheOptions<
/**
* Cache for temporary component state
*
* [Read more](./view-cache.md)
*
* #### Implementations
* - browser extension popup: used to persist UI between popup open and close
* - all other clients: noop

View File

@@ -325,13 +325,14 @@ import {
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { NoopViewCacheService } from "../platform/services/noop-view-cache.service";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
import { ViewCacheService } from "../platform/view-cache";
// eslint-disable-next-line no-restricted-imports -- Needed for DI
import { NoopViewCacheService } from "../platform/view-cache/internal";
import {
CLIENT_TYPE,

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -1,7 +1,7 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { Jsonify } from "type-fest";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -2,7 +2,7 @@ import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -1,7 +1,7 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { Jsonify } from "type-fest";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -1,7 +1,7 @@
import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";

View File

@@ -1,6 +1,6 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@@ -13,7 +13,7 @@ import {
import { BehaviorSubject } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";

View File

@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { mock } from "jest-mock-extended";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";

View File

@@ -1,7 +1,7 @@
import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";

View File

@@ -1,6 +1,6 @@
import { inject, Injectable } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";