packages/storage-plugin/src/storage.plugin.ts
import { PLATFORM_ID, Injectable, inject } from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { ɵPlainObject } from '@ngxs/store/internals';
import {
NgxsPlugin,
setValue,
getValue,
InitState,
UpdateState,
actionMatcher,
NgxsNextPluginFn
} from '@ngxs/store/plugins';
import {
ɵDEFAULT_STATE_KEY,
ɵALL_STATES_PERSISTED,
ɵ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;
@Injectable()
export class NgxsStoragePlugin implements NgxsPlugin {
private _keysManager = inject(ɵNgxsStoragePluginKeysManager);
private _options = inject(ɵNGXS_STORAGE_PLUGIN_OPTIONS);
private _allStatesPersisted = inject(ɵALL_STATES_PERSISTED);
private _isServer = isPlatformServer(inject(PLATFORM_ID));
handle(state: any, event: any, next: NgxsNextPluginFn) {
if (this._isServer) {
return next(state, event);
}
const matches = actionMatcher(event);
const isInitAction = matches(InitState);
const isUpdateAction = matches(UpdateState);
const isInitOrUpdateAction = isInitAction || isUpdateAction;
let hasMigration = false;
if (isInitOrUpdateAction) {
const addedStates: ɵPlainObject = isUpdateAction && event.addedStates;
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._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
// notation and extracts the state name out of the key.
// Given the `key` is `myState.myProperty`, the `addedStates` will only contain `myState`.
const dotNotationIndex = key.indexOf(DOT);
const stateName = dotNotationIndex > -1 ? key.slice(0, dotNotationIndex) : key;
if (!addedStates.hasOwnProperty(stateName)) {
continue;
}
}
const storageKey = getStorageKey(key, this._options);
let storedValue: any = engine.getItem(storageKey);
if (storedValue !== 'undefined' && storedValue != null) {
try {
const newVal = this._options.deserialize!(storedValue);
storedValue = this._options.afterDeserialize!(newVal, key);
} catch {
typeof ngDevMode !== 'undefined' &&
ngDevMode &&
console.error(
`Error ocurred while deserializing the ${storageKey} store value, falling back to empty object, the value obtained from the store: `,
storedValue
);
storedValue = {};
}
this._options.migrations?.forEach(strategy => {
const versionMatch =
strategy.version === getValue(storedValue, strategy.versionKey || 'version');
const keyMatch =
(!strategy.key && this._allStatesPersisted) || strategy.key === key;
if (versionMatch && keyMatch) {
storedValue = strategy.migrate(storedValue);
hasMigration = true;
}
});
if (this._allStatesPersisted) {
storedValue = this._hydrateSelectivelyOnUpdate(storedValue, addedStates);
state = { ...state, ...storedValue };
} else {
state = setValue(state, key, storedValue);
}
}
}
}
return next(state, event).pipe(
tap(nextState => {
if (isInitOrUpdateAction && !hasMigration) {
return;
}
for (const { key, engine } of this._keysManager.getKeysWithEngines()) {
let storedValue = nextState;
const storageKey = getStorageKey(key, this._options);
if (key !== ɵDEFAULT_STATE_KEY) {
storedValue = getValue(nextState, key);
}
try {
const newStoredValue = this._options.beforeSerialize!(storedValue, key);
engine.setItem(storageKey, this._options.serialize!(newStoredValue));
} catch (error: any) {
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
if (
error &&
(error.name === 'QuotaExceededError' ||
error.name === 'NS_ERROR_DOM_QUOTA_REACHED')
) {
console.error(
`The ${storageKey} store value exceeds the browser storage quota: `,
storedValue
);
} else {
console.error(
`Error ocurred while serializing the ${storageKey} store value, value not updated, the value obtained from the store: `,
storedValue
);
}
}
}
}
})
);
}
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 = '.';