UNPKG

@tanstack/persister

Version:

Utilities for persisting state to local storage, session storage, indexedDB, and more.

1 lines 10.7 kB
{"version":3,"file":"storage-persister.cjs","sources":["../../src/storage-persister.ts"],"sourcesContent":["import { Persister } from './persister'\nimport type { RequiredKeys } from './types'\n\nexport interface PersistedStorage<\n TState,\n TSelected extends Partial<TState> = TState,\n> {\n buster?: string\n state: TSelected | undefined\n timestamp: number\n}\n\n/**\n * Configuration options for creating a browser-based state persister.\n *\n * The persister can use either localStorage (persists across browser sessions) or\n * sessionStorage (cleared when browser tab/window closes) to store serialized state.\n */\nexport interface StoragePersisterOptions<\n TState,\n TSelected extends Partial<TState> = TState,\n> {\n /**\n * A version string used to invalidate cached state. When changed, any existing\n * stored state will be considered invalid and cleared.\n */\n buster?: string\n /**\n * The default state to use if no state is found in storage.\n */\n defaultState?: TState\n /**\n * Optional function to customize how state is deserialized after loading from storage.\n * By default, JSON.parse is used.\n *\n * Optionally, consider using SuperJSON for better deserialization of complex objects. See https://github.com/flightcontrolhq/superjson\n */\n deserializer?: (state: string) => PersistedStorage<TSelected>\n /**\n * Unique identifier used as the storage key for persisting state.\n */\n key: string\n /**\n * Maximum age in milliseconds before stored state is considered expired.\n * When exceeded, the state will be cleared and treated as if it doesn't exist.\n */\n maxAge?: number\n /**\n * Optional callback that runs after state is successfully loaded.\n */\n onLoadState?: (\n state: TSelected | undefined,\n storagePersister: StoragePersister<TState, TSelected>,\n ) => void\n /**\n * Optional callback that runs after state is unable to be loaded.\n */\n onLoadStateError?: (\n error: Error,\n storagePersister: StoragePersister<TState, TSelected>,\n ) => void\n /**\n * Optional callback that runs after state is successfully saved.\n */\n onSaveState?: (\n state: TSelected,\n storagePersister: StoragePersister<TState, TSelected>,\n ) => void\n /**\n * Optional callback that runs after state is unable to be saved.\n * For example, if the storage is full (localStorage >= 5MB)\n */\n onSaveStateError?: (\n error: Error,\n storagePersister: StoragePersister<TState, TSelected>,\n ) => void\n /**\n * Optional function to customize how state is serialized before saving to storage.\n * By default, JSON.stringify is used.\n *\n * Optionally, consider using SuperJSON for better serialization of complex objects. See https://github.com/flightcontrolhq/superjson\n */\n serializer?: (state: PersistedStorage<TSelected>) => string\n /**\n * Optional function to filter which parts of the state are persisted and loaded.\n * When provided, only the filtered state will be saved to storage and returned when loading.\n * This is useful for excluding sensitive or temporary data from persistence.\n *\n * Note: Don't use this to replace the serialization. Use the `serializer` option instead for that.\n */\n select?: (state: TState) => TSelected\n /**\n * The browser storage implementation to use for persisting state.\n * Typically window.localStorage or window.sessionStorage.\n *\n * Defaults to window.localStorage.\n */\n storage?: Storage | null\n}\n\ntype DefaultOptions = RequiredKeys<\n Partial<StoragePersisterOptions<any>>,\n 'deserializer' | 'serializer' | 'storage'\n>\n\nconst defaultOptions: DefaultOptions = {\n deserializer: JSON.parse,\n serializer: JSON.stringify,\n storage: typeof window !== 'undefined' ? window.localStorage : null,\n}\n\n/**\n * A persister that saves state to browser local/session storage.\n *\n * The persister can use either localStorage (persists across browser sessions) or\n * sessionStorage (cleared when browser tab/window closes). State is automatically\n * serialized to JSON when saving and deserialized when loading.\n *\n * Optionally, a `buster` string can be provided to force cache busting by storing it in the value.\n * Optionally, a `maxAge` (in ms) can be provided to expire the stored state after a certain duration.\n * Optionally, callbacks can be provided to run after state is saved or loaded.\n *\n * @example\n * ```ts\n * const persister = new StoragePersister({\n * key: 'my-rate-limiter', // required\n * storage: window.localStorage,\n * buster: 'v2',\n * maxAge: 1000 * 60 * 60, // 1 hour\n * stateTransform: (state) => ({\n * // Only persist specific parts of the state\n * count: state.count,\n * lastReset: state.lastReset,\n * // Exclude sensitive or temporary data\n * }),\n * onSaveState: (key, state) => console.log('State saved:', key, state),\n * onLoadState: (key, state) => console.log('State loaded:', key, state),\n * onLoadStateError: (key, error) => console.error('Error loading state:', key, error),\n * onSaveStateError: (key, error) => console.error('Error saving state:', key, error)\n * })\n * ```\n */\nexport class StoragePersister<\n TState,\n TSelected extends Partial<TState> = TState,\n> extends Persister<TState, TSelected> {\n options: StoragePersisterOptions<TState, TSelected> & DefaultOptions\n\n constructor(initialOptions: StoragePersisterOptions<TState, TSelected>) {\n super(initialOptions.key)\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n }\n }\n\n /**\n * Updates the persister options\n */\n setOptions = (\n newOptions: Partial<StoragePersisterOptions<TState, TSelected>>,\n ): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n /**\n * Saves the state to storage\n */\n saveState = (state: TState | TSelected): void => {\n try {\n const stateToSave = this.options.select\n ? this.options.select(state)\n : state\n\n this.options.storage?.setItem(\n this.key,\n this.options.serializer({\n buster: this.options.buster,\n state: stateToSave,\n timestamp: Date.now(),\n }),\n )\n this.options.onSaveState?.(state, this)\n } catch (error) {\n console.error(error)\n this.options.onSaveStateError?.(error as Error, this)\n }\n }\n\n /**\n * Loads the state from storage\n */\n loadState = (): TSelected | undefined => {\n const stored = this.options.storage?.getItem(this.key)\n if (!stored) {\n return undefined\n }\n\n try {\n const parsed = this.options.deserializer(stored)\n\n const isValidVersion =\n !this.options.buster || parsed.buster === this.options.buster\n\n const isNotExpired =\n !this.options.maxAge ||\n !parsed.timestamp ||\n Date.now() - parsed.timestamp <= this.options.maxAge\n\n if (!isValidVersion || !isNotExpired) {\n // clear the item from storage\n this.options.storage?.removeItem(this.key)\n return undefined\n }\n\n const state = parsed.state\n this.options.onLoadState?.(state, this)\n return state\n } catch (error) {\n console.error(error)\n this.options.onLoadStateError?.(error as Error, this)\n return undefined\n }\n }\n\n /**\n * Handles the storage event and ignores events triggered by the current tab.\n *\n * The storage event is only fired in other tabs/windows, not the one that made the change.\n * However, some browsers or environments may fire it in the same tab, so we check if the event's storageArea\n * matches the persister's storage and ignore if not.\n */\n private handleStorageChange = (e: StorageEvent) => {\n // Ignore events not from the same storage area or not matching our key\n if (e.storageArea !== this.options.storage) {\n return\n }\n // Ignore events triggered by the current tab (storage event is not fired in the same tab in most browsers)\n // But this check is defensive in case of non-standard environments\n if (e.key === this.key && e.newValue) {\n this.loadState()\n }\n }\n\n subscribeToStorage = (): void => {\n typeof window !== 'undefined' &&\n window.addEventListener('storage', this.handleStorageChange)\n }\n\n unsubscribeFromStorage = (): void => {\n typeof window !== 'undefined' &&\n window.removeEventListener('storage', this.handleStorageChange)\n }\n\n /**\n * Clears the state from storage or sets the default state if provided and specified to be used\n */\n clearState = (useDefaultState: boolean = false): void => {\n if (useDefaultState) {\n this.saveState(this.options.defaultState)\n } else {\n this.options.storage?.removeItem(this.key)\n }\n }\n}\n"],"names":["Persister"],"mappings":";;;AAyGA,MAAM,iBAAiC;AAAA,EACrC,cAAc,KAAK;AAAA,EACnB,YAAY,KAAK;AAAA,EACjB,SAAS,OAAO,WAAW,cAAc,OAAO,eAAe;AACjE;AAiCO,MAAM,yBAGHA,UAAAA,UAA6B;AAAA,EAGrC,YAAY,gBAA4D;AACtE,UAAM,eAAe,GAAG;AAU1B,SAAA,aAAa,CACX,eACS;AACT,WAAK,UAAU,EAAE,GAAG,KAAK,SAAS,GAAG,WAAA;AAAA,IAAW;AAMlD,SAAA,YAAY,CAAC,UAAoC;AAC/C,UAAI;AACF,cAAM,cAAc,KAAK,QAAQ,SAC7B,KAAK,QAAQ,OAAO,KAAK,IACzB;AAEJ,aAAK,QAAQ,SAAS;AAAA,UACpB,KAAK;AAAA,UACL,KAAK,QAAQ,WAAW;AAAA,YACtB,QAAQ,KAAK,QAAQ;AAAA,YACrB,OAAO;AAAA,YACP,WAAW,KAAK,IAAA;AAAA,UAAI,CACrB;AAAA,QAAA;AAEH,aAAK,QAAQ,cAAc,OAAO,IAAI;AAAA,MAAA,SAC/B,OAAO;AACd,gBAAQ,MAAM,KAAK;AACnB,aAAK,QAAQ,mBAAmB,OAAgB,IAAI;AAAA,MAAA;AAAA,IACtD;AAMF,SAAA,YAAY,MAA6B;AACvC,YAAM,SAAS,KAAK,QAAQ,SAAS,QAAQ,KAAK,GAAG;AACrD,UAAI,CAAC,QAAQ;AACX,eAAO;AAAA,MAAA;AAGT,UAAI;AACF,cAAM,SAAS,KAAK,QAAQ,aAAa,MAAM;AAE/C,cAAM,iBACJ,CAAC,KAAK,QAAQ,UAAU,OAAO,WAAW,KAAK,QAAQ;AAEzD,cAAM,eACJ,CAAC,KAAK,QAAQ,UACd,CAAC,OAAO,aACR,KAAK,IAAA,IAAQ,OAAO,aAAa,KAAK,QAAQ;AAEhD,YAAI,CAAC,kBAAkB,CAAC,cAAc;AAEpC,eAAK,QAAQ,SAAS,WAAW,KAAK,GAAG;AACzC,iBAAO;AAAA,QAAA;AAGT,cAAM,QAAQ,OAAO;AACrB,aAAK,QAAQ,cAAc,OAAO,IAAI;AACtC,eAAO;AAAA,MAAA,SACA,OAAO;AACd,gBAAQ,MAAM,KAAK;AACnB,aAAK,QAAQ,mBAAmB,OAAgB,IAAI;AACpD,eAAO;AAAA,MAAA;AAAA,IACT;AAUF,SAAQ,sBAAsB,CAAC,MAAoB;AAEjD,UAAI,EAAE,gBAAgB,KAAK,QAAQ,SAAS;AAC1C;AAAA,MAAA;AAIF,UAAI,EAAE,QAAQ,KAAK,OAAO,EAAE,UAAU;AACpC,aAAK,UAAA;AAAA,MAAU;AAAA,IACjB;AAGF,SAAA,qBAAqB,MAAY;AAC/B,aAAO,WAAW,eAChB,OAAO,iBAAiB,WAAW,KAAK,mBAAmB;AAAA,IAAA;AAG/D,SAAA,yBAAyB,MAAY;AACnC,aAAO,WAAW,eAChB,OAAO,oBAAoB,WAAW,KAAK,mBAAmB;AAAA,IAAA;AAMlE,SAAA,aAAa,CAAC,kBAA2B,UAAgB;AACvD,UAAI,iBAAiB;AACnB,aAAK,UAAU,KAAK,QAAQ,YAAY;AAAA,MAAA,OACnC;AACL,aAAK,QAAQ,SAAS,WAAW,KAAK,GAAG;AAAA,MAAA;AAAA,IAC3C;AAhHA,SAAK,UAAU;AAAA,MACb,GAAG;AAAA,MACH,GAAG;AAAA,IAAA;AAAA,EACL;AA+GJ;;"}