Prevent persist store to overwrite storage #814
-
|
Current listeners persist options In the case for an asynchronous storage (React Native), where you need for an user input to retrieve the data (biometrics prompt), if not provided or an error appears, the hydration continues with an empty value and sets an empty store, overwriting storage with it. I think the |
Beta Was this translation helpful? Give feedback.
Replies: 4 comments 4 replies
-
|
Seems related to #405 |
Beta Was this translation helpful? Give feedback.
-
How I managed to make
|
Beta Was this translation helpful? Give feedback.
-
|
When using an async storage with zustand i encounter the following problem set:
To prevent these edge cases i wrote following utilty to work with zustand's persist middleware easily. export const useCountingStore = create<TStore>()(
persist(
subscribeWithSelector((...set) => ({
...initialValues,
})),
createPersistOptions_forAsyncZustandStorage_withZodValidation({
initialValues,
storeIn: "indexedDB",
name: "useCountingStore",
persistSchema: ZCountingStore_thatsPersistedInIndexedDb,
partialize: (s) => R.pick(s, Object.keys(pick_forPersistence) as (keyof TStore)[]),
})
)
)
/**
* Protects overwriting the persisted state with initial values, before the store was hydrated
*/
export function createPersistOptions_forAsyncZustandStorage_withZodValidation<
State extends {},
PersistedSchema extends z.AnyZodObject
>(
input: {
storeIn: "indexedDB"
name: string
initialValues: State
persistSchema: PersistedSchema
partialize: (state: State) => z.output<PersistedSchema>
} & PersistOptions<State, z.output<PersistedSchema>>
): PersistOptions<State, z.output<PersistedSchema>> {
let wasStoreHydrated = false
let rehydratedStore_with: z.output<PersistedSchema> | null = null
const storageToUse = createAsyncZustandStorage_withZodValidation({
schema: input.persistSchema,
initialValues: input.partialize(input.initialValues),
storage: input.storeIn,
})
if (!storageToUse) return { name: input.name }
return {
...input,
storage: (() => {
if (!storageToUse) return undefined
return {
getItem: async (name) => {
const val = await storageToUse.getItem(name)
if (!wasStoreHydrated) {
rehydratedStore_with = val?.state ?? null
}
return val
},
removeItem: storageToUse.removeItem,
setItem: (name, value) => {
if (!wasStoreHydrated) {
const partialInitialValues = input.partialize(input.initialValues)
const doesState_toWrite_equalInitialValues = isDeepEqual(
value.state,
partialInitialValues
)
if (doesState_toWrite_equalInitialValues) {
return
}
// is expected to be encountered once (is fired before onRehydrateStorage returned function is fired )
const doesState_toWrite_equalRehydrateValues = isDeepEqual(
value.state,
rehydratedStore_with
)
if (doesState_toWrite_equalRehydrateValues) {
return
}
console.error(
`${name} - setItem invoked with state different from initial values, before store was hydrated`,
{ value: value.state, partialInitialValues },
new Error().stack
)
}
if (wasStoreHydrated) {
storageToUse.setItem(name, value)
}
},
} as PersistStorage<z.output<PersistedSchema>>
})(),
onRehydrateStorage: (state) => {
//console.debug("rehydrated storage start")
return (state) => {
// console.debug("rehydrated storage done")
wasStoreHydrated = true
if (state) {
const stateToPersist = input.partialize(state)
storageToUse?.setItem(input.name, { state: stateToPersist })
// console.debug("persist store after rehydration done", stateToPersist)
}
}
},
}
}
function createAsyncZustandStorage_withZodValidation<S extends z.AnyZodObject>(input: {
schema: S
storage: "indexedDB"
initialValues: z.infer<S>
}): PersistStorage<unknown> | undefined {
// when server rendering we dont want to persist any values / read any persisted values
if (typeof window === "undefined") return undefined
const storageAdapter: {
setItem: (name: string, value: string) => void | Promise<void>
getItem: (name: string) => string | null | Promise<string | null | undefined>
removeItem: (name: string) => void
} = (() => {
if (input.storage === "indexedDB") {
return {
setItem: idbKeyval.set,
getItem: idbKeyval.get,
removeItem: idbKeyval.del,
}
}
// for typesafety - if we forget one "storage enum kind" typescript will remind us
const __exhaustiveCheck: never = input.storage
throw new Error("unreachable code")
})()
const storageValueSchema = z.object({
state: input.schema,
version: z.number().int().optional(),
})
const throttledSetItem = throttle({
fn: async (name: string, value: StorageValue<unknown>) => {
await Result.fromAsync(async () => {
const stringified = superjson.stringify(value)
await storageAdapter.setItem(name, stringified)
}).then((res) => res.inspectErr(console.error))
},
interval_inMs: 500,
options: {trailing: true}
})
window.addEventListener("pagehide", () => {
throttledSetItem.flush()
})
window.addEventListener("beforeunload", () => {
throttledSetItem.flush()
})
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
throttledSetItem.flush()
}
})
// Chrome Page Lifecycle: when the page is about to be frozen (e.g., reload/background)
// we flush to ensure no writes are lost.
document.addEventListener("freeze", () => {
throttledSetItem.flush()
})
return {
setItem: throttledSetItem,
removeItem: (name) => {
void Result.from(() => {
storageAdapter.removeItem(name)
}).inspectErr(console.error)
},
/**
*
* @param name name of the store (not the individual field-key of a store)
*/
getItem: async (name) => {
const parseResult = await Result.fromAsync(async () => {
const valInStorage =
(await storageAdapter.getItem(name)) ?? raise(`no value in storage under name: ${name}`)
const valInStorage_superjsonParsed = superjson.parse(valInStorage) as z.infer<
typeof storageValueSchema
> // typecasting is okay here, since we use zod parsing in the next step
/** use initial values and overwrite them with persisted values (in order to satisfy the input zod schemata when envolving the schema) */
return storageValueSchema.parse({
version: valInStorage_superjsonParsed.version,
state: { ...input.initialValues, ...valInStorage_superjsonParsed.state },
})
})
if (parseResult.isOk()) {
const unwrapped = parseResult.unwrap()
return { state: unwrapped.state ?? null, version: unwrapped.version }
}
return null
},
}
}
/**
* trailing=true: führt einmal am Ende des Intervalls mit den letzten Argumenten aus (geeignet um “letzten Stand” zu schreiben).
*/
export function throttle<Func extends (...args: any[]) => any>(input: {
fn: Func
interval_inMs: number
options?: {
leading?: boolean
trailing?: boolean
}
}): Func & {
cancel: () => void
flush: () => void
} {
const { leading, trailing } = (() => {
if (input.options?.leading && input.options?.trailing) {
console.error("Only leading or trailing can be true, not both")
return { leading: true, trailing: false }
}
if (input.options?.leading) {
return { leading: true, trailing: false }
}
if (input.options?.trailing) {
return { leading: false, trailing: true }
}
return { leading: true, trailing: false }
})()
let timeoutId: NodeJS.Timeout | null = null
let throttledFn: (() => void) | null = null
const cancel = function () {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
}
const flush = function () {
const call = throttledFn
cancel()
if (call) {
call()
}
}
const throttleWrapper = function (...args: any[]) {
let callNow = leading && !timeoutId
throttledFn = () => {
return input.fn(...args)
}
if (!timeoutId) {
timeoutId = setTimeout(function () {
timeoutId = null
if (trailing) {
return throttledFn!()
}
}, input.interval_inMs)
}
if (callNow) {
callNow = false
return throttledFn!()
}
}
throttleWrapper.cancel = cancel
throttleWrapper.flush = flush
return throttleWrapper as Func & {
cancel: () => void
flush: () => void
}
}
export function raise(theErrorMessage?: string | Error): never {
if (theErrorMessage instanceof Error) throw theErrorMessage
throw new Error(theErrorMessage)
}Note: the code used here uses some utility functions from the following packages. Feel free to install these / use equivalent helpers from your own codebase: import { z } from "zod"
import superjson from "superjson"
import isDeepEqual from "fast-deep-equal/es6"
import { Result } from "@sapphire/result"
import * as R from "remeda" |
Beta Was this translation helpful? Give feedback.
-
|
@LeonMueller-OneAndOnly wouldn't skipHydration help in this case? https://zustand.docs.pmnd.rs/middlewares/persist#persisting-a-state-and-hydrate-it-manually |
Beta Was this translation helpful? Give feedback.
How I managed to make
zustand persistaccept asynchronous storage.First of all, I want to acknowledge the simplicity with which this library was created, otherwise I wouldn't have the confidence to look into its source code and try a workaround. Big up team! 🙌🏼
My hopes are that this is not just a hack, but serves as an inspiration to others or maybe even a good fix for this amazing library.
Problem
As I said earlier, my issue was that
zustand'spersistmiddleware was overwriting the storage on hydrate, because there was no "initial" way of making it to or check for a condition before overwriting the storage or wait for a trigger to start hydration, before getting the storage - in case o…