Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sunny-poets-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/storage": patch
---

fix: hydration mismatch
24 changes: 7 additions & 17 deletions packages/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ type PersistedOptions<Type, StorageOptions> = {
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
Expand All @@ -52,21 +54,9 @@ type PersistedOptions<Type, StorageOptions> = {
- 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:

Expand All @@ -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

Expand Down
8 changes: 7 additions & 1 deletion packages/storage/src/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type PersistenceOptions<
serialize?: (data: T) => string;
deserialize?: (data: string) => T;
sync?: PersistenceSyncAPI;
hydrated?: boolean;
action?: (signal: S) => Parameters<typeof action>[0];
} & (undefined extends O
? { storage?: SyncStorage | AsyncStorage }
Expand Down Expand Up @@ -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);

Expand Down
14 changes: 14 additions & 0 deletions packages/storage/test/persisted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading