Skip to content

Commit 2fa9cbf

Browse files
committed
storage: solve hydration issue
1 parent 5d2808a commit 2fa9cbf

File tree

3 files changed

+31
-9
lines changed

3 files changed

+31
-9
lines changed

.changeset/few-ties-boil.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solid-primitives/storage": minor
3+
---
4+
5+
simplify workaround for hydration mismatches based on storage initialization

packages/storage/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ type PersistedOptions<Type, StorageOptions> = {
4040
deserialize?: (value: string) => Type(value),
4141
// sync API (see below)
4242
sync?: PersistenceSyncAPI
43+
// isHydrated from @solid-primitives/lifecycle
44+
isHydrated?: () => boolean
4345
};
4446
```
4547

@@ -48,8 +50,8 @@ type PersistedOptions<Type, StorageOptions> = {
4850
- initial values of signals or stores are not persisted, so they can be safely changed
4951
- values persisted in asynchronous storage APIs will not overwrite already changed signals or stores
5052
- setting a persisted signal to undefined or null will remove the item from the storage
51-
- to use `makePersisted` with other state management APIs, you need some adapter that will project your API to either
52-
the output of `createSignal` or `createStore`
53+
- 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`
54+
- 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
5355

5456
### Using `makePersisted` with resources
5557

packages/storage/src/persisted.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Accessor, Setter, Signal } from "solid-js";
2-
import { createUniqueId, untrack } from "solid-js";
2+
import { createEffect, createRoot, createUniqueId, untrack } from "solid-js";
33
import { isServer, isDev } from "solid-js/web";
44
import type { SetStoreFunction, Store } from "solid-js/store";
55
import { reconcile } from "solid-js/store";
@@ -59,10 +59,16 @@ export type PersistenceSyncAPI = [
5959
];
6060

6161
export type PersistenceOptions<T, O extends Record<string, any> | undefined> = {
62+
/** 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 */
6263
name?: string;
64+
/** 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. */
6365
serialize?: (data: T) => string;
66+
/** 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. */
6467
deserialize?: (data: string) => T;
68+
/** Add one of the existing Sync APIs to sync storages over boundaries or provide your own */
6569
sync?: PersistenceSyncAPI;
70+
/** If you experience hydration mismatch issues, add `isHydrated` from `@solid-primitives/lifecycle` here */
71+
isHydrated?: () => boolean;
6672
} & (undefined extends O
6773
? { storage?: SyncStorage | AsyncStorage }
6874
: {
@@ -77,9 +83,9 @@ export type SignalType<S extends SignalInput> =
7783

7884
export type PersistedState<S extends SignalInput> =
7985
S extends Signal<infer T>
80-
? [get: Accessor<T>, set: Setter<T>, init: Promise<string> | string | null]
86+
? [get: Accessor<T>, set: Setter<T>, init: Promise<string | null> | string | null]
8187
: S extends [Store<infer T>, SetStoreFunction<infer T>]
82-
? [get: Store<T>, set: SetStoreFunction<T>, init: Promise<string> | string | null]
88+
? [get: Store<T>, set: SetStoreFunction<T>, init: Promise<string | null> | string | null]
8389
: never;
8490

8591
/**
@@ -92,6 +98,7 @@ export type PersistedState<S extends SignalInput> =
9298
* name: "solid-data", // optional
9399
* serialize: (value: string) => value, // optional
94100
* deserialize: (data: string) => data, // optional
101+
* isHydrated, // optional, use @solid-primitives/lifecycle to avoid hydration mismatch
95102
* };
96103
* ```
97104
* Can be used with `createSignal` or `createStore`. The initial value from the storage will overwrite the initial
@@ -126,7 +133,6 @@ export function makePersisted<
126133
const storageOptions = (options as unknown as { storageOptions: O }).storageOptions;
127134
const serialize: (data: T) => string = options.serialize || JSON.stringify.bind(JSON);
128135
const deserialize: (data: string) => T = options.deserialize || JSON.parse.bind(JSON);
129-
const init = storage.getItem(name, storageOptions);
130136
const set =
131137
typeof signal[0] === "function"
132138
? (data: string) => {
@@ -147,10 +153,19 @@ export function makePersisted<
147153
if (isDev) console.warn(e);
148154
}
149155
};
150-
let unchanged = true;
151156

152-
if (init instanceof Promise) init.then(data => unchanged && data && set(data));
153-
else if (init) set(init);
157+
let unchanged = true;
158+
let init: string | Promise<string | null> | null = null;
159+
const initialize = () => {
160+
init = storage.getItem(name, storageOptions);
161+
if (init instanceof Promise) init.then(data => unchanged && data && set(data));
162+
else if (init) set(init);
163+
};
164+
if (typeof options.isHydrated === "function") {
165+
createRoot(dispose => createEffect(() => options.isHydrated?.() && (initialize(), dispose())));
166+
} else {
167+
initialize();
168+
}
154169

155170
if (typeof options.sync?.[0] === "function") {
156171
const get: () => T =

0 commit comments

Comments
 (0)