diff --git a/README.md b/README.md index 3eb7421..d992a83 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,15 @@ export const Header = () => { } ``` +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/ --- diff --git a/index.js b/index.js index 2756e54..9eef8a3 100644 --- a/index.js +++ b/index.js @@ -2,13 +2,14 @@ import { listenKeys } from 'nanostores' import { useEffect, useState } from 'preact/hooks' export function useStore(store, opts = {}) { + let [hydrated, setHydrated] = useState(false) let [, forceRender] = useState({}) - let [valueBeforeEffect] = useState(store.get()) + // A re-render is always forced on mount and hydrate useEffect(() => { - valueBeforeEffect !== store.get() && forceRender({}) + setHydrated(true) }, []) - + useEffect(() => { let batching, timer, unlisten let rerender = () => { @@ -31,5 +32,6 @@ export function useStore(store, opts = {}) { } }, [store, '' + opts.keys]) - return store.get() + // `'init' in store` check for compatibility with nanostores <= 1.1.1 + return !hydrated && 'init' in store ? store.init : store.get() } diff --git a/package.json b/package.json index a9630e2..24a8d2a 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "nanodelay": "^2.0.2", "nanostores": "^1.0.1", "preact": "^10.26.5", + "preact-render-to-string": "^6.6.6", "size-limit": "^11.2.0", "tsx": "^4.19.3", "typescript": "^5.8.3" @@ -88,7 +89,7 @@ "index.js": "{ useStore }", "nanostores": "{ map, computed }" }, - "limit": "922 B" + "limit": "943 B" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62f7d7c..dba0751 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: preact: specifier: ^10.26.5 version: 10.26.5 + preact-render-to-string: + specifier: ^6.6.6 + version: 6.6.6(preact@10.26.5) size-limit: specifier: ^11.2.0 version: 11.2.0 @@ -1373,6 +1376,11 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + preact-render-to-string@6.6.6: + resolution: {integrity: sha512-EfqZJytnjJldV+YaaqhthU2oXsEf5e+6rDv957p+zxAvNfFLQOPfvBOTncscQ+akzu6Wrl7s3Pa0LjUQmWJsGQ==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' + preact@10.26.5: resolution: {integrity: sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==} @@ -2977,6 +2985,10 @@ snapshots: possible-typed-array-names@1.1.0: {} + preact-render-to-string@6.6.6(preact@10.26.5): + dependencies: + preact: 10.26.5 + preact@10.26.5: {} prelude-ls@1.2.1: {} diff --git a/test/index.test.ts b/test/index.test.ts index c777e1e..4bbd50b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -6,7 +6,8 @@ import { atom, map, onMount, STORE_UNMOUNT_DELAY } from 'nanostores' import { deepStrictEqual, equal } from 'node:assert' import { afterEach, test } from 'node:test' import type { FunctionalComponent as FC } from 'preact' -import { h } from 'preact' +import { h, hydrate } from 'preact' +import { renderToString } from 'preact-render-to-string' import { useState } from 'preact/hooks' import { useStore } from '../index.js' @@ -60,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, 1) + equal(renders, 2) await act(async () => { letter.set('b') @@ -70,13 +71,13 @@ test('renders simple store', async () => { equal(screen.getByTestId('test1').textContent, 'c') equal(screen.getByTestId('test2').textContent, 'c') - equal(renders, 2) + equal(renders, 3) act(() => { screen.getByRole('button').click() }) equal(screen.queryByTestId('test'), null) - equal(renders, 2) + equal(renders, 3) await delay(STORE_UNMOUNT_DELAY) deepStrictEqual(events, ['constructor', 'destroy']) @@ -178,7 +179,7 @@ test('has keys option', async () => { render(h(Wrapper, {}, h(MapTest, {}))) equal(screen.getByTestId('map-test').textContent, 'map:undefined-undefined') - equal(renderCount, 1) + equal(renderCount, 2) // updates on init await act(async () => { @@ -187,7 +188,7 @@ test('has keys option', async () => { }) equal(screen.getByTestId('map-test').textContent, 'map:undefined-undefined') - equal(renderCount, 2) + equal(renderCount, 3) // updates when has key await act(async () => { @@ -196,7 +197,7 @@ test('has keys option', async () => { }) equal(screen.getByTestId('map-test').textContent, 'map:a-undefined') - equal(renderCount, 3) + equal(renderCount, 4) // does not update when has no key await act(async () => { @@ -205,7 +206,7 @@ test('has keys option', async () => { }) equal(screen.getByTestId('map-test').textContent, 'map:a-undefined') - equal(renderCount, 3) + equal(renderCount, 4) // reacts on parameter changes await act(async () => { @@ -214,7 +215,7 @@ test('has keys option', async () => { }) equal(screen.getByTestId('map-test').textContent, 'map:a-b') - equal(renderCount, 4) + equal(renderCount, 5) }) test('supports atom changes between rendering and useEffect', () => { @@ -254,3 +255,60 @@ test('supports map changes between rendering and useEffect', () => { let result = screen.getByText('new').textContent equal(result, 'new') }) + +test('returns initial value until hydrated', () => { + 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) + 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).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) + ) + } + + // 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 + + equal(screen.getByTestId('atom-test').textContent, 'old') + equal(screen.getByTestId('map-test').textContent, 'old') + + // Simulate store state change on client-side, after "server" render + atomStore.set('new') + mapStore.set({ value: 'new' }) + + // Hydrate into SSR element + act(() => { + 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 + deepStrictEqual(atomValues, ['old', 'old', 'new']) + deepStrictEqual(mapValues, ['old', 'old', 'new']) + + equal(screen.getByTestId('atom-test').textContent, 'new') + equal(screen.getByTestId('map-test').textContent, 'new') +})