import type { StateCreator, StoreApi, StoreMutatorIdentifier } from '../vanilla' export interface StateStorage { getItem: (name: string) => string | null | Promise setItem: (name: string, value: string) => R removeItem: (name: string) => R } export type StorageValue = { state: S version?: number } export interface PersistStorage { getItem: (name: string) => StorageValue | null | Promise | null> setItem: (name: string, value: StorageValue) => R removeItem: (name: string) => R } type JsonStorageOptions = { reviver?: (key: string, value: unknown) => unknown replacer?: (key: string, value: unknown) => unknown } export function createJSONStorage( getStorage: () => StateStorage, options?: JsonStorageOptions ): PersistStorage | undefined { let storage: StateStorage | undefined try { storage = getStorage() } catch { // prevent error if the storage is not defined (e.g. when server side rendering a page) return } const persistStorage: PersistStorage = { getItem: (name) => { const parse = (str: string | null) => { if (str === null) { return null } return JSON.parse(str, options?.reviver) as StorageValue } const str = storage.getItem(name) ?? null if (str instanceof Promise) { return str.then(parse) } return parse(str) }, setItem: (name, newValue) => storage.setItem(name, JSON.stringify(newValue, options?.replacer)), removeItem: (name) => storage.removeItem(name) } return persistStorage } export interface PersistOptions { /** Name of the storage (must be unique) */ name: string /** * Use a custom persist storage. * * Combining `createJSONStorage` helps creating a persist storage * with JSON.parse and JSON.stringify. * * @default createJSONStorage(() => localStorage) */ storage?: PersistStorage | undefined /** * Filter the persisted value. * * @params state The state's value */ partialize?: (state: S) => PersistedState /** * A function returning another (optional) function. * The main function will be called before the state rehydration. * The returned function will be called after the state rehydration or when an error occurred. */ onRehydrateStorage?: (state: S) => ((state?: S, error?: unknown) => void) | void /** * If the stored state's version mismatch the one specified here, the storage will not be used. * This is useful when adding a breaking change to your store. */ version?: number /** * A function to perform persisted state migration. * This function will be called when persisted state versions mismatch with the one specified here. */ migrate?: (persistedState: unknown, version: number) => PersistedState | Promise /** * A function to perform custom hydration merges when combining the stored state with the current one. * By default, this function does a shallow merge. */ merge?: (persistedState: unknown, currentState: S) => S /** * An optional boolean that will prevent the persist middleware from triggering hydration on initialization, * This allows you to call `rehydrate()` at a specific point in your apps rendering life-cycle. * * This is useful in SSR application. * * @default false */ skipHydration?: boolean } type PersistListener = (state: S) => void type StorePersist = S extends { getState: () => infer T setState: { // capture both overloads of setState (...args: infer Sa1): infer Sr1 (...args: infer Sa2): infer Sr2 } } ? { setState(...args: Sa1): Sr1 | Pr setState(...args: Sa2): Sr2 | Pr persist: { setOptions: (options: Partial>) => void clearStorage: () => void rehydrate: () => Promise | void hasHydrated: () => boolean onHydrate: (fn: PersistListener) => () => void onFinishHydration: (fn: PersistListener) => () => void getOptions: () => Partial> } } : never type Thenable = { then(onFulfilled: (value: Value) => V | Promise | Thenable): Thenable catch(onRejected: (reason: Error) => V | Promise | Thenable): Thenable } const toThenable = (fn: (input: Input) => Result | Promise | Thenable) => (input: Input): Thenable => { try { const result = fn(input) if (result instanceof Promise) { return result as Thenable } return { then(onFulfilled) { return toThenable(onFulfilled)(result as Result) }, catch(_onRejected) { return this as Thenable } } } catch (e: any) { return { then(_onFulfilled) { return this as Thenable }, catch(onRejected) { return toThenable(onRejected)(e) } } } } const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => { type S = ReturnType let options = { storage: createJSONStorage(() => localStorage), partialize: (state: S) => state, version: 0, merge: (persistedState: unknown, currentState: S) => ({ ...currentState, ...(persistedState as object) }), ...baseOptions } let hasHydrated = false const hydrationListeners = new Set>() const finishHydrationListeners = new Set>() let storage = options.storage if (!storage) { return config( (...args) => { console.warn( `[zustand persist middleware] Unable to update item '${options.name}', the given storage is currently unavailable.` ) set(...(args as Parameters)) }, get, api ) } const setItem = () => { const state = options.partialize({ ...get() }) return (storage as PersistStorage).setItem(options.name, { state, version: options.version }) } const savedSetState = api.setState api.setState = (state, replace) => { savedSetState(state, replace as any) return setItem() } const configResult = config( (...args) => { set(...(args as Parameters)) return setItem() }, get, api ) api.getInitialState = () => configResult // a workaround to solve the issue of not storing rehydrated state in sync storage // the set(state) value would be later overridden with initial state by create() // to avoid this, we merge the state from localStorage into the initial state. let stateFromStorage: S | undefined // rehydrate initial state with existing stored state const hydrate = () => { if (!storage) return // On the first invocation of 'hydrate', state will not yet be defined (this is // true for both the 'asynchronous' and 'synchronous' case). Pass 'configResult' // as a backup to 'get()' so listeners and 'onRehydrateStorage' are called with // the latest available state. hasHydrated = false hydrationListeners.forEach((cb) => cb(get() ?? configResult)) const postRehydrationCallback = options.onRehydrateStorage?.(get() ?? configResult) || undefined // bind is used to avoid `TypeError: Illegal invocation` error return toThenable(storage.getItem.bind(storage))(options.name) .then((deserializedStorageValue) => { if (deserializedStorageValue) { if ( typeof deserializedStorageValue.version === 'number' && deserializedStorageValue.version !== options.version ) { if (options.migrate) { const migration = options.migrate( deserializedStorageValue.state, deserializedStorageValue.version ) if (migration instanceof Promise) { return migration.then((result) => [true, result] as const) } return [true, migration] as const } console.error( `State loaded from storage couldn't be migrated since no migrate function was provided` ) } else { return [false, deserializedStorageValue.state] as const } } return [false, undefined] as const }) .then((migrationResult) => { const [migrated, migratedState] = migrationResult stateFromStorage = options.merge(migratedState as S, get() ?? configResult) set(stateFromStorage as S, true) if (migrated) { return setItem() } }) .then(() => { // TODO: In the asynchronous case, it's possible that the state has changed // since it was set in the prior callback. As such, it would be better to // pass 'get()' to the 'postRehydrationCallback' to ensure the most up-to-date // state is used. However, this could be a breaking change, so this isn't being // done now. postRehydrationCallback?.(stateFromStorage, undefined) // It's possible that 'postRehydrationCallback' updated the state. To ensure // that isn't overwritten when returning 'stateFromStorage' below // (synchronous-case only), update 'stateFromStorage' to point to the latest // state. In the asynchronous case, 'stateFromStorage' isn't used after this // callback, so there's no harm in updating it to match the latest state. stateFromStorage = get() hasHydrated = true finishHydrationListeners.forEach((cb) => cb(stateFromStorage as S)) }) .catch((e: Error) => { postRehydrationCallback?.(undefined, e) }) } ;(api as StoreApi & StorePersist, S, unknown>).persist = { setOptions: (newOptions) => { options = { ...options, ...newOptions } if (newOptions.storage) { storage = newOptions.storage } }, clearStorage: () => { storage?.removeItem(options.name) }, getOptions: () => options, rehydrate: () => hydrate() as Promise, hasHydrated: () => hasHydrated, onHydrate: (cb) => { hydrationListeners.add(cb) return () => { hydrationListeners.delete(cb) } }, onFinishHydration: (cb) => { finishHydrationListeners.add(cb) return () => { finishHydrationListeners.delete(cb) } } } if (!options.skipHydration) { hydrate() } return stateFromStorage || configResult } type Persist = < T, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = [], U = T >( initializer: StateCreator, options: PersistOptions ) => StateCreator declare module '../vanilla' { interface StoreMutators { 'zustand/persist': WithPersist } } type Write = Omit & U type WithPersist = Write> type PersistImpl = ( storeInitializer: StateCreator, options: PersistOptions ) => StateCreator export const persist = persistImpl as unknown as Persist