diff --git a/README.md b/README.md index a01c3cb..162d316 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,56 @@ export const Header = ({ postId }) => { } ``` +

Options

+ +

Keys

+ +Use the `keys` option to re-render only on specific key changes: + +```tsx +export const Header = () => { + const profile = useStore($profile, { keys: 'name' }) + return
{profile.name}
+} +``` + +

SSR

+ +Use the `ssr` option to avoid hydration errors loading server-side rendered (SSR) pages when the browser's client store gets out of sync with the server's HTML. For example, when using Astro with `` for client-side routing and a global nanostore. + +For simple cases where the store's initial value is the same on the server and the client, and there are no server-side store updates, set `ssr:true`: + +```tsx +export const Header = () => { + const profile = useStore($profile, { ssr: true }) + // Hydrate with initial profile, then render latest client-side 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, { + ssr: + typeof window === 'undefined' + ? // On server, always use up-to-date store value (no SSR handling) + false + : // On client, set server value to avoid error on hydration + () => profileFromServer + }) + // Hydrate with profile at time of SSR, then render latest client-side value + return
{profile.name}
+} +``` + +A function set on `ssr` is provided to React's `useSyncExternalStore` as the +`getServerSnapshot` option. + [Nano Stores]: https://github.com/nanostores/nanostores/ --- diff --git a/index.d.ts b/index.d.ts index 4a1dc6a..ffa0966 100644 --- a/index.d.ts +++ b/index.d.ts @@ -18,6 +18,13 @@ export interface UseStoreOptions { * Will re-render components only on specific key changes. */ keys?: StoreKeys[] + + /** + * Enable SSR support. Set `true` 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 (per useSyncExternalStore's getServerSnapshot). + */ + ssr?: (() => StoreValue) | boolean } /** diff --git a/index.js b/index.js index 41dbb76..1210c94 100644 --- a/index.js +++ b/index.js @@ -7,7 +7,7 @@ let emit = (snapshotRef, onChange) => value => { onChange() } -export function useStore(store, { keys, deps = [store, keys] } = {}) { +export function useStore(store, { keys, deps = [store, keys], ssr } = {}) { let snapshotRef = useRef() snapshotRef.current = store.get() @@ -18,5 +18,12 @@ export function useStore(store, { keys, deps = [store, keys] } = {}) { ? listenKeys(store, keys, emit(snapshotRef, onChange)) : store.listen(emit(snapshotRef, onChange)) }, deps) - return useSyncExternalStore(subscribe, () => snapshotRef.current, () => store.init) + + let get = () => snapshotRef.current + + return useSyncExternalStore( + subscribe, + get, + ssr === true ? () => store.init : ssr || get + ) } diff --git a/package.json b/package.json index f54289f..ea430e3 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "index.js": "{ useStore }", "nanostores": "{ map, computed }" }, - "limit": "926 B" + "limit": "940 B" } ] } diff --git a/test/index.test.ts b/test/index.test.ts index 159c621..daf0176 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -245,7 +245,7 @@ test('useSyncExternalStore late subscription handling', () => { equal(screen.getByTestId('subscription-test').textContent, 'updated content') }) -test('returns initial value until hydrated via useSyncExternalStore', t => { +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' }) @@ -261,7 +261,52 @@ test('returns initial value until hydrated via useSyncExternalStore', t => { 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) + } + + let Wrapper: FC = () => { + return h('div', { 'data-testid': 'test' }, h(AtomTest), h(MapTest)) + } + + // Simulate store state change on server side + atomStore.set('new') + mapStore.set({ value: 'new' }) + + // Create a "server" rendered element to re-hydrate. Thanks to childrentime + // https://github.com/testing-library/react-testing-library/issues/1120#issuecomment-2065733238 + let ssrElement = document.createElement('div') + document.body.appendChild(ssrElement) + let html = renderToString(h(Wrapper)) + 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', t => { + 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: true }) + 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: true }).value mapValues.push(value) return h('div', { 'data-testid': 'map-test' }, value) } @@ -280,7 +325,7 @@ test('returns initial value until hydrated via useSyncExternalStore', t => { 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' }) @@ -297,11 +342,102 @@ test('returns initial value until hydrated via useSyncExternalStore', t => { let consoleErrorMessage = String(consoleErrorCall?.arguments?.[0] ?? '') equal(consoleErrorMessage, '') - // 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', t => { + 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. Thanks to childrentime + // https://github.com/testing-library/react-testing-library/issues/1120#issuecomment-2065733238 + let ssrElement = document.createElement('div') + document.body.appendChild(ssrElement) + let html = renderToString(h(Wrapper)) + 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 change on client, now different from value at "server" SSR + 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. Logs errors to console on hydration failure + let consoleErrorMock = t.mock.method(console, 'error', () => {}) + act(() => { + hydrateRoot(ssrElement, h(Wrapper)) + }) + + // Check nothing was logged to `console.error()` + let consoleErrorCall = consoleErrorMock.mock.calls[0] as + | { arguments: any } + | undefined + let consoleErrorMessage = String(consoleErrorCall?.arguments?.[0] ?? '') + equal(consoleErrorMessage, '') + + // Confirm "server" render (renderToString) got latest update on server, + // initial client render got latest server update at hydration, then + // post-hydration render got latest client update + 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') +})