b0302f68 perf(react-store): build useSelector on useSyncExternalStore with one selection ref
`useSelector` wrapped `use-sync-external-store/shim/with-selector`. Per
subscribed component and per render that stack ran two `useCallback`s in
`useSelector` (`subscribe`, `getSnapshot`) and, inside the shim, a
`useRef`, a `useMemo` with four deps that rebuilt the memoized selector
whenever the (usually inline) selector changed identity, a `useEffect`
copying the committed value into the ref, `useDebugValue`, and finally
`useSyncExternalStore`: about seven hook slots and six allocations per
render plus a passive effect React had to traverse on every commit.
Measured in TanStack Router with 200 mounted `<Link>`s, a plain
`useSyncExternalStore` plus a single ref cut retained heap by 8%
(2738 -> 2512 KB) and re-render CPU by about 5% on renders that
recompute the selection.
`useSelector` now calls `useSyncExternalStore` from
`use-sync-external-store/shim` directly. One `useRef` holds the last
`{ selector, snapshot, selected }` record, mutated in place. `getSnapshot`
reads `source.get()`; when the record's selector and snapshot are
identical (`===`) it returns the stored selection, otherwise it runs the
selector and, when `compare(previous, next)` holds, keeps the previous
selection so `useSyncExternalStore` sees an unchanged value and skips the
re-render. Keying the memo on the selector identity as well as the
snapshot is what keeps a render that suspends with a different selector
(pinned by the existing suspended-transition test) from poisoning the
committed selector's selection. As in the with-selector shim, `compare`
runs against the previous selection regardless of which selector produced
it, which is what keeps inline selectors identity-stable across
re-renders. The default identity selector is hoisted so
`useSelector(atom)` hits the memo too.
`subscribe` stays memoized on `[source]`: React re-subscribes in a
passive effect whenever `subscribe` changes identity (its deps array is
`[subscribe]`), so a per-render closure would tear down and recreate the
store subscription on every render. `getSnapshot` is a plain closure: it
has to read this render's `selector` and `compare`, which are usually
inline and would defeat a `useCallback` anyway; React only compares its
identity to decide whether to re-check the store after commit.
The base shim is kept because the peer range still includes React 16.8
and 17, which have no native `useSyncExternalStore`; on React 18+ the
shim delegates to the native hook. Only the `with-selector` entry is
dropped, so that module leaves consumer bundles (react-store + shim,
minified: 3385 -> 2808 B raw, 1510 -> 1331 B gzip).
Public API and semantics are unchanged; all existing tests pass
unmodified. New tests pin that a stable selector is not re-run on a
re-render with an unchanged store value, that `compare` returning true
keeps the previous selection identity without re-rendering, that a new
selector is re-run and its selection returned, and that a store update
re-runs the installed selector exactly once.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> b0302f68 perf(react-store): build useSelector on useSyncExternalStore with one selection ref
`useSelector` wrapped `use-sync-external-store/shim/with-selector`. Per
subscribed component and per render that stack ran two `useCallback`s in
`useSelector` (`subscribe`, `getSnapshot`) and, inside the shim, a
`useRef`, a `useMemo` with four deps that rebuilt the memoized selector
whenever the (usually inline) selector changed identity, a `useEffect`
copying the committed value into the ref, `useDebugValue`, and finally
`useSyncExternalStore`: about seven hook slots and six allocations per
render plus a passive effect React had to traverse on every commit.
Measured in TanStack Router with 200 mounted `<Link>`s, a plain
`useSyncExternalStore` plus a single ref cut retained heap by 8%
(2738 -> 2512 KB) and re-render CPU by about 5% on renders that
recompute the selection.
`useSelector` now calls `useSyncExternalStore` from
`use-sync-external-store/shim` directly. One `useRef` holds the last
`{ selector, snapshot, selected }` record, mutated in place. `getSnapshot`
reads `source.get()`; when the record's selector and snapshot are
identical (`===`) it returns the stored selection, otherwise it runs the
selector and, when `compare(previous, next)` holds, keeps the previous
selection so `useSyncExternalStore` sees an unchanged value and skips the
re-render. Keying the memo on the selector identity as well as the
snapshot is what keeps a render that suspends with a different selector
(pinned by the existing suspended-transition test) from poisoning the
committed selector's selection. As in the with-selector shim, `compare`
runs against the previous selection regardless of which selector produced
it, which is what keeps inline selectors identity-stable across
re-renders. The default identity selector is hoisted so
`useSelector(atom)` hits the memo too.
`subscribe` stays memoized on `[source]`: React re-subscribes in a
passive effect whenever `subscribe` changes identity (its deps array is
`[subscribe]`), so a per-render closure would tear down and recreate the
store subscription on every render. `getSnapshot` is a plain closure: it
has to read this render's `selector` and `compare`, which are usually
inline and would defeat a `useCallback` anyway; React only compares its
identity to decide whether to re-check the store after commit.
The base shim is kept because the peer range still includes React 16.8
and 17, which have no native `useSyncExternalStore`; on React 18+ the
shim delegates to the native hook. Only the `with-selector` entry is
dropped, so that module leaves consumer bundles (react-store + shim,
minified: 3385 -> 2808 B raw, 1510 -> 1331 B gzip).
Public API and semantics are unchanged; all existing tests pass
unmodified. New tests pin that a stable selector is not re-run on a
re-render with an unchanged store value, that `compare` returning true
keeps the previous selection identity without re-rendering, that a new
selector is re-run and its selection returned, and that a store update
re-runs the installed selector exactly once.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> b0302f68 perf(react-store): build useSelector on useSyncExternalStore with one selection ref
`useSelector` wrapped `use-sync-external-store/shim/with-selector`. Per
subscribed component and per render that stack ran two `useCallback`s in
`useSelector` (`subscribe`, `getSnapshot`) and, inside the shim, a
`useRef`, a `useMemo` with four deps that rebuilt the memoized selector
whenever the (usually inline) selector changed identity, a `useEffect`
copying the committed value into the ref, `useDebugValue`, and finally
`useSyncExternalStore`: about seven hook slots and six allocations per
render plus a passive effect React had to traverse on every commit.
Measured in TanStack Router with 200 mounted `<Link>`s, a plain
`useSyncExternalStore` plus a single ref cut retained heap by 8%
(2738 -> 2512 KB) and re-render CPU by about 5% on renders that
recompute the selection.
`useSelector` now calls `useSyncExternalStore` from
`use-sync-external-store/shim` directly. One `useRef` holds the last
`{ selector, snapshot, selected }` record, mutated in place. `getSnapshot`
reads `source.get()`; when the record's selector and snapshot are
identical (`===`) it returns the stored selection, otherwise it runs the
selector and, when `compare(previous, next)` holds, keeps the previous
selection so `useSyncExternalStore` sees an unchanged value and skips the
re-render. Keying the memo on the selector identity as well as the
snapshot is what keeps a render that suspends with a different selector
(pinned by the existing suspended-transition test) from poisoning the
committed selector's selection. As in the with-selector shim, `compare`
runs against the previous selection regardless of which selector produced
it, which is what keeps inline selectors identity-stable across
re-renders. The default identity selector is hoisted so
`useSelector(atom)` hits the memo too.
`subscribe` stays memoized on `[source]`: React re-subscribes in a
passive effect whenever `subscribe` changes identity (its deps array is
`[subscribe]`), so a per-render closure would tear down and recreate the
store subscription on every render. `getSnapshot` is a plain closure: it
has to read this render's `selector` and `compare`, which are usually
inline and would defeat a `useCallback` anyway; React only compares its
identity to decide whether to re-check the store after commit.
The base shim is kept because the peer range still includes React 16.8
and 17, which have no native `useSyncExternalStore`; on React 18+ the
shim delegates to the native hook. Only the `with-selector` entry is
dropped, so that module leaves consumer bundles (react-store + shim,
minified: 3385 -> 2808 B raw, 1510 -> 1331 B gzip).
Public API and semantics are unchanged; all existing tests pass
unmodified. New tests pin that a stable selector is not re-run on a
re-render with an unchanged store value, that `compare` returning true
keeps the previous selection identity without re-rendering, that a new
selector is re-run and its selection returned, and that a store update
re-runs the installed selector exactly once.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> b0302f68 perf(react-store): build useSelector on useSyncExternalStore with one selection ref
`useSelector` wrapped `use-sync-external-store/shim/with-selector`. Per
subscribed component and per render that stack ran two `useCallback`s in
`useSelector` (`subscribe`, `getSnapshot`) and, inside the shim, a
`useRef`, a `useMemo` with four deps that rebuilt the memoized selector
whenever the (usually inline) selector changed identity, a `useEffect`
copying the committed value into the ref, `useDebugValue`, and finally
`useSyncExternalStore`: about seven hook slots and six allocations per
render plus a passive effect React had to traverse on every commit.
Measured in TanStack Router with 200 mounted `<Link>`s, a plain
`useSyncExternalStore` plus a single ref cut retained heap by 8%
(2738 -> 2512 KB) and re-render CPU by about 5% on renders that
recompute the selection.
`useSelector` now calls `useSyncExternalStore` from
`use-sync-external-store/shim` directly. One `useRef` holds the last
`{ selector, snapshot, selected }` record, mutated in place. `getSnapshot`
reads `source.get()`; when the record's selector and snapshot are
identical (`===`) it returns the stored selection, otherwise it runs the
selector and, when `compare(previous, next)` holds, keeps the previous
selection so `useSyncExternalStore` sees an unchanged value and skips the
re-render. Keying the memo on the selector identity as well as the
snapshot is what keeps a render that suspends with a different selector
(pinned by the existing suspended-transition test) from poisoning the
committed selector's selection. As in the with-selector shim, `compare`
runs against the previous selection regardless of which selector produced
it, which is what keeps inline selectors identity-stable across
re-renders. The default identity selector is hoisted so
`useSelector(atom)` hits the memo too.
`subscribe` stays memoized on `[source]`: React re-subscribes in a
passive effect whenever `subscribe` changes identity (its deps array is
`[subscribe]`), so a per-render closure would tear down and recreate the
store subscription on every render. `getSnapshot` is a plain closure: it
has to read this render's `selector` and `compare`, which are usually
inline and would defeat a `useCallback` anyway; React only compares its
identity to decide whether to re-check the store after commit.
The base shim is kept because the peer range still includes React 16.8
and 17, which have no native `useSyncExternalStore`; on React 18+ the
shim delegates to the native hook. Only the `with-selector` entry is
dropped, so that module leaves consumer bundles (react-store + shim,
minified: 3385 -> 2808 B raw, 1510 -> 1331 B gzip).
Public API and semantics are unchanged; all existing tests pass
unmodified. New tests pin that a stable selector is not re-run on a
re-render with an unchanged store value, that `compare` returning true
keeps the previous selection identity without re-rendering, that a new
selector is re-run and its selection returned, and that a store update
re-runs the installed selector exactly once.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>