feat(middleare/persist): return storage promise from setState (#3206)

* feat(middleare/persist): return storage promise from setState

* refactor types (technically breaking)

* another breaking change in types

* make public types not breaking
This commit is contained in:
Daishi Kato 2025-08-20 08:08:35 +09:00 committed by GitHub
parent 2cc19881fa
commit feddc0c210
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 49 additions and 32 deletions

View File

@ -4,10 +4,10 @@ import type {
StoreMutatorIdentifier, StoreMutatorIdentifier,
} from '../vanilla.ts' } from '../vanilla.ts'
export interface StateStorage { export interface StateStorage<R = unknown> {
getItem: (name: string) => string | null | Promise<string | null> getItem: (name: string) => string | null | Promise<string | null>
setItem: (name: string, value: string) => unknown | Promise<unknown> setItem: (name: string, value: string) => R
removeItem: (name: string) => unknown | Promise<unknown> removeItem: (name: string) => R
} }
export type StorageValue<S> = { export type StorageValue<S> = {
@ -15,12 +15,12 @@ export type StorageValue<S> = {
version?: number version?: number
} }
export interface PersistStorage<S> { export interface PersistStorage<S, R = unknown> {
getItem: ( getItem: (
name: string, name: string,
) => StorageValue<S> | null | Promise<StorageValue<S> | null> ) => StorageValue<S> | null | Promise<StorageValue<S> | null>
setItem: (name: string, value: StorageValue<S>) => unknown | Promise<unknown> setItem: (name: string, value: StorageValue<S>) => R
removeItem: (name: string) => unknown | Promise<unknown> removeItem: (name: string) => R
} }
type JsonStorageOptions = { type JsonStorageOptions = {
@ -28,18 +28,18 @@ type JsonStorageOptions = {
replacer?: (key: string, value: unknown) => unknown replacer?: (key: string, value: unknown) => unknown
} }
export function createJSONStorage<S>( export function createJSONStorage<S, R = unknown>(
getStorage: () => StateStorage, getStorage: () => StateStorage<R>,
options?: JsonStorageOptions, options?: JsonStorageOptions,
): PersistStorage<S> | undefined { ): PersistStorage<S, unknown> | undefined {
let storage: StateStorage | undefined let storage: StateStorage<R> | undefined
try { try {
storage = getStorage() storage = getStorage()
} catch { } catch {
// prevent error if the storage is not defined (e.g. when server side rendering a page) // prevent error if the storage is not defined (e.g. when server side rendering a page)
return return
} }
const persistStorage: PersistStorage<S> = { const persistStorage: PersistStorage<S, R> = {
getItem: (name) => { getItem: (name) => {
const parse = (str: string | null) => { const parse = (str: string | null) => {
if (str === null) { if (str === null) {
@ -60,7 +60,11 @@ export function createJSONStorage<S>(
return persistStorage return persistStorage
} }
export interface PersistOptions<S, PersistedState = S> { export interface PersistOptions<
S,
PersistedState = S,
PersistReturn = unknown,
> {
/** Name of the storage (must be unique) */ /** Name of the storage (must be unique) */
name: string name: string
/** /**
@ -71,7 +75,7 @@ export interface PersistOptions<S, PersistedState = S> {
* *
* @default createJSONStorage(() => localStorage) * @default createJSONStorage(() => localStorage)
*/ */
storage?: PersistStorage<PersistedState> | undefined storage?: PersistStorage<PersistedState, PersistReturn> | undefined
/** /**
* Filter the persisted value. * Filter the persisted value.
* *
@ -118,17 +122,28 @@ export interface PersistOptions<S, PersistedState = S> {
type PersistListener<S> = (state: S) => void type PersistListener<S> = (state: S) => void
type StorePersist<S, Ps> = { type StorePersist<S, Ps, Pr> = S extends {
persist: { getState: () => infer T
setOptions: (options: Partial<PersistOptions<S, Ps>>) => void setState: {
clearStorage: () => void // capture both overloads of setState
rehydrate: () => Promise<void> | void (...args: infer Sa1): infer Sr1
hasHydrated: () => boolean (...args: infer Sa2): infer Sr2
onHydrate: (fn: PersistListener<S>) => () => void
onFinishHydration: (fn: PersistListener<S>) => () => void
getOptions: () => Partial<PersistOptions<S, Ps>>
} }
} }
? {
setState(...args: Sa1): Sr1 | Pr
setState(...args: Sa2): Sr2 | Pr
persist: {
setOptions: (options: Partial<PersistOptions<T, Ps, Pr>>) => void
clearStorage: () => void
rehydrate: () => Promise<void> | void
hasHydrated: () => boolean
onHydrate: (fn: PersistListener<T>) => () => void
onFinishHydration: (fn: PersistListener<T>) => () => void
getOptions: () => Partial<PersistOptions<T, Ps, Pr>>
}
}
: never
type Thenable<Value> = { type Thenable<Value> = {
then<V>( then<V>(
@ -172,7 +187,7 @@ const toThenable =
const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => { const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
type S = ReturnType<typeof config> type S = ReturnType<typeof config>
let options = { let options = {
storage: createJSONStorage<S>(() => localStorage), storage: createJSONStorage<S, void>(() => localStorage),
partialize: (state: S) => state, partialize: (state: S) => state,
version: 0, version: 0,
merge: (persistedState: unknown, currentState: S) => ({ merge: (persistedState: unknown, currentState: S) => ({
@ -202,7 +217,7 @@ const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
const setItem = () => { const setItem = () => {
const state = options.partialize({ ...get() }) const state = options.partialize({ ...get() })
return (storage as PersistStorage<S>).setItem(options.name, { return (storage as PersistStorage<S, unknown>).setItem(options.name, {
state, state,
version: options.version, version: options.version,
}) })
@ -212,13 +227,13 @@ const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
api.setState = (state, replace) => { api.setState = (state, replace) => {
savedSetState(state, replace as any) savedSetState(state, replace as any)
void setItem() return setItem()
} }
const configResult = config( const configResult = config(
(...args) => { (...args) => {
set(...(args as Parameters<typeof set>)) set(...(args as Parameters<typeof set>))
void setItem() return setItem()
}, },
get, get,
api, api,
@ -307,7 +322,7 @@ const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
}) })
} }
;(api as StoreApi<S> & StorePersist<S, S>).persist = { ;(api as StoreApi<S> & StorePersist<StoreApi<S>, S, unknown>).persist = {
setOptions: (newOptions) => { setOptions: (newOptions) => {
options = { options = {
...options, ...options,
@ -365,9 +380,7 @@ declare module '../vanilla' {
type Write<T, U> = Omit<T, keyof U> & U type Write<T, U> = Omit<T, keyof U> & U
type WithPersist<S, A> = S extends { getState: () => infer T } type WithPersist<S, A> = Write<S, StorePersist<S, A, unknown>>
? Write<S, StorePersist<T, A>>
: never
type PersistImpl = <T>( type PersistImpl = <T>(
storeInitializer: StateCreator<T, [], []>, storeInitializer: StateCreator<T, [], []>,

View File

@ -165,7 +165,9 @@ describe('persist middleware with async configuration', () => {
}) })
// Write something to the store // Write something to the store
act(() => useBoundStore.setState({ count: 42 })) act(() => {
useBoundStore.setState({ count: 42 })
})
expect(await screen.findByText('count: 42')).toBeInTheDocument() expect(await screen.findByText('count: 42')).toBeInTheDocument()
expect(setItemSpy).toBeCalledWith( expect(setItemSpy).toBeCalledWith(
'test-storage', 'test-storage',
@ -788,7 +790,9 @@ describe('persist middleware with async configuration', () => {
// Write something to the store // Write something to the store
const updatedMap = new Map(map).set('foo', 'bar') const updatedMap = new Map(map).set('foo', 'bar')
act(() => useBoundStore.setState({ map: updatedMap })) act(() => {
useBoundStore.setState({ map: updatedMap })
})
expect(await screen.findByText('map-content: bar')).toBeInTheDocument() expect(await screen.findByText('map-content: bar')).toBeInTheDocument()
expect(setItemSpy).toBeCalledWith( expect(setItemSpy).toBeCalledWith(