diff --git a/.changeset/sunny-poets-brush.md b/.changeset/sunny-poets-brush.md new file mode 100644 index 000000000..e993ecc97 --- /dev/null +++ b/.changeset/sunny-poets-brush.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/storage": patch +--- + +fix: hydration mismatch diff --git a/packages/storage/README.md b/packages/storage/README.md index e6e214e2c..c2548a3fc 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -36,6 +36,8 @@ type PersistedOptions = { storageOptions?: StorageOptions, // key in the storage API name?: "...", + // defer initialization in order to avoid hydration mismatches in SSR settings + hydrated?: true, // JSON.stringify is the default serialize?: (value: Type) => value.toString(), // JSON.parse is the default @@ -52,21 +54,9 @@ type PersistedOptions = { - 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` +- using `makePersisted` in an SSR scenario where you have different values on the server and the client might lead to hydration mismatches; use the `hydrated` option to defer the initialization so both values are the same -### Using `makePersisted` with resources - -Instead of wrapping the resource itself, it is far simpler to use the `storage` option of the resource to provide a -persisted signal or [deep signal](../resource/#createdeepsignal): - -```ts -const [resource] = createResource(fetcher, { storage: makePersisted(createSignal()) }); -``` - -If you are using an asynchronous storage to persist the state of a resource, it might receive an update due to being -initialized from the storage before or after the fetcher resolved. If the initialization resolves after the fetcher, its -result is discarded not to overwrite more current data. - -### Using `makePersisted` with Suspense +### Using `makePersisted` with Loading 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: @@ -75,11 +65,11 @@ const [state, setState, init] = makePersisted(createStore({}), { name: "state", storage: localForage, }); -// run the resource so it is triggered -createResource(() => init)[0](); +// run the memo so it hits the Loading boundary +createMemo(() => init)(); ``` -Now Suspense should be blocked until the initialisation is resolved. +Now Loading should be blocked until the initialisation is resolved. ### Different storage APIs diff --git a/packages/storage/src/persisted.ts b/packages/storage/src/persisted.ts index d58af53db..287710064 100644 --- a/packages/storage/src/persisted.ts +++ b/packages/storage/src/persisted.ts @@ -65,6 +65,7 @@ export type PersistenceOptions< serialize?: (data: T) => string; deserialize?: (data: string) => T; sync?: PersistenceSyncAPI; + hydrated?: boolean; action?: (signal: S) => Parameters[0]; } & (undefined extends O ? { storage?: SyncStorage | AsyncStorage } @@ -167,7 +168,12 @@ export function makePersisted< let unchanged = true; if (init instanceof Promise) init.then(data => unchanged && data && set(data)); - else if (init) set(init); + else if (init) + // in case of hydration mismatches due to the server lacking the same initial value, + // we want to defer the initialization by a short amount so the same state can be used + // during hydration + if (options.hydrated) setTimeout(() => set(init), 45); + else set(init); const getter: () => T = isSignal ? (signal[0] as () => T) : () => snapshot(signal[0] as T); diff --git a/packages/storage/test/persisted.test.ts b/packages/storage/test/persisted.test.ts index c5aae9fab..bccc4ac43 100644 --- a/packages/storage/test/persisted.test.ts +++ b/packages/storage/test/persisted.test.ts @@ -194,4 +194,18 @@ describe("makePersisted", () => { }); expect(init).toBe(promise); }); + + it("defers the initialization if the hydrated option is set", async () => { + const anotherMockStorage = { ...mockStorage }; + anotherMockStorage.setItem("test18", '"init"'); + const [signal, _setSignal] = makePersisted( + createSignal("default"), + { storage: anotherMockStorage, name: "test18", hydrated: true } + ); + flush(); + expect(signal()).toBe("default"); + await new Promise(r => setTimeout(r, 200)); + flush(); + expect(signal()).toBe("init"); + }); });