diff --git a/docs/plugins/storage.md b/docs/plugins/storage.md index bc916af80..e7cf8c26c 100644 --- a/docs/plugins/storage.md +++ b/docs/plugins/storage.md @@ -304,3 +304,35 @@ In the migration strategy, we define: - `key`: The key for the item to migrate. If not specified, it takes the entire storage state. Note: Its important to specify the strategies in the order of which they should progress. + +### Feature States + +We can also add states at the feature level when invoking `provideStates`, such as within `Route` providers. This is useful when we want to avoid the root level, responsible for providing the store, from being aware of any feature states. If we do not specify any states to be persisted at the root level, we should specify an empty list: + +```ts +import { provideStore } from '@ngxs/store'; +import { withNgxsStoragePlugin } from '@ngxs/storage-plugin'; + +export const appConfig: ApplicationConfig = { + providers: [provideStore([], withNgxsStoragePlugin({ keys: [] }))] +}; +``` + +If `keys` is an empty list, it indicates that the plugin should not persist any state until it's explicitly added at the feature level. + +After registering the `AnimalsState` at the feature level, we also want to persist this state in storage: + +```ts +import { provideStates } from '@ngxs/store'; +import { withStorageFeature } from '@ngxs/storage-plugin'; + +export const routes: Routes = [ + { + path: 'animals', + loadComponent: () => import('./animals'), + providers: [provideStates([AnimalsState], withStorageFeature([AnimalsState]))] + } +]; +``` + +Please note that at the root level, `keys` should not be set to `*` because `*` indicates persisting everything. diff --git a/packages/storage-plugin/internals/src/final-options.ts b/packages/storage-plugin/internals/src/final-options.ts deleted file mode 100644 index 2a7624646..000000000 --- a/packages/storage-plugin/internals/src/final-options.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { InjectionToken, Injector } from '@angular/core'; - -import { - STORAGE_ENGINE, - StorageEngine, - ɵNgxsTransformedStoragePluginOptions -} from './symbols'; -import { StorageKey, ɵextractStringKey, ɵisKeyWithExplicitEngine } from './storage-key'; - -export interface ɵFinalNgxsStoragePluginOptions extends ɵNgxsTransformedStoragePluginOptions { - keysWithEngines: { - key: string; - engine: StorageEngine; - }[]; -} - -declare const ngDevMode: boolean; - -const NG_DEV_MODE = typeof ngDevMode === 'undefined' || ngDevMode; - -export const ɵFINAL_NGXS_STORAGE_PLUGIN_OPTIONS = - new InjectionToken<ɵFinalNgxsStoragePluginOptions>( - NG_DEV_MODE ? 'FINAL_NGXS_STORAGE_PLUGIN_OPTIONS' : '' - ); - -export function ɵcreateFinalStoragePluginOptions( - injector: Injector, - options: ɵNgxsTransformedStoragePluginOptions -): ɵFinalNgxsStoragePluginOptions { - const storageKeys = options.keys; - - const keysWithEngines = storageKeys.map((storageKey: StorageKey) => { - const key = ɵextractStringKey(storageKey); - const engine = ɵisKeyWithExplicitEngine(storageKey) - ? injector.get(storageKey.engine) - : injector.get(STORAGE_ENGINE); - return { key, engine }; - }); - - return { - ...options, - keysWithEngines - }; -} diff --git a/packages/storage-plugin/internals/src/index.ts b/packages/storage-plugin/internals/src/index.ts index 2a5d15a62..0219a367b 100644 --- a/packages/storage-plugin/internals/src/index.ts +++ b/packages/storage-plugin/internals/src/index.ts @@ -1,3 +1,2 @@ export * from './symbols'; -export * from './final-options'; export * from './storage-key'; diff --git a/packages/storage-plugin/internals/src/symbols.ts b/packages/storage-plugin/internals/src/symbols.ts index 2ac331914..1c4dd4258 100644 --- a/packages/storage-plugin/internals/src/symbols.ts +++ b/packages/storage-plugin/internals/src/symbols.ts @@ -1,4 +1,4 @@ -import { InjectionToken } from '@angular/core'; +import { InjectionToken, inject } from '@angular/core'; import { StorageKey } from './storage-key'; @@ -87,6 +87,19 @@ export interface ɵNgxsTransformedStoragePluginOptions extends NgxsStoragePlugin keys: StorageKey[]; } +export const ɵUSER_OPTIONS = new InjectionToken( + NG_DEV_MODE ? 'USER_OPTIONS' : '' +); + +// Determines whether all states in the NGXS registry should be persisted or not. +export const ɵALL_STATES_PERSISTED = new InjectionToken( + NG_DEV_MODE ? 'ALL_STATES_PERSISTED' : '', + { + providedIn: 'root', + factory: () => inject(ɵUSER_OPTIONS).keys === '*' + } +); + export const ɵNGXS_STORAGE_PLUGIN_OPTIONS = new InjectionToken<ɵNgxsTransformedStoragePluginOptions>( NG_DEV_MODE ? 'NGXS_STORAGE_PLUGIN_OPTIONS' : '' diff --git a/packages/storage-plugin/src/keys-manager.ts b/packages/storage-plugin/src/keys-manager.ts new file mode 100644 index 000000000..9b5925654 --- /dev/null +++ b/packages/storage-plugin/src/keys-manager.ts @@ -0,0 +1,58 @@ +import { Injectable, Injector, inject } from '@angular/core'; +import { + STORAGE_ENGINE, + StorageEngine, + StorageKey, + ɵextractStringKey, + ɵisKeyWithExplicitEngine, + ɵNGXS_STORAGE_PLUGIN_OPTIONS +} from '@ngxs/storage-plugin/internals'; + +interface KeyWithEngine { + key: string; + engine: StorageEngine; +} + +@Injectable({ providedIn: 'root' }) +export class ɵNgxsStoragePluginKeysManager { + /** Store keys separately in a set so we're able to check if the key already exists. */ + private readonly _keys = new Set(); + + private readonly _injector = inject(Injector); + + private readonly _keysWithEngines: KeyWithEngine[] = []; + + constructor() { + const { keys } = inject(ɵNGXS_STORAGE_PLUGIN_OPTIONS); + this.addKeys(keys); + } + + getKeysWithEngines() { + // Spread to prevent external code from directly modifying the internal state. + return [...this._keysWithEngines]; + } + + addKeys(storageKeys: StorageKey[]): void { + for (const storageKey of storageKeys) { + const key = ɵextractStringKey(storageKey); + + // The user may call `withStorageFeature` with the same state multiple times. + // Let's prevent duplicating state names in the `keysWithEngines` list. + // Please note that calling provideStates multiple times with the same state is + // acceptable behavior. This may occur because the state could be necessary at the + // feature level, and different parts of the application might require its registration. + // Consequently, `withStorageFeature` may also be called multiple times. + if (this._keys.has(key)) { + continue; + } + + this._keys.add(key); + + const engine = ɵisKeyWithExplicitEngine(storageKey) + ? this._injector.get(storageKey.engine) + : this._injector.get(STORAGE_ENGINE); + + this._keysWithEngines.push({ key, engine }); + } + } +} diff --git a/packages/storage-plugin/src/public_api.ts b/packages/storage-plugin/src/public_api.ts index 1bee7deb7..836fb78d5 100644 --- a/packages/storage-plugin/src/public_api.ts +++ b/packages/storage-plugin/src/public_api.ts @@ -1,4 +1,5 @@ export { NgxsStoragePluginModule, withNgxsStoragePlugin } from './storage.module'; +export { withStorageFeature } from './with-storage-feature'; export { NgxsStoragePlugin } from './storage.plugin'; export * from './engines'; diff --git a/packages/storage-plugin/src/storage.module.ts b/packages/storage-plugin/src/storage.module.ts index 221d32103..1b7da5ab2 100644 --- a/packages/storage-plugin/src/storage.module.ts +++ b/packages/storage-plugin/src/storage.module.ts @@ -2,30 +2,21 @@ import { NgModule, ModuleWithProviders, PLATFORM_ID, - InjectionToken, - Injector, EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; import { withNgxsPlugin } from '@ngxs/store'; import { NGXS_PLUGINS } from '@ngxs/store/plugins'; import { - NgxsStoragePluginOptions, + ɵUSER_OPTIONS, STORAGE_ENGINE, ɵNGXS_STORAGE_PLUGIN_OPTIONS, - ɵcreateFinalStoragePluginOptions, - ɵFINAL_NGXS_STORAGE_PLUGIN_OPTIONS + NgxsStoragePluginOptions } from '@ngxs/storage-plugin/internals'; import { NgxsStoragePlugin } from './storage.plugin'; import { engineFactory, storageOptionsFactory } from './internals'; -declare const ngDevMode: boolean; - -const NG_DEV_MODE = typeof ngDevMode === 'undefined' || ngDevMode; - -export const USER_OPTIONS = new InjectionToken(NG_DEV_MODE ? 'USER_OPTIONS' : ''); - @NgModule() export class NgxsStoragePluginModule { static forRoot( @@ -40,23 +31,18 @@ export class NgxsStoragePluginModule { multi: true }, { - provide: USER_OPTIONS, + provide: ɵUSER_OPTIONS, useValue: options }, { provide: ɵNGXS_STORAGE_PLUGIN_OPTIONS, useFactory: storageOptionsFactory, - deps: [USER_OPTIONS] + deps: [ɵUSER_OPTIONS] }, { provide: STORAGE_ENGINE, useFactory: engineFactory, deps: [ɵNGXS_STORAGE_PLUGIN_OPTIONS, PLATFORM_ID] - }, - { - provide: ɵFINAL_NGXS_STORAGE_PLUGIN_OPTIONS, - useFactory: ɵcreateFinalStoragePluginOptions, - deps: [Injector, ɵNGXS_STORAGE_PLUGIN_OPTIONS] } ] }; @@ -69,23 +55,18 @@ export function withNgxsStoragePlugin( return makeEnvironmentProviders([ withNgxsPlugin(NgxsStoragePlugin), { - provide: USER_OPTIONS, + provide: ɵUSER_OPTIONS, useValue: options }, { provide: ɵNGXS_STORAGE_PLUGIN_OPTIONS, useFactory: storageOptionsFactory, - deps: [USER_OPTIONS] + deps: [ɵUSER_OPTIONS] }, { provide: STORAGE_ENGINE, useFactory: engineFactory, deps: [ɵNGXS_STORAGE_PLUGIN_OPTIONS, PLATFORM_ID] - }, - { - provide: ɵFINAL_NGXS_STORAGE_PLUGIN_OPTIONS, - useFactory: ɵcreateFinalStoragePluginOptions, - deps: [Injector, ɵNGXS_STORAGE_PLUGIN_OPTIONS] } ]); } diff --git a/packages/storage-plugin/src/storage.plugin.ts b/packages/storage-plugin/src/storage.plugin.ts index b32e5dd57..4786b8045 100644 --- a/packages/storage-plugin/src/storage.plugin.ts +++ b/packages/storage-plugin/src/storage.plugin.ts @@ -1,4 +1,4 @@ -import { PLATFORM_ID, Inject, Injectable } from '@angular/core'; +import { PLATFORM_ID, Inject, Injectable, inject } from '@angular/core'; import { isPlatformServer } from '@angular/common'; import { ɵPlainObject } from '@ngxs/store/internals'; import { @@ -12,12 +12,14 @@ import { } from '@ngxs/store/plugins'; import { ɵDEFAULT_STATE_KEY, - ɵFinalNgxsStoragePluginOptions, - ɵFINAL_NGXS_STORAGE_PLUGIN_OPTIONS + ɵALL_STATES_PERSISTED, + NgxsStoragePluginOptions, + ɵNGXS_STORAGE_PLUGIN_OPTIONS } from '@ngxs/storage-plugin/internals'; import { tap } from 'rxjs/operators'; import { getStorageKey } from './internals'; +import { ɵNgxsStoragePluginKeysManager } from './keys-manager'; declare const ngDevMode: boolean; @@ -25,14 +27,11 @@ const NG_DEV_MODE = typeof ngDevMode === 'undefined' || ngDevMode; @Injectable() export class NgxsStoragePlugin implements NgxsPlugin { - private _keysWithEngines = this._options.keysWithEngines; - // We default to `[ɵDEFAULT_STATE_KEY]` if the user explicitly does not provide the `key` option. - private _usesDefaultStateKey = - this._keysWithEngines.length === 1 && this._keysWithEngines[0].key === ɵDEFAULT_STATE_KEY; + private _allStatesPersisted = inject(ɵALL_STATES_PERSISTED); constructor( - @Inject(ɵFINAL_NGXS_STORAGE_PLUGIN_OPTIONS) - private _options: ɵFinalNgxsStoragePluginOptions, + private _keysManager: ɵNgxsStoragePluginKeysManager, + @Inject(ɵNGXS_STORAGE_PLUGIN_OPTIONS) private _options: NgxsStoragePluginOptions, @Inject(PLATFORM_ID) private _platformId: string ) {} @@ -48,14 +47,14 @@ export class NgxsStoragePlugin implements NgxsPlugin { let hasMigration = false; if (isInitOrUpdateAction) { - const addedStates = isUpdateAction && event.addedStates; + const addedStates: ɵPlainObject = isUpdateAction && event.addedStates; - for (const { key, engine } of this._keysWithEngines) { + for (const { key, engine } of this._keysManager.getKeysWithEngines()) { // We're checking what states have been added by NGXS and if any of these states should be handled by // the storage plugin. For instance, we only want to deserialize the `auth` state, NGXS has added // the `user` state, the storage plugin will be rerun and will do redundant deserialization. // `usesDefaultStateKey` is necessary to check since `event.addedStates` never contains `@@STATE`. - if (!this._usesDefaultStateKey && addedStates) { + if (!this._allStatesPersisted && addedStates) { // We support providing keys that can be deeply nested via dot notation, for instance, // `keys: ['myState.myProperty']` is a valid key. // The state name should always go first. The below code checks if the `key` includes dot @@ -89,46 +88,18 @@ export class NgxsStoragePlugin implements NgxsPlugin { const versionMatch = strategy.version === getValue(storedValue, strategy.versionKey || 'version'); const keyMatch = - (!strategy.key && this._usesDefaultStateKey) || strategy.key === key; + (!strategy.key && this._allStatesPersisted) || strategy.key === key; if (versionMatch && keyMatch) { storedValue = strategy.migrate(storedValue); hasMigration = true; } }); - if (!this._usesDefaultStateKey) { - state = setValue(state, key, storedValue); - } else { - // The `UpdateState` action is dispatched whenever the feature - // state is added. The condition below is satisfied only when - // the `UpdateState` action is dispatched. Let's consider two states: - // `counter` and `@ngxs/router-plugin` state. When we call `NgxsModule.forRoot()`, - // `CounterState` is provided at the root level, while `@ngxs/router-plugin` - // is provided as a feature state. Beforehand, the storage plugin may have - // stored the value of the counter state as `10`. If `CounterState` implements - // the `ngxsOnInit` hook and calls `ctx.setState(999)`, the storage plugin - // will rehydrate the entire state when the `RouterState` is registered. - // Consequently, the `counter` state will revert back to `10` instead of `999`. - if (storedValue && addedStates && Object.keys(addedStates).length > 0) { - storedValue = Object.keys(addedStates).reduce( - (accumulator, addedState) => { - // The `storedValue` can be equal to the entire state when the default - // state key is used. However, if `addedStates` only contains the `router` value, - // we only want to merge the state with the `router` value. - // Let's assume that the `storedValue` is an object: - // `{ counter: 10, router: {...} }` - // This will pick only the `router` object from the `storedValue` and `counter` - // state will not be rehydrated unnecessary. - if (storedValue.hasOwnProperty(addedState)) { - accumulator[addedState] = storedValue[addedState]; - } - return accumulator; - }, - <ɵPlainObject>{} - ); - } - + if (this._allStatesPersisted) { + storedValue = this._hydrateSelectivelyOnUpdate(storedValue, addedStates); state = { ...state, ...storedValue }; + } else { + state = setValue(state, key, storedValue); } } } @@ -140,7 +111,7 @@ export class NgxsStoragePlugin implements NgxsPlugin { return; } - for (const { key, engine } of this._keysWithEngines) { + for (const { key, engine } of this._keysManager.getKeysWithEngines()) { let storedValue = nextState; const storageKey = getStorageKey(key, this._options); @@ -175,6 +146,40 @@ export class NgxsStoragePlugin implements NgxsPlugin { }) ); } + + private _hydrateSelectivelyOnUpdate(storedValue: any, addedStates: ɵPlainObject) { + // The `UpdateState` action is triggered whenever a feature state is added. + // The condition below is only satisfied when this action is triggered. + // Let's consider two states: `counter` and `@ngxs/router-plugin` state. + // When `provideStore` is called, `CounterState` is provided at the root level, + // while `@ngxs/router-plugin` is provided as a feature state. Previously, the storage + // plugin might have stored the value of the counter state as `10`. If `CounterState` + // implements the `ngxsOnInit` hook and sets the state to `999`, the storage plugin will + // reset the entire state when the `RouterState` is registered. + // Consequently, the `counter` state will revert back to `10` instead of `999`. + + if (!storedValue || !addedStates || Object.keys(addedStates).length === 0) { + // Nothing to update if `addedStates` object is empty. + return storedValue; + } + + // The `storedValue` can be the entire state when the default state key + // is used. However, if `addedStates` only contains the `router` value, + // we only want to merge the state with that `router` value. + // Given the `storedValue` is an object: + // `{ counter: 10, router: {...} }` + // This will only select the `router` object from the `storedValue`, + // avoiding unnecessary rehydration of the `counter` state. + return Object.keys(addedStates).reduce( + (accumulator, addedState) => { + if (storedValue.hasOwnProperty(addedState)) { + accumulator[addedState] = storedValue[addedState]; + } + return accumulator; + }, + <ɵPlainObject>{} + ); + } } const DOT = '.'; diff --git a/packages/storage-plugin/src/with-storage-feature.ts b/packages/storage-plugin/src/with-storage-feature.ts new file mode 100644 index 000000000..33c3cbea1 --- /dev/null +++ b/packages/storage-plugin/src/with-storage-feature.ts @@ -0,0 +1,44 @@ +import { + inject, + EnvironmentProviders, + ENVIRONMENT_INITIALIZER, + makeEnvironmentProviders +} from '@angular/core'; +import { StorageKey, ɵALL_STATES_PERSISTED } from '@ngxs/storage-plugin/internals'; + +import { ɵNgxsStoragePluginKeysManager } from './keys-manager'; + +declare const ngDevMode: boolean; + +const NG_DEV_MODE = typeof ngDevMode !== 'undefined' && ngDevMode; + +export function withStorageFeature(storageKeys: StorageKey[]): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useValue: () => { + const allStatesPersisted = inject(ɵALL_STATES_PERSISTED); + + if (allStatesPersisted) { + if (NG_DEV_MODE) { + const message = + 'The NGXS storage plugin is currently persisting all states because the `keys` ' + + 'option was explicitly set to `*` at the root level. To selectively persist states, ' + + 'consider explicitly specifying them, allowing for addition at the feature level.'; + + console.error(message); + } + + // We should prevent the addition of any feature states to persistence + // if the `keys` property is set to `*`, as this could disrupt the algorithm + // used in the storage plugin. Instead, we should log an error in development + // mode. In production, it should continue to function, but act as a no-op. + return; + } + + inject(ɵNgxsStoragePluginKeysManager).addKeys(storageKeys); + } + } + ]); +} diff --git a/packages/storage-plugin/tests/storage-for-feature.spec.ts b/packages/storage-plugin/tests/storage-for-feature.spec.ts new file mode 100644 index 000000000..0112071e2 --- /dev/null +++ b/packages/storage-plugin/tests/storage-for-feature.spec.ts @@ -0,0 +1,123 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { APP_BASE_HREF } from '@angular/common'; +import { ApplicationConfig, Component, Injectable, NgZone } from '@angular/core'; +import { + Router, + RouterOutlet, + provideRouter, + withDisabledInitialNavigation +} from '@angular/router'; + +import { State, Store, provideStates, provideStore } from '@ngxs/store'; +import { freshPlatform, skipConsoleLogging } from '@ngxs/store/internals/testing'; + +import { NgxsStoragePluginOptions, withNgxsStoragePlugin, withStorageFeature } from '..'; + +describe('forFeature', () => { + interface CounterStateModel { + count: number; + } + + @State({ + name: 'counter', + defaults: { count: 0 } + }) + @Injectable() + class CounterState {} + + interface BlogStateModel { + pages: number[]; + } + + @State({ + name: 'blog', + defaults: { pages: [] } + }) + @Injectable() + class BlogState {} + + @Component({ selector: 'app-blog', template: 'This is blog', standalone: true }) + class BlogComponent {} + + @Component({ + selector: 'app-root', + template: '', + standalone: true, + imports: [RouterOutlet] + }) + class TestComponent {} + + const setupAppConfig = (options: NgxsStoragePluginOptions) => { + const appConfig: ApplicationConfig = { + providers: [ + { provide: APP_BASE_HREF, useValue: '/' }, + + provideRouter( + [ + { + path: 'blog', + loadComponent: () => BlogComponent, + providers: [provideStates([BlogState], withStorageFeature([BlogState]))] + } + ], + withDisabledInitialNavigation() + ), + + provideStore([CounterState], withNgxsStoragePlugin(options)) + ] + }; + + return appConfig; + }; + + it( + 'should de-serialize the state when provided through forFeature', + freshPlatform(async () => { + // Arrange + localStorage.setItem('counter', JSON.stringify({ counter: { count: 100 } })); + localStorage.setItem('blog', JSON.stringify({ pages: [1, 2, 3] })); + + const appConfig = setupAppConfig({ + keys: [CounterState] + }); + + const { injector } = await skipConsoleLogging(() => + bootstrapApplication(TestComponent, appConfig) + ); + const router = injector.get(Router); + const ngZone = injector.get(NgZone); + const store = injector.get(Store); + // Act + await ngZone.run(() => router.navigateByUrl('/blog')); + // Assert + expect(store.snapshot().blog).toEqual({ pages: [1, 2, 3] }); + }) + ); + + it( + 'should log an error if the `keys` property is set to `*`', + freshPlatform(async () => { + // Arrange + const spy = jest.spyOn(console, 'error'); + + const appConfig = setupAppConfig({ + keys: '*' + }); + + const { injector } = await bootstrapApplication(TestComponent, appConfig); + const router = injector.get(Router); + const ngZone = injector.get(NgZone); + + // Act + await ngZone.run(() => router.navigateByUrl('/blog')); + + try { + expect(spy).toHaveBeenCalledWith( + expect.stringMatching(/The NGXS storage plugin is currently persisting all states/) + ); + } finally { + spy.mockRestore(); + } + }) + ); +});