diff --git a/README.md b/README.md index d992a83..9f25f8b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ export const Header = () => { } ``` +[Nano Stores]: https://github.com/nanostores/nanostores/ + +--- + + Made at Evil Martians, product consulting for developer tools. + +--- + +## Options + +### Keys + Use the `keys` option to re-render only on specific key changes: ```tsx @@ -34,10 +46,47 @@ export const Header = () => { } ``` -[Nano Stores]: https://github.com/nanostores/nanostores/ +### SSR ---- +SSR could be very complicated in React. To avoid hydration errors you +need exactly the same stores state in the end of server HTML rendering +and during the first DOM render on the client. - Made at Evil Martians, product consulting for developer tools. +For simple solution you can disable any store update on the server +by `ssr: 'initial'`: ---- +```tsx +export const Header = () => { + const profile = useStore($profile, { ssr: 'initial' }) + + // Server render and client hydration use store's initial value. + // After hydration, client re-renders with the current value. + return
{profile.name}
+} +``` + +For advanced cases where you update store values on the server before SSR, and +need pages to hydrate with the updated value from the server, set a function +that returns the server state: `ssr: () => serverState`. + +```tsx +// Value of store on server at time of SSR, passed to client somehow... +const profileFromServer = { name: 'A User' } + +export const Header = () => { + const profile = useStore($profile, { + // On server, always use up-to-date store value (set `ssr` to `false`). + // On client, set server value to avoid error on hydration. + ssr: typeof window === 'object' && (() => profileFromServer) + }) + + // Server render uses store's current value. Client uses value from function + // for hydration, then after hydration re-renders with the current value. + return
{profile.name}
+} +``` + +A function set on `ssr` works similarly to the `getServerSnapshot` option of +React's +[`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore) +hook, though it works differently internally for Preact. diff --git a/index.d.ts b/index.d.ts index 8416925..3a19810 100644 --- a/index.d.ts +++ b/index.d.ts @@ -9,6 +9,12 @@ export interface UseStoreOptions { * Will re-render components only on specific key changes. */ keys?: StoreKeys[] + /** + * Enable SSR support. Set `initial` when store's initial value is the same + * on server and client, or provide a function to return the server store + * state for advanced cases. + */ + ssr?: 'initial' | (() => StoreValue) | false } /** diff --git a/index.js b/index.js index 973c3bf..00158b2 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,15 @@ import { listenKeys } from 'nanostores' import { useEffect, useState } from 'preact/hooks' -export function useStore(store, opts = {}) { - let [hydrated, setHydrated] = useState(false) +export function useStore(store, { keys, ssr } = {}) { + let [isHydrated, setIsHydrated] = useState(false) let [, forceRender] = useState({}) + let [valueBeforeEffect] = useState(store.get()) - // A re-render is always forced on mount and hydrate useEffect(() => { - setHydrated(true) + // Skip re-render afer hydration when not needed for SSR support + ssr && setIsHydrated(true) + valueBeforeEffect !== store.get() && forceRender({}) }, []) useEffect(() => { @@ -21,8 +23,8 @@ export function useStore(store, opts = {}) { }) } } - if (opts.keys) { - unlisten = listenKeys(store, opts.keys, rerender) + if (keys) { + unlisten = listenKeys(store, keys, rerender) } else { unlisten = store.listen(rerender) } @@ -30,7 +32,13 @@ export function useStore(store, opts = {}) { unlisten() clearTimeout(timer) } - }, [store, '' + opts.keys]) + }, [store, '' + keys]) - return !hydrated ? store.init : store.get() + // For SSR return initial value or result of function until hydrated: always + // on server, until post-hydration on client + if (ssr && !isHydrated) { + return ssr === 'initial' ? store.init : ssr() + } + + return store.get() } diff --git a/package.json b/package.json index 0a5e6e4..c4b6956 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "index.js": "{ useStore }", "nanostores": "{ map, computed }" }, - "limit": "931 B" + "limit": "965 B" } ] } diff --git a/test/index.test.ts b/test/index.test.ts index 4bbd50b..b7b3f15 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -61,7 +61,7 @@ test('renders simple store', async () => { deepStrictEqual(events, ['constructor']) equal(screen.getByTestId('test1').textContent, 'a') equal(screen.getByTestId('test2').textContent, 'a') - equal(renders, 2) + equal(renders, 1) await act(async () => { letter.set('b') @@ -71,13 +71,13 @@ test('renders simple store', async () => { equal(screen.getByTestId('test1').textContent, 'c') equal(screen.getByTestId('test2').textContent, 'c') - equal(renders, 3) + equal(renders, 2) act(() => { screen.getByRole('button').click() }) equal(screen.queryByTestId('test'), null) - equal(renders, 3) + equal(renders, 2) await delay(STORE_UNMOUNT_DELAY) deepStrictEqual(events, ['constructor', 'destroy']) @@ -179,7 +179,7 @@ test('has keys option', async () => { render(h(Wrapper, {}, h(MapTest, {}))) equal(screen.getByTestId('map-test').textContent, 'map:undefined-undefined') - equal(renderCount, 2) + equal(renderCount, 1) // updates on init await act(async () => { @@ -188,7 +188,7 @@ test('has keys option', async () => { }) equal(screen.getByTestId('map-test').textContent, 'map:undefined-undefined') - equal(renderCount, 3) + equal(renderCount, 2) // updates when has key await act(async () => { @@ -197,7 +197,7 @@ test('has keys option', async () => { }) equal(screen.getByTestId('map-test').textContent, 'map:a-undefined') - equal(renderCount, 4) + equal(renderCount, 3) // does not update when has no key await act(async () => { @@ -206,7 +206,7 @@ test('has keys option', async () => { }) equal(screen.getByTestId('map-test').textContent, 'map:a-undefined') - equal(renderCount, 4) + equal(renderCount, 3) // reacts on parameter changes await act(async () => { @@ -215,7 +215,7 @@ test('has keys option', async () => { }) equal(screen.getByTestId('map-test').textContent, 'map:a-b') - equal(renderCount, 5) + equal(renderCount, 4) }) test('supports atom changes between rendering and useEffect', () => { @@ -256,7 +256,7 @@ test('supports map changes between rendering and useEffect', () => { equal(result, 'new') }) -test('returns initial value until hydrated', () => { +test('support for SSR does not break server behaviour in non-SSR projects', () => { type Value = 'new' | 'old' let atomStore = atom('old') let mapStore = map<{ value: Value }>({ value: 'old' }) @@ -272,7 +272,11 @@ test('returns initial value until hydrated', () => { let mapValues: Value[] = [] // Track values used across renders let MapTest: FC = () => { - let value = useStore(mapStore).value + let value = useStore( + mapStore, + // Setting `ssr:false` should be equivalent to not setting `ssr` at all + { ssr: false } + ).value mapValues.push(value) return h('div', { 'data-testid': 'map-test' }, value) } @@ -286,16 +290,66 @@ test('returns initial value until hydrated', () => { ) } + // Simulate store state change on server side + atomStore.set('new') + mapStore.set({ value: 'new' }) + + // Create a "server" rendered element + let ssrElement = document.createElement('div') + document.body.appendChild(ssrElement) + let html = renderToString(h(Wrapper, null)) + ssrElement.innerHTML = html + + // Confirm server rendered HTML includes the latest store data + equal(screen.getByTestId('atom-test').textContent, 'new') + equal(screen.getByTestId('map-test').textContent, 'new') +}) + +test('support SSR to fix client hydration errors, use initial data', () => { + type Value = 'new' | 'old' + let atomStore = atom('old') + let mapStore = map<{ value: Value }>({ value: 'old' }) + + let atomValues: Value[] = [] // Track values used across renders + + let AtomTest: FC = () => { + let value = useStore(atomStore, { ssr: 'initial' }) + atomValues.push(value) + return h('div', { 'data-testid': 'atom-test' }, value) + } + + let mapValues: Value[] = [] // Track values used across renders + + let MapTest: FC = () => { + let value = useStore(mapStore, { ssr: 'initial' }).value + mapValues.push(value) + return h('div', { 'data-testid': 'map-test' }, value) + } + + let Wrapper: FC = () => { + return h( + 'div', + { 'data-testid': 'test' }, + h(AtomTest, null), + h(MapTest, null) + ) + } + + // Simulate store state change on server side + atomStore.set('new') + mapStore.set({ value: 'new' }) + // Create a "server" rendered element to re-hydrate let ssrElement = document.createElement('div') document.body.appendChild(ssrElement) let html = renderToString(h(Wrapper, null)) ssrElement.innerHTML = html + // Confirm server renders initial value, not current value equal(screen.getByTestId('atom-test').textContent, 'old') equal(screen.getByTestId('map-test').textContent, 'old') - // Simulate store state change on client-side, after "server" render + // Simulate store change on client, now different from value at "server" SSR atomStore.set('new') mapStore.set({ value: 'new' }) @@ -304,11 +358,93 @@ test('returns initial value until hydrated', () => { hydrate(h(Wrapper, null), ssrElement) }) - // Confirm "server" render got old values, initial client render got old - // values at hydration, then post-hydration render got new values + // Confirm "server" render (renderToString) got old values, initial client + // render got old values at hydration, then post-hydration render got new + // values deepStrictEqual(atomValues, ['old', 'old', 'new']) deepStrictEqual(mapValues, ['old', 'old', 'new']) equal(screen.getByTestId('atom-test').textContent, 'new') equal(screen.getByTestId('map-test').textContent, 'new') }) + +test('support SSR to fix client hydration errors, server passes data to client', () => { + type Value = 'initial' | 'update on client' | 'update on server' + let atomStore = atom('initial') + let mapStore = map<{ value: Value }>({ value: 'initial' }) + + let ssrDataFnForAtom: typeof atomStore.get | undefined + let ssrDataFnForMap: typeof mapStore.get | undefined + + let atomValues: Value[] = [] // Track values used across renders + + let AtomTest: FC = () => { + let value = useStore(atomStore, { ssr: ssrDataFnForAtom }) + atomValues.push(value) + return h('div', { 'data-testid': 'atom-test' }, value) + } + + let mapValues: Value[] = [] // Track values used across renders + + let MapTest: FC = () => { + let value = useStore(mapStore, { ssr: ssrDataFnForMap }).value + mapValues.push(value) + return h('div', { 'data-testid': 'map-test' }, value) + } + + let Wrapper: FC = () => { + return h( + 'div', + { 'data-testid': 'test' }, + h(AtomTest, null), + h(MapTest, null) + ) + } + + // Simulate store state change on server side + atomStore.set('update on server') + mapStore.set({ value: 'update on server' }) + + // Create a "server" rendered element to re-hydrate + let ssrElement = document.createElement('div') + document.body.appendChild(ssrElement) + let html = renderToString(h(Wrapper, null)) + ssrElement.innerHTML = html + + // Confirm server render includes latest updates to server store + equal(screen.getByTestId('atom-test').textContent, 'update on server') + equal(screen.getByTestId('map-test').textContent, 'update on server') + + // Simulate store state change on client-side, after "server" render + atomStore.set('update on client') + mapStore.set({ value: 'update on client' }) + + // Simulate passing of store state data from server to client, provided to + // hook via `ssr` option + ssrDataFnForAtom = (): Value => 'update on server' + let serverDataForMap = { value: 'update on server' as Value } + ssrDataFnForMap = (): { value: Value } => serverDataForMap + + // Hydrate into SSR element + act(() => { + hydrate(h(Wrapper, null), ssrElement) + }) + + // Confirm "server" render got latest update on server, initial client render + // got latest update on server at hydration, then post-hydration render got + // latest update on client + deepStrictEqual(atomValues, [ + 'update on server', + 'update on server', + 'update on client' + ]) + deepStrictEqual(mapValues, [ + 'update on server', + 'update on server', + 'update on client' + ]) + + // Confirm final rendered version has latest updates to client store + equal(screen.getByTestId('atom-test').textContent, 'update on client') + equal(screen.getByTestId('map-test').textContent, 'update on client') +})