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
+}
+```
+
+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
+}
+```
+
+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
+}
+```
+
+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')
+})