Skip to content
Merged
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
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export const Header = () => {
}
```

[Nano Stores]: https://github.com/nanostores/nanostores/

---

<img src="https://cdn.evilmartians.com/badges/logo-no-label.svg" alt="" width="22" height="16" /> Made at <b><a href="https://evilmartians.com/devtools?utm_source=nanostores-preact&utm_campaign=devtools-button&utm_medium=github">Evil Martians</a></b>, product consulting for <b>developer tools</b>.

---

## Options

### Keys

Use the `keys` option to re-render only on specific key changes:

```tsx
Expand All @@ -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.

<img src="https://cdn.evilmartians.com/badges/logo-no-label.svg" alt="" width="22" height="16" /> Made at <b><a href="https://evilmartians.com/devtools?utm_source=nanostores-preact&utm_campaign=devtools-button&utm_medium=github">Evil Martians</a></b>, product consulting for <b>developer tools</b>.
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 <header>{profile.name}</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
// 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 <header>{profile.name}</header>
}
```

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.
6 changes: 6 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export interface UseStoreOptions<SomeStore> {
* Will re-render components only on specific key changes.
*/
keys?: StoreKeys<SomeStore>[]
/**
* 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<SomeStore>) | false
}

/**
Expand Down
24 changes: 16 additions & 8 deletions index.js
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -21,16 +23,22 @@ 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)
}
return () => {
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()
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"index.js": "{ useStore }",
"nanostores": "{ map, computed }"
},
"limit": "931 B"
"limit": "965 B"
}
]
}
162 changes: 149 additions & 13 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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'])
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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<Value>('old')
let mapStore = map<{ value: Value }>({ value: 'old' })
Expand All @@ -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)
}
Expand All @@ -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<Value>('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' })

Expand All @@ -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<Value>('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')
})