Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

storage: solve hydration issue #738

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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/few-ties-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/storage": minor
---

simplify workaround for hydration mismatches based on storage initialization
8 changes: 5 additions & 3 deletions packages/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type PersistedOptions<Type, StorageOptions> = {
deserialize?: (value: string) => Type(value),
// sync API (see below)
sync?: PersistenceSyncAPI
// isHydrated from @solid-primitives/lifecycle
isHydrated?: () => boolean
};
```

Expand All @@ -48,8 +50,8 @@ type PersistedOptions<Type, StorageOptions> = {
- 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

Expand All @@ -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({}), {
Expand Down
29 changes: 22 additions & 7 deletions packages/storage/src/persisted.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -59,10 +59,16 @@ export type PersistenceSyncAPI = [
];

export type PersistenceOptions<T, O extends Record<string, any> | 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 }
: {
Expand All @@ -77,9 +83,9 @@ export type SignalType<S extends SignalInput> =

export type PersistedState<S extends SignalInput> =
S extends Signal<infer T>
? [get: Accessor<T>, set: Setter<T>, init: Promise<string> | string | null]
? [get: Accessor<T>, set: Setter<T>, init: Promise<string | null> | string | null]
: S extends [Store<infer T>, SetStoreFunction<infer T>]
? [get: Store<T>, set: SetStoreFunction<T>, init: Promise<string> | string | null]
? [get: Store<T>, set: SetStoreFunction<T>, init: Promise<string | null> | string | null]
: never;

/**
Expand All @@ -92,6 +98,7 @@ export type PersistedState<S extends SignalInput> =
* 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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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<string | null> | 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 =
Expand Down
Loading