From 2fa9cbfaa887ae89d0a26e6483f2fe9557134215 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Wed, 15 Jan 2025 13:52:03 +0100 Subject: [PATCH 1/2] storage: solve hydration issue --- .changeset/few-ties-boil.md | 5 +++++ packages/storage/README.md | 6 ++++-- packages/storage/src/persisted.ts | 29 ++++++++++++++++++++++------- 3 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 .changeset/few-ties-boil.md 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..18b6bafbd 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, add `isHydrated` from the [lifecycles package](../lifecycle/) to your options to delay the initialization until the parent component is hydrated ### Using `makePersisted` with resources diff --git a/packages/storage/src/persisted.ts b/packages/storage/src/persisted.ts index a8f26a1ba..ce077e020 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 { createEffect, createRoot, 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, add `isHydrated` from `@solid-primitives/lifecycle` here */ + isHydrated?: () => 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 (typeof options.isHydrated === "function") { + createRoot(dispose => createEffect(() => options.isHydrated?.() && (initialize(), dispose()))); + } else { + initialize(); + } if (typeof options.sync?.[0] === "function") { const get: () => T = From b3a785b32cd70cbb5e63d99530f648441fda94f8 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Sun, 19 Jan 2025 12:29:32 +0100 Subject: [PATCH 2/2] address PR comments --- packages/storage/README.md | 4 ++-- packages/storage/src/persisted.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/storage/README.md b/packages/storage/README.md index 18b6bafbd..e662a008f 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -51,7 +51,7 @@ type PersistedOptions = { - 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` -- if you experience hydration mismatch issues, add `isHydrated` from the [lifecycles package](../lifecycle/) to your options to delay the initialization until the parent component is hydrated +- 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 @@ -68,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 ce077e020..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 { createEffect, createRoot, 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"; @@ -67,8 +67,8 @@ export type PersistenceOptions | undefined> = { 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, add `isHydrated` from `@solid-primitives/lifecycle` here */ - isHydrated?: () => boolean; + /** 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 } : { @@ -161,8 +161,8 @@ export function makePersisted< if (init instanceof Promise) init.then(data => unchanged && data && set(data)); else if (init) set(init); }; - if (typeof options.isHydrated === "function") { - createRoot(dispose => createEffect(() => options.isHydrated?.() && (initialize(), dispose()))); + if (options.deferInit) { + onMount(initialize); } else { initialize(); }