From 82cb1de7dd2af3c6e85f7c40ff6a6ace9b627d39 Mon Sep 17 00:00:00 2001 From: James Murty Date: Sun, 15 Mar 2026 16:31:01 +1100 Subject: [PATCH 1/5] Fix SSR support: require opt in & support both simple and advanced SSR cases Disable SSR behaviour entirely unless the new `ssr` option is provided, to fix regression where server-side store changes are ignored on the initial value is always rendered even in projects that don't need SSR support Enable SSR support for simple cases, where the store's initial value is the same on server and client, by setting `ssr:true` Enable SSR support for advanced cases, where server store state is updated on the server then passed to the client, by providing a function that returns the server state: `ssr: () => serverState`. This function is provided to `useSyncExternalStore` as the `getServerSnapshot` option. --- index.d.ts | 7 +++ index.js | 11 +++- package.json | 2 +- test/index.test.ts | 140 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 153 insertions(+), 7 deletions(-) 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..0c89948 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..804df66 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "index.js": "{ useStore }", "nanostores": "{ map, computed }" }, - "limit": "926 B" + "limit": "939 B" } ] } diff --git a/test/index.test.ts b/test/index.test.ts index 159c621..d2f8d04 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', t => { type Value = 'new' | 'old' let atomStore = atom('old') let mapStore = map<{ value: Value }>({ value: 'old' }) @@ -270,6 +270,47 @@ test('returns initial value until hydrated via useSyncExternalStore', t => { 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) + } + + let Wrapper: FC = () => { + return h('div', { 'data-testid': 'test' }, h(AtomTest), h(MapTest)) + } + // 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') @@ -280,7 +321,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 +338,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') +}) From a1d459e88e7a12b86c9ee86c7b812e4b07d9e3b1 Mon Sep 17 00:00:00 2001 From: James Murty Date: Sun, 15 Mar 2026 16:35:46 +1100 Subject: [PATCH 2/5] Fix lint error --- test/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index d2f8d04..be2e6c8 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('support for SSR does not break server behaviour in non-SSR projects', 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' }) From bb32594c5883b251e8f63e32f3dac3ddd8865360 Mon Sep 17 00:00:00 2001 From: James Murty Date: Sun, 15 Mar 2026 21:46:35 +1100 Subject: [PATCH 3/5] Document the `keys` option --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index a01c3cb..5b2e1b3 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,17 @@ export const Header = ({ postId }) => { } ``` +

Options

+ +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}
+} +``` + [Nano Stores]: https://github.com/nanostores/nanostores/ --- From 2f576fc0dcf51314664e5826ea42052c26221318 Mon Sep 17 00:00:00 2001 From: James Murty Date: Sun, 15 Mar 2026 22:33:19 +1100 Subject: [PATCH 4/5] Update README to try and describe when and how to use the `ssr` option --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index 5b2e1b3..b17930a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ export const Header = ({ postId }) => {

Options

+

Keys

+ Use the `keys` option to re-render only on specific key changes: ```tsx @@ -35,6 +37,43 @@ export const Header = () => { } ``` +

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 +// Store value 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/ --- From d6e48f955d77b9db856176166170a6d8f8543f58 Mon Sep 17 00:00:00 2001 From: James Murty Date: Sun, 15 Mar 2026 22:38:33 +1100 Subject: [PATCH 5/5] Fix handling of option `ssr:false` to work like not setting `ssr` option at all --- README.md | 2 +- index.js | 2 +- package.json | 2 +- test/index.test.ts | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b17930a..162d316 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ export const Header = () => { 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 -// Store value on server at time of SSR, passed to client somehow... +// Value of store on server at time of SSR, passed to client somehow... const profileFromServer = { name: 'A User' } export const Header = () => { diff --git a/index.js b/index.js index 0c89948..1210c94 100644 --- a/index.js +++ b/index.js @@ -24,6 +24,6 @@ export function useStore(store, { keys, deps = [store, keys], ssr } = {}) { return useSyncExternalStore( subscribe, get, - ssr === true ? () => store.init : (ssr ?? get) + ssr === true ? () => store.init : ssr || get ) } diff --git a/package.json b/package.json index 804df66..ea430e3 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "index.js": "{ useStore }", "nanostores": "{ map, computed }" }, - "limit": "939 B" + "limit": "940 B" } ] } diff --git a/test/index.test.ts b/test/index.test.ts index be2e6c8..daf0176 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -261,7 +261,11 @@ test('support for SSR does not break server behaviour in non-SSR projects', () = 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) }