diff --git a/.changeset/few-ties-boil.md b/.changeset/few-ties-boil.md new file mode 100644 index 000000000..8911f9f87 --- /dev/null +++ b/.changeset/few-ties-boil.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/storage": minor +--- + +simplify workaround for hydration mismatches based on storage initialization diff --git a/packages/storage/README.md b/packages/storage/README.md index 0976d395d..e662a008f 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -40,6 +40,8 @@ type PersistedOptions = { deserialize?: (value: string) => Type(value), // sync API (see below) sync?: PersistenceSyncAPI + // isHydrated from @solid-primitives/lifecycle + isHydrated?: () => boolean }; ``` @@ -48,8 +50,8 @@ type PersistedOptions = { - initial values of signals or stores are not persisted, so they can be safely changed - values persisted in asynchronous storage APIs will not overwrite already changed signals or stores - setting a persisted signal to undefined or null will remove the item from the storage -- to use `makePersisted` with other state management APIs, you need some adapter that will project your API to either - the output of `createSignal` or `createStore` +- to use `makePersisted` with other state management APIs, you need some adapter that will project your API to either the output of `createSignal` or `createStore` +- if you experience hydration mismatch issues, set `deferInit` to true to delay the initialization from storage until the parent component is hydrated - this way, client and server will use the same initial data and avoid hydration conflicts ### Using `makePersisted` with resources @@ -66,7 +68,7 @@ result is discarded not to overwrite more current data. ### Using `makePersisted` with Suspense -In case you are using an asynchronous storage and want the initialisation mesh into Suspense instead of mixing it with Show, we provide the output of the initialisation as third part of the returned tuple: +In case you are using an asynchronous storage and want the initialization mesh into Suspense instead of mixing it with Show, we provide the output of the initialization as third part of the returned tuple: ```ts const [state, setState, init] = makePersisted(createStore({}), { diff --git a/packages/storage/src/persisted.ts b/packages/storage/src/persisted.ts index a8f26a1ba..90d5c74e0 100644 --- a/packages/storage/src/persisted.ts +++ b/packages/storage/src/persisted.ts @@ -1,5 +1,5 @@ import type { Accessor, Setter, Signal } from "solid-js"; -import { createUniqueId, untrack } from "solid-js"; +import { onMount, createUniqueId, untrack } from "solid-js"; import { isServer, isDev } from "solid-js/web"; import type { SetStoreFunction, Store } from "solid-js/store"; import { reconcile } from "solid-js/store"; @@ -59,10 +59,16 @@ export type PersistenceSyncAPI = [ ]; export type PersistenceOptions | undefined> = { + /** The name of the item in storage, `createUniqueId` is used to generate it otherwise, which means that it is bound to the component scope then */ name?: string; + /** A function that turns the value into a string for the storage. `JSON.stringify` is used as default. You can use seroval or your own custom serializer. */ serialize?: (data: T) => string; + /** A function that turns the string from the storage back into the value. `JSON.parse` is used as default. You can use seroval or your own custom deserializer. */ deserialize?: (data: string) => T; + /** Add one of the existing Sync APIs to sync storages over boundaries or provide your own */ sync?: PersistenceSyncAPI; + /** If you experience hydration mismatch issues, set this to true to defer initial loading from store until after onMount */ + deferInit?: boolean; } & (undefined extends O ? { storage?: SyncStorage | AsyncStorage } : { @@ -77,9 +83,9 @@ export type SignalType = export type PersistedState = S extends Signal - ? [get: Accessor, set: Setter, init: Promise | string | null] + ? [get: Accessor, set: Setter, init: Promise | string | null] : S extends [Store, SetStoreFunction] - ? [get: Store, set: SetStoreFunction, init: Promise | string | null] + ? [get: Store, set: SetStoreFunction, init: Promise | string | null] : never; /** @@ -92,6 +98,7 @@ export type PersistedState = * name: "solid-data", // optional * serialize: (value: string) => value, // optional * deserialize: (data: string) => data, // optional + * isHydrated, // optional, use @solid-primitives/lifecycle to avoid hydration mismatch * }; * ``` * Can be used with `createSignal` or `createStore`. The initial value from the storage will overwrite the initial @@ -126,7 +133,6 @@ export function makePersisted< const storageOptions = (options as unknown as { storageOptions: O }).storageOptions; const serialize: (data: T) => string = options.serialize || JSON.stringify.bind(JSON); const deserialize: (data: string) => T = options.deserialize || JSON.parse.bind(JSON); - const init = storage.getItem(name, storageOptions); const set = typeof signal[0] === "function" ? (data: string) => { @@ -147,10 +153,19 @@ export function makePersisted< if (isDev) console.warn(e); } }; - let unchanged = true; - if (init instanceof Promise) init.then(data => unchanged && data && set(data)); - else if (init) set(init); + let unchanged = true; + let init: string | Promise | null = null; + const initialize = () => { + init = storage.getItem(name, storageOptions); + if (init instanceof Promise) init.then(data => unchanged && data && set(data)); + else if (init) set(init); + }; + if (options.deferInit) { + onMount(initialize); + } else { + initialize(); + } if (typeof options.sync?.[0] === "function") { const get: () => T =