From 0918237715da01a1b649b5b8daf4c5eb1c003ea2 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:42:03 +0200 Subject: [PATCH 1/8] feat(react-store)!: build useSelector on React's useSyncExternalStore with one selection ref, require React 18+ (#362) * 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 ``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> * ci: apply automated fixes and generate docs * feat(react-store)!: require React 18+, use the built-in useSyncExternalStore Review feedback on #362 asked to change the supported React versions rather than keep the `use-sync-external-store` shim around for React 16.8 and 17. The peer range is now `react` / `react-dom` `^18.0.0 || ^19.0.0`, so `useSelector` imports `useSyncExternalStore` from `react` and the `use-sync-external-store` dependency and its types are removed. The consumer bundle (react-store, minified, `react` and `@tanstack/store` external) goes from 3385 B raw / 1510 B gzip with both shim modules to 1347 B raw / 646 B gzip. Because dropping React 16/17 is breaking, the changeset is now `major` and the repo enters changesets pre mode with the `alpha` tag (`.changeset/pre.json`), so the release lands as `@tanstack/react-store@1.0.0-alpha.0`. `docs/installation.md` states the new minimum React version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * ci: apply automated fixes and generate docs * fix(react-store): call unsubscribe on the subscription object `useSelector` handed React the `unsubscribe` method detached from the subscription object, both before this PR (destructured) and in the rewrite. `SelectionSource` is structural, so a source whose `unsubscribe` relies on `this` (a class-based subscription, for example) satisfies the type but threw `TypeError` from React's effect cleanup and stayed subscribed. The cleanup is now a closure that calls `subscription.unsubscribe()`. Adds a regression test with a class-based subscription that fails with "Cannot set properties of undefined (setting 'closed')" on the previous code, and tidies the ReactDOM sentence in docs/installation.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * perf(react-store): keep useSelector callbacks in one ref, stable across renders `useSelector` still paid for three of its own hook slots per render (two `useCallback`s plus the store hook; React clones every hook object on each re-render and `useCallback` allocates the closure and deps array every time) and handed `useSyncExternalStore` a fresh `getSnapshot` each render. React compares `getSnapshot` by identity: whenever it changes it flags the fiber for passive effects, pushes an `updateStoreInstance` effect (plus a `bind`) and, in transitions, a store consistency check that calls `getSnapshot` again, even when nothing about the component changed. The hook now keeps a single instance in one `useRef`: the `subscribe` and `getSnapshot` callbacks together with the `source`, `selector` and `compare` they were built for. A new instance is only created when one of those inputs changes; `subscribe` is carried over unless the source changed, so React re-subscribes only then. Both closures capture their inputs instead of reading them from the ref, so a render that suspends with a different selector cannot change what the committed subscription selects (the suspended-transition test still passes). The selection record is shared by all instances of a component so inline selectors keep their identity-stable results, and it is now keyed on the compare function as well: after a compare-equal update the record advances its snapshot while keeping the previous selection, and a later render with a different `compare` used to hit that memo without ever consulting the new function. The with-selector shim keyed its memo on `isEqual`, so this restores parity; a new test pins it and fails on the previous commit. Measured with a throwaway vitest bench (production React 19.2.5, jsdom, 200 subscribed components, mean per operation): parent re-render with stable selectors and an unchanged store 0.164 -> 0.128 ms (-22%), inline selectors 0.161 -> 0.153 ms (-5%), store update re-rendering all 200 0.203 -> 0.188 ms (-8%), mount + unmount 0.795 -> 0.732 ms (-8%). A variant that kept `useCallback` for both callbacks was slower than the previous code, so the extra hook slot costs more than the skipped effect saves. The consumer bundle (react-store minified, react and @tanstack/store external) is 1347 -> 1660 B raw, 646 -> 740 B gzip for this, still down from 3385 / 1510 B on main. Tests: source switch moves the subscription and reads the new source; the compare function from the latest render is used. Docs: the installation page no longer claims ReactDOM-only support, React Native works as well. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * ci: apply automated fixes and generate docs * perf(react-store): key the useSelector memo on the getSnapshot closure Flatten useSelector's per-component state into one object that is mutated in place instead of an instance that was re-created on every input change plus a nested selection record, and key the memoized selection on the `getSnapshot` closure that computed it. That closure already captures the source, selector and compare it was built for, so the memo hit is two identity checks (owner, snapshot) instead of four, the record needs no selector/compare fields, and the three factory functions and the `previous` plumbing go away. Per render this removes one object allocation for inline selectors (only the closure is created now) and the nested record indirection from every `getSnapshot` call; a mount allocates one object instead of two. The stable path is unchanged: one ref, three comparisons, no allocations, no effects. A whole-render bench with 200 components cannot separate this from the previous commit (the hook is now a small fraction of React's per-component work), so the gain is by operation count. useSelector minified: 812 -> 595 B raw, 400 -> 343 B gzip. Consumer bundle (react-store minified, react and @tanstack/store external): 1660 -> 1443 B raw, 740 -> 676 B gzip; main ships 3385 / 1510 B. Dropping the owner check makes the suspended-transition, selector-switch and compare-change tests fail, so the key stays pinned. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * ci: apply automated fixes and generate docs * ci: apply automated fixes and generate docs * chore: update changsets file --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Corbin Crutchley --- .changeset/pre.json | 16 + .../react-store-use-selector-single-ref.md | 7 + .../react/reference/functions/useSelector.md | 2 +- .../interfaces/UseSelectorOptions.md | 4 +- docs/installation.md | 2 +- packages/react-store/package.json | 8 +- packages/react-store/src/useSelector.ts | 108 +++++-- packages/react-store/tests/index.test.tsx | 282 ++++++++++++++++++ pnpm-lock.yaml | 16 - 9 files changed, 399 insertions(+), 46 deletions(-) create mode 100644 .changeset/pre.json create mode 100644 .changeset/react-store-use-selector-single-ref.md diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..ad703a0b --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,16 @@ +{ + "mode": "pre", + "tag": "alpha", + "initialVersions": { + "@tanstack/angular-store": "0.11.2", + "@tanstack/lit-store": "0.14.2", + "@tanstack/octane-store": "0.13.0", + "@tanstack/preact-store": "0.13.5", + "@tanstack/react-store": "0.11.2", + "@tanstack/solid-store": "0.11.2", + "@tanstack/store": "0.11.2", + "@tanstack/svelte-store": "0.12.3", + "@tanstack/vue-store": "0.11.2" + }, + "changesets": [] +} diff --git a/.changeset/react-store-use-selector-single-ref.md b/.changeset/react-store-use-selector-single-ref.md new file mode 100644 index 00000000..c10eed47 --- /dev/null +++ b/.changeset/react-store-use-selector-single-ref.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-store': major +--- + +`@tanstack/react-store` now requires React 18 or newer (`peerDependencies` are `react` and `react-dom` `^18.0.0 || ^19.0.0`); support for React 16.8 and 17 has been dropped. + +`useSelector` builds on React's built-in `useSyncExternalStore` with a single memoized selection ref instead of the `use-sync-external-store/shim/with-selector` helper: fewer hook slots and allocations per subscribed component, no per-component passive effect, and the `use-sync-external-store` dependency is gone from consumer bundles. The public API and selection semantics of `useSelector`, `useAtom`, `_useStore` and `useStore` are unchanged. diff --git a/docs/framework/react/reference/functions/useSelector.md b/docs/framework/react/reference/functions/useSelector.md index 1ea0f378..4ea95cb5 100644 --- a/docs/framework/react/reference/functions/useSelector.md +++ b/docs/framework/react/reference/functions/useSelector.md @@ -10,7 +10,7 @@ function useSelector( options?): TSelected; ``` -Defined in: [packages/react-store/src/useSelector.ts:43](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L43) +Defined in: [packages/react-store/src/useSelector.ts:58](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L58) Selects a slice of state from an atom or store and subscribes the component to that selection. diff --git a/docs/framework/react/reference/interfaces/UseSelectorOptions.md b/docs/framework/react/reference/interfaces/UseSelectorOptions.md index 67e1e1e6..dfcd3401 100644 --- a/docs/framework/react/reference/interfaces/UseSelectorOptions.md +++ b/docs/framework/react/reference/interfaces/UseSelectorOptions.md @@ -3,7 +3,7 @@ id: UseSelectorOptions title: UseSelectorOptions --- -Defined in: [packages/react-store/src/useSelector.ts:4](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L4) +Defined in: [packages/react-store/src/useSelector.ts:3](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L3) ## Type Parameters @@ -19,7 +19,7 @@ Defined in: [packages/react-store/src/useSelector.ts:4](https://github.com/TanSt optional compare?: (a, b) => boolean; ``` -Defined in: [packages/react-store/src/useSelector.ts:5](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L5) +Defined in: [packages/react-store/src/useSelector.ts:4](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L4) #### Parameters diff --git a/docs/installation.md b/docs/installation.md index bdbc29af..6db7a2c4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -11,7 +11,7 @@ You can install TanStack Store with any [NPM](https://npmjs.com) package manager npm install @tanstack/react-store ``` -TanStack Store is compatible with React v16.8+ and is currently only compatible with ReactDOM only. If you would like to contribute to the React Native adapter, please reach out to us on [Discord](https://tlinz.com/discord). +TanStack Store is compatible with React v18+. ## Preact diff --git a/packages/react-store/package.json b/packages/react-store/package.json index a93c2c77..6c0b13fb 100644 --- a/packages/react-store/package.json +++ b/packages/react-store/package.json @@ -49,20 +49,18 @@ "src" ], "dependencies": { - "@tanstack/store": "workspace:*", - "use-sync-external-store": "^1.6.0" + "@tanstack/store": "workspace:*" }, "devDependencies": { "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/use-sync-external-store": "^1.5.0", "@vitejs/plugin-react": "^6.0.1", "react": "^19.2.5", "react-dom": "^19.2.5" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } } diff --git a/packages/react-store/src/useSelector.ts b/packages/react-store/src/useSelector.ts index 0f2593f0..6bf5e4c9 100644 --- a/packages/react-store/src/useSelector.ts +++ b/packages/react-store/src/useSelector.ts @@ -1,14 +1,9 @@ -import { useCallback } from 'react' -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector' +import { useRef, useSyncExternalStore } from 'react' export interface UseSelectorOptions { compare?: (a: TSelected, b: TSelected) => boolean } -type SyncExternalStoreSubscribe = Parameters< - typeof useSyncExternalStoreWithSelector ->[0] - type SelectionSource = { get: () => T subscribe: (listener: (value: T) => void) => { @@ -16,6 +11,26 @@ type SelectionSource = { } } +/** + * Per-component state, mutated in place. The inputs and the callbacks built + * for them are written during render; the selection is written by whichever + * `getSnapshot` closure computed it last and is keyed on that closure. + */ +type Instance = { + source?: SelectionSource + selector?: (snapshot: TSource) => TSelected + compare?: (a: TSelected, b: TSelected) => boolean + subscribe?: (onStoreChange: () => void) => () => void + getSnapshot?: () => TSelected + owner: (() => TSelected) | null + snapshot?: TSource + selected?: TSelected +} + +function identity(snapshot: TSource): TSelected { + return snapshot as unknown as TSelected +} + function defaultCompare(a: T, b: T) { return a === b } @@ -42,26 +57,77 @@ function defaultCompare(a: T, b: T) { */ export function useSelector>( source: SelectionSource, - selector: (snapshot: TSource) => TSelected = (s) => s as unknown as TSelected, + selector: (snapshot: TSource) => TSelected = identity, options?: UseSelectorOptions, ): TSelected { const compare = options?.compare ?? defaultCompare - const subscribe: SyncExternalStoreSubscribe = useCallback( - (handleStoreChange) => { - const { unsubscribe } = source.subscribe(handleStoreChange) - return unsubscribe - }, - [source], - ) + // One ref instead of `useCallback`s. `useSyncExternalStore` re-subscribes + // whenever `subscribe` changes identity and schedules a passive effect plus + // a consistency check whenever `getSnapshot` does, so both are only rebuilt + // when their inputs change. With a stable selector, a re-render that leaves + // the store untouched costs no allocations and no effects. + const instanceRef = useRef | null>(null) + const instance = + instanceRef.current ?? (instanceRef.current = { owner: null }) + const sourceChanged = instance.source !== source + + if (sourceChanged) { + instance.subscribe = (onStoreChange) => { + const subscription = source.subscribe(onStoreChange) - const getSnapshot = useCallback(() => source.get(), [source]) + // Call `unsubscribe` on the subscription so sources that rely on `this` + // keep working. + return () => subscription.unsubscribe() + } + } + + if ( + sourceChanged || + instance.selector !== selector || + instance.compare !== compare + ) { + instance.source = source + instance.selector = selector + instance.compare = compare + + // The closure captures its inputs instead of reading them from the + // instance so that a render which suspends with a different selector + // cannot change what the committed subscription selects. The selection is + // keyed on the closure for the same reason. + const getSnapshot = () => { + const snapshot = source.get() + + if (instance.owner !== getSnapshot || instance.snapshot !== snapshot) { + const selected = selector(snapshot) + + // Keep the previous selection's identity when `compare` considers the + // new one equal so that `useSyncExternalStore` does not re-render the + // component. Like the former `use-sync-external-store/shim/with-selector` + // helper, this compares against the previous selection even when the + // selector identity changed: inline selectors are recreated on every + // render and must still return the same object when the selection is + // equal. + if ( + instance.owner === null || + !compare(instance.selected as TSelected, selected) + ) { + instance.selected = selected + } + + instance.owner = getSnapshot + instance.snapshot = snapshot + } + + return instance.selected as TSelected + } + + instance.getSnapshot = getSnapshot + } - return useSyncExternalStoreWithSelector( - subscribe, - getSnapshot, - getSnapshot, - selector, - compare, + return useSyncExternalStore( + instance.subscribe!, + instance.getSnapshot!, + instance.getSnapshot, ) } diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 5f36579b..5de37bb7 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -637,6 +637,288 @@ describe('store hooks', () => { }) }) +describe('useSelector selection memo', () => { + type State = { a: number; b: number } + + it('does not re-run a stable selector when the component re-renders with an unchanged store value', () => { + const store = createStore({ a: 1, b: 2 }) + const selector = vi.fn((state: State) => state.a) + + function Comp({ label }: { label: string }) { + const value = useSelector(store, selector) + + return ( +

+ {label}: {value} +

+ ) + } + + const { getByText, rerender } = render() + + expect(getByText('First: 1')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(1) + + rerender() + + expect(getByText('Second: 1')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(1) + }) + + it('keeps the previous selection identity when compare returns true', () => { + const store = createStore({ items: [1, 2], other: 0 }) + const selections: Array<{ items: Array }> = [] + + function Comp() { + const selected = useSelector(store, (state) => ({ items: state.items }), { + compare: shallow, + }) + selections.push(selected) + + return

Items: {selected.items.join(',')}

+ } + + const { getByText, rerender } = render() + + expect(getByText('Items: 1,2')).toBeInTheDocument() + expect(selections).toHaveLength(1) + + // The selection is shallowly equal, so the component must not re-render. + act(() => { + store.setState((prev) => ({ ...prev, other: 1 })) + }) + + expect(selections).toHaveLength(1) + + // The inline selector has a new identity on every render; compare still + // runs against the previous selection so the component receives the same + // object. + rerender() + + expect(selections).toHaveLength(2) + expect(selections[1]).toBe(selections[0]) + + act(() => { + store.setState((prev) => ({ ...prev, items: [...prev.items, 3] })) + }) + + expect(getByText('Items: 1,2,3')).toBeInTheDocument() + expect(selections).toHaveLength(3) + expect(selections[2]).not.toBe(selections[0]) + }) + + it('re-runs a new selector and returns its selection', () => { + const store = createStore({ a: 1, b: 2 }) + const selectA = vi.fn((state: State) => state.a) + const selectB = vi.fn((state: State) => state.b) + + function Comp({ selector }: { selector: (state: State) => number }) { + const value = useSelector(store, selector) + + return

Value: {value}

+ } + + const { getByText, rerender } = render() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(selectA).toHaveBeenCalledTimes(1) + expect(selectB).not.toHaveBeenCalled() + + rerender() + + expect(getByText('Value: 2')).toBeInTheDocument() + expect(selectA).toHaveBeenCalledTimes(1) + expect(selectB).toHaveBeenCalledTimes(1) + + rerender() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(selectA).toHaveBeenCalledTimes(2) + expect(selectB).toHaveBeenCalledTimes(1) + }) + + it('re-runs the installed selector exactly once per store update', () => { + const store = createStore({ a: 1, b: 2 }) + const selector = vi.fn((state: State) => state.a) + const renderSpy = vi.fn() + + function Comp() { + const value = useSelector(store, selector) + renderSpy() + + return

Value: {value}

+ } + + const { getByText } = render() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(1) + expect(renderSpy).toHaveBeenCalledTimes(1) + + act(() => { + store.setState((prev) => ({ ...prev, a: 10 })) + }) + + expect(getByText('Value: 10')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(2) + expect(renderSpy).toHaveBeenCalledTimes(2) + + // An update that leaves the selection unchanged still runs the selector once + // to find that out, but does not re-render. + act(() => { + store.setState((prev) => ({ ...prev, b: 20 })) + }) + + expect(getByText('Value: 10')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(3) + expect(renderSpy).toHaveBeenCalledTimes(2) + }) +}) + +describe('useSelector subscription cleanup', () => { + it('unsubscribes through the subscription object so `this`-based sources clean up', () => { + const listeners = new Set<(value: number) => void>() + + class Subscription { + closed = false + + constructor(private readonly listener: (value: number) => void) {} + + // Throws if React calls it detached from the subscription object. + unsubscribe() { + this.closed = true + listeners.delete(this.listener) + } + } + + const source = { + get: () => 1, + subscribe: (listener: (value: number) => void) => { + listeners.add(listener) + return new Subscription(listener) + }, + } + + function Comp() { + const value = useSelector(source) + + return

Value: {value}

+ } + + const { getByText, unmount } = render() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(listeners.size).toBe(1) + + unmount() + + expect(listeners.size).toBe(0) + }) + + it('moves the subscription when the source changes', () => { + function createSource(initial: number) { + const listeners = new Set<(value: number) => void>() + let value = initial + + return { + listeners, + get: () => value, + set: (next: number) => { + value = next + listeners.forEach((listener) => listener(next)) + }, + subscribe: (listener: (value: number) => void) => { + listeners.add(listener) + return { + unsubscribe: () => { + listeners.delete(listener) + }, + } + }, + } + } + + const first = createSource(1) + const second = createSource(10) + + function Comp({ source }: { source: typeof first }) { + const value = useSelector(source) + + return

Value: {value}

+ } + + const { getByText, rerender } = render() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(first.listeners.size).toBe(1) + + rerender() + + expect(getByText('Value: 10')).toBeInTheDocument() + expect(first.listeners.size).toBe(0) + expect(second.listeners.size).toBe(1) + + act(() => { + second.set(20) + }) + + expect(getByText('Value: 20')).toBeInTheDocument() + }) +}) + +describe('useSelector compare changes', () => { + it('uses the compare function from the latest render', () => { + type State = { a: number; b: number } + const store = createStore({ a: 0, b: 0 }) + const compareA = (x: State, y: State) => x.a === y.a + const compareB = (x: State, y: State) => x.b === y.b + const renderSpy = vi.fn() + + function Comp({ compare }: { compare: typeof compareA }) { + const value = useSelector(store, undefined, { compare }) + renderSpy() + + return ( +

+ a{value.a} b{value.b} +

+ ) + } + + const { getByText, rerender } = render() + + expect(getByText('a0 b0')).toBeInTheDocument() + + // compareA ignores `b`, so this update does not re-render. + act(() => { + store.setState((prev) => ({ ...prev, b: 1 })) + }) + + expect(getByText('a0 b0')).toBeInTheDocument() + expect(renderSpy).toHaveBeenCalledTimes(1) + + rerender() + + expect(getByText('a0 b1')).toBeInTheDocument() + expect(renderSpy).toHaveBeenCalledTimes(2) + + // compareB ignores `a`, so this update does not re-render. + act(() => { + store.setState((prev) => ({ ...prev, a: 1 })) + }) + + expect(getByText('a0 b1')).toBeInTheDocument() + expect(renderSpy).toHaveBeenCalledTimes(2) + + act(() => { + store.setState((prev) => ({ ...prev, b: 2 })) + }) + + expect(getByText('a1 b2')).toBeInTheDocument() + expect(renderSpy).toHaveBeenCalledTimes(3) + }) +}) + describe('useStore', () => { it('is a compatibility alias for useSelector', async () => { const store = createStore(0) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5abe8a24..43ad3839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1318,9 +1318,6 @@ importers: '@tanstack/store': specifier: workspace:* version: link:../store - use-sync-external-store: - specifier: ^1.6.0 - version: 1.6.0(react@19.2.5) devDependencies: '@testing-library/react': specifier: ^16.3.2 @@ -1331,9 +1328,6 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) - '@types/use-sync-external-store': - specifier: ^1.5.0 - version: 1.5.0 '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(vite@8.3.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.0)(yaml@2.9.0)) @@ -4301,8 +4295,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/use-sync-external-store@1.5.0': - resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} '@typescript-eslint/eslint-plugin@8.58.1': resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} @@ -7477,10 +7469,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -10305,7 +10293,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/use-sync-external-store@1.5.0': {} '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: @@ -14301,9 +14288,6 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.5): - dependencies: - react: 19.2.5 util-deprecate@1.0.2: {} From cf5c080072be9cacae6bd2e0ee16a231a6a00742 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 9 Oct 2026 16:49:11 -0700 Subject: [PATCH 2/8] [alpha] Drop Vue 2 Support (#389) chore: remove Vue 2 support --- .changeset/grumpy-hairs-shop.md | 7 +++ .github/renovate.json | 4 +- docs/installation.md | 2 +- knip.json | 3 - packages/vue-store/package.json | 22 ++----- packages/vue-store/src/_useStore.ts | 2 +- packages/vue-store/src/useAtom.ts | 2 +- packages/vue-store/src/useSelector.ts | 4 +- packages/vue-store/src/useStore.ts | 2 +- packages/vue-store/tests/index.test.tsx | 2 +- packages/vue-store/tests/test.test-d.ts | 2 +- pnpm-lock.yaml | 78 +------------------------ pnpm-workspace.yaml | 10 ---- 13 files changed, 21 insertions(+), 119 deletions(-) create mode 100644 .changeset/grumpy-hairs-shop.md diff --git a/.changeset/grumpy-hairs-shop.md b/.changeset/grumpy-hairs-shop.md new file mode 100644 index 00000000..4b417a31 --- /dev/null +++ b/.changeset/grumpy-hairs-shop.md @@ -0,0 +1,7 @@ +--- +'@tanstack/vue-store': major +--- + +Drop Vue 2 support from `@tanstack/vue-store`. The adapter now requires Vue 3 (`vue: ^3.0.0`) and imports Vue APIs and types directly, removing its `vue-demi` dependency and optional `@vue/composition-api` peer dependency. + +Vue 2 applications must upgrade to Vue 3 or keep using a previous version of `@tanstack/vue-store`. The adapter's public hooks and their behavior are unchanged for Vue 3 applications. diff --git a/.github/renovate.json b/.github/renovate.json index eecf09c7..cf5493ac 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -28,8 +28,6 @@ "typescript50", "typescript51", "typescript52", - "typescript53", - "vue2", - "vue2.7" + "typescript53" ] } diff --git a/docs/installation.md b/docs/installation.md index 6db7a2c4..035f9654 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,7 +27,7 @@ TanStack Store is compatible with Preact 10+. npm install @tanstack/vue-store ``` -TanStack Store is compatible with Vue 2 and 3. +TanStack Store is compatible with Vue 3. ## Angular diff --git a/knip.json b/knip.json index 1be79759..84210695 100644 --- a/knip.json +++ b/knip.json @@ -3,9 +3,6 @@ "workspaces": { "packages/store": { "entry": ["src/signal.ts"] - }, - "packages/vue-store": { - "ignoreDependencies": ["vue2", "vue2.7"] } }, "ignoreWorkspaces": ["examples/**"] diff --git a/packages/vue-store/package.json b/packages/vue-store/package.json index b6517925..929c7d9e 100644 --- a/packages/vue-store/package.json +++ b/packages/vue-store/package.json @@ -27,11 +27,7 @@ "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js", "test:types:ts58": "node ../../node_modules/typescript58/lib/tsc.js", "test:types:ts59": "tsc", - "test:lib": "pnpm run test:lib:3", - "test:lib:2": "vue-demi-switch 2 vue2 && vitest", - "test:lib:2.7": "vue-demi-switch 2.7 vue2.7 && vitest", - "test:lib:3": "vue-demi-switch 3 && vitest", - "test:lib:fixme": "pnpm run test:lib:2 && pnpm run test:lib:2.7 && pnpm run test:lib:3", + "test:lib": "vitest", "test:lib:dev": "pnpm run test:lib --watch", "test:build": "publint --strict", "build": "tsdown --tsconfig tsconfig.build.json" @@ -53,24 +49,14 @@ "src" ], "dependencies": { - "@tanstack/store": "workspace:*", - "vue-demi": "^0.14.10" + "@tanstack/store": "workspace:*" }, "devDependencies": { "@testing-library/vue": "^8.1.0", "@vitejs/plugin-vue": "^6.0.5", - "@vue/composition-api": "^1.7.2", - "vue": "^3.5.43", - "vue2": "npm:vue@2.6", - "vue2.7": "npm:vue@2.7" + "vue": "^3.5.43" }, "peerDependencies": { - "@vue/composition-api": "^1.2.1", - "vue": "^2.5.0 || ^3.0.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } + "vue": "^3.0.0" } } diff --git a/packages/vue-store/src/_useStore.ts b/packages/vue-store/src/_useStore.ts index f7401fb8..8e990f7c 100644 --- a/packages/vue-store/src/_useStore.ts +++ b/packages/vue-store/src/_useStore.ts @@ -1,5 +1,5 @@ import { useSelector } from './useSelector' -import type { Ref } from 'vue-demi' +import type { Ref } from 'vue' import type { Store, StoreActionMap } from '@tanstack/store' import type { UseSelectorOptions } from './useSelector' diff --git a/packages/vue-store/src/useAtom.ts b/packages/vue-store/src/useAtom.ts index 44680038..f5271d64 100644 --- a/packages/vue-store/src/useAtom.ts +++ b/packages/vue-store/src/useAtom.ts @@ -1,5 +1,5 @@ import { useSelector } from './useSelector' -import type { Ref } from 'vue-demi' +import type { Ref } from 'vue' import type { Atom } from '@tanstack/store' import type { UseSelectorOptions } from './useSelector' diff --git a/packages/vue-store/src/useSelector.ts b/packages/vue-store/src/useSelector.ts index 1a3cea66..1ed054e4 100644 --- a/packages/vue-store/src/useSelector.ts +++ b/packages/vue-store/src/useSelector.ts @@ -1,5 +1,5 @@ -import { onScopeDispose, readonly, shallowRef, toRaw } from 'vue-demi' -import type { Ref } from 'vue-demi' +import { onScopeDispose, readonly, shallowRef, toRaw } from 'vue' +import type { Ref } from 'vue' export interface UseSelectorOptions { compare?: (a: TSelected, b: TSelected) => boolean diff --git a/packages/vue-store/src/useStore.ts b/packages/vue-store/src/useStore.ts index 7b6d1346..7a273d8b 100644 --- a/packages/vue-store/src/useStore.ts +++ b/packages/vue-store/src/useStore.ts @@ -1,5 +1,5 @@ import { useSelector } from './useSelector' -import type { Ref } from 'vue-demi' +import type { Ref } from 'vue' /** * Deprecated alias for {@link useSelector}. diff --git a/packages/vue-store/tests/index.test.tsx b/packages/vue-store/tests/index.test.tsx index f9a2d0ce..a2a7c678 100644 --- a/packages/vue-store/tests/index.test.tsx +++ b/packages/vue-store/tests/index.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, test, vi } from 'vitest' -import { defineComponent, h } from 'vue-demi' +import { defineComponent, h } from 'vue' import { render, waitFor } from '@testing-library/vue' import { createAtom, createStore } from '@tanstack/store' import { userEvent } from '@testing-library/user-event' diff --git a/packages/vue-store/tests/test.test-d.ts b/packages/vue-store/tests/test.test-d.ts index 77410538..6a1c9151 100644 --- a/packages/vue-store/tests/test.test-d.ts +++ b/packages/vue-store/tests/test.test-d.ts @@ -1,7 +1,7 @@ import { expectTypeOf, test } from 'vitest' import { createAtom, createStore } from '@tanstack/store' import { _useStore, useAtom, useSelector, useStore } from '../src' -import type { Ref } from 'vue-demi' +import type { Ref } from 'vue' import type { Store } from '@tanstack/store' test('useSelector works with derived state', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43ad3839..edc8a422 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,7 +162,7 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -packageExtensionsChecksum: sha256-QSjdHvOExHHSyNiVe6itFpAjt52AQVWz92MEbXECXgk= +packageExtensionsChecksum: sha256-iWMlPJFHrj45xVLCE2lxHogmkbbHqhVgFYbuj0g0l1k= importers: @@ -1399,9 +1399,6 @@ importers: '@tanstack/store': specifier: workspace:* version: link:../store - vue-demi: - specifier: ^0.14.10 - version: 0.14.10(@vue/composition-api@1.7.2(vue@3.5.43(typescript@6.0.3)))(vue@3.5.43(typescript@6.0.3)) devDependencies: '@testing-library/vue': specifier: ^8.1.0 @@ -1409,18 +1406,9 @@ importers: '@vitejs/plugin-vue': specifier: ^6.0.5 version: 6.0.5(vite@8.3.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.46.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3)) - '@vue/composition-api': - specifier: ^1.7.2 - version: 1.7.2(vue@3.5.43(typescript@6.0.3)) vue: specifier: ^3.5.43 version: 3.5.43(typescript@6.0.3) - vue2: - specifier: npm:vue@2.6 - version: vue@2.6.14 - vue2.7: - specifier: npm:vue@2.7 - version: vue@2.7.16 packages: @@ -4295,7 +4283,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript-eslint/eslint-plugin@8.58.1': resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4544,9 +4531,6 @@ packages: '@vue/compiler-dom@3.5.43': resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} - '@vue/compiler-sfc@2.7.16': - resolution: {integrity: sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==} - '@vue/compiler-sfc@3.5.32': resolution: {integrity: sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==} @@ -4559,11 +4543,6 @@ packages: '@vue/compiler-ssr@3.5.43': resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} - '@vue/composition-api@1.7.2': - resolution: {integrity: sha512-M8jm9J/laYrYT02665HkZ5l2fWTK4dcVg3BsDHm/pfz+MjDYwX+9FUaZyGwEyXEDonQYRCo0H7aLgdklcIELjw==} - peerDependencies: - vue: '>= 2.5 < 2.7' - '@vue/language-core@3.2.6': resolution: {integrity: sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==} @@ -6633,11 +6612,6 @@ packages: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - prettier@2.8.8: - resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} - engines: {node: '>=10.13.0'} - hasBin: true - prettier@3.8.2: resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==} engines: {node: '>=14'} @@ -7469,7 +7443,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -7641,17 +7614,6 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} - vue-demi@0.14.10: - resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} - engines: {node: '>=12'} - hasBin: true - peerDependencies: - '@vue/composition-api': ^1.0.0-rc.1 - vue: ^3.0.0-0 || ^2.6.0 - peerDependenciesMeta: - '@vue/composition-api': - optional: true - vue-eslint-parser@10.4.0: resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7664,14 +7626,6 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue@2.6.14: - resolution: {integrity: sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==} - deprecated: Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details. - - vue@2.7.16: - resolution: {integrity: sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==} - deprecated: Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details. - vue@3.5.32: resolution: {integrity: sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==} peerDependencies: @@ -10293,7 +10247,6 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -10654,14 +10607,6 @@ snapshots: '@vue/compiler-core': 3.5.43 '@vue/shared': 3.5.43 - '@vue/compiler-sfc@2.7.16': - dependencies: - '@babel/parser': 7.29.8 - postcss: 8.5.28 - source-map: 0.6.1 - optionalDependencies: - prettier: 2.8.8 - '@vue/compiler-sfc@3.5.32': dependencies: '@babel/parser': 7.29.8 @@ -10696,10 +10641,6 @@ snapshots: '@vue/compiler-dom': 3.5.43 '@vue/shared': 3.5.43 - '@vue/composition-api@1.7.2(vue@3.5.43(typescript@6.0.3))': - dependencies: - vue: 3.5.43(typescript@6.0.3) - '@vue/language-core@3.2.6': dependencies: '@volar/language-core': 2.4.28 @@ -13363,9 +13304,6 @@ snapshots: prettier: 3.8.2 svelte: 5.55.7(@typescript-eslint/types@8.58.1) - prettier@2.8.8: - optional: true - prettier@3.8.2: {} pretty-format@27.5.1: @@ -14288,7 +14226,6 @@ snapshots: dependencies: punycode: 2.3.1 - util-deprecate@1.0.2: {} validate-npm-package-name@8.0.0: {} @@ -14474,12 +14411,6 @@ snapshots: vue-component-type-helpers@2.2.12: {} - vue-demi@0.14.10(@vue/composition-api@1.7.2(vue@3.5.43(typescript@6.0.3)))(vue@3.5.43(typescript@6.0.3)): - dependencies: - vue: 3.5.43(typescript@6.0.3) - optionalDependencies: - '@vue/composition-api': 1.7.2(vue@3.5.43(typescript@6.0.3)) - vue-eslint-parser@10.4.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -14498,13 +14429,6 @@ snapshots: '@vue/language-core': 3.2.6 typescript: 6.0.3 - vue@2.6.14: {} - - vue@2.7.16: - dependencies: - '@vue/compiler-sfc': 2.7.16 - csstype: 3.2.3 - vue@3.5.32(typescript@6.0.3): dependencies: '@vue/compiler-dom': 3.5.32 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8c502004..48f28675 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,14 +10,7 @@ trustPolicyExclude: - 'semver@5.7.2' # Socket: popular legacy line, 673M weekly downloads; alerts page returned no alert entries - 'semver@6.3.1' # Socket: popular latest-6 line, 673M weekly downloads; alerts page returned no alert entries -peerDependencyRules: - allowedVersions: - '@vue/composition-api>vue': '>= 2.5' - packageExtensions: - '@vue/composition-api': - peerDependencies: - vue: '>= 2.5' '@vue/test-utils@2.4.6': peerDependencies: # Use the consumer's Vue runtime for rendering and reactivity. @@ -44,9 +37,6 @@ allowBuilds: '@parcel/watcher': false # optional dep of @angular/build lmdb: false # optional dep of @angular/build msgpackr-extract: false # optional dep of @angular/build - - # @tanstack/vue-store - vue-demi: true # only required for vue 2 support minimumReleaseAgeExclude: # Renovate security update: nx@22.7.7 || 22.7.10 - nx@22.7.7 || 22.7.10 From 4c77d1a01000db0f850b0e12b4d265ca1dcd2dcd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:31:35 -0700 Subject: [PATCH 3/8] ci: Version Packages (alpha) (#388) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/pre.json | 14 +------------ .changeset/{ => pre}/grumpy-hairs-shop.md | 0 .../react-store-use-selector-single-ref.md | 0 examples/react/atoms/package.json | 2 +- examples/react/simple/package.json | 2 +- examples/react/store-actions/package.json | 2 +- examples/react/store-context/package.json | 2 +- examples/react/stores/package.json | 2 +- examples/vue/atoms/package.json | 2 +- examples/vue/simple/package.json | 2 +- examples/vue/store-actions/package.json | 2 +- examples/vue/store-context/package.json | 2 +- examples/vue/stores/package.json | 2 +- packages/react-store/CHANGELOG.md | 8 ++++++++ packages/react-store/package.json | 2 +- packages/vue-store/CHANGELOG.md | 8 ++++++++ packages/vue-store/package.json | 2 +- pnpm-lock.yaml | 20 +++++++++---------- 18 files changed, 39 insertions(+), 35 deletions(-) rename .changeset/{ => pre}/grumpy-hairs-shop.md (100%) rename .changeset/{ => pre}/react-store-use-selector-single-ref.md (100%) diff --git a/.changeset/pre.json b/.changeset/pre.json index ad703a0b..9a89b8a3 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,16 +1,4 @@ { "mode": "pre", - "tag": "alpha", - "initialVersions": { - "@tanstack/angular-store": "0.11.2", - "@tanstack/lit-store": "0.14.2", - "@tanstack/octane-store": "0.13.0", - "@tanstack/preact-store": "0.13.5", - "@tanstack/react-store": "0.11.2", - "@tanstack/solid-store": "0.11.2", - "@tanstack/store": "0.11.2", - "@tanstack/svelte-store": "0.12.3", - "@tanstack/vue-store": "0.11.2" - }, - "changesets": [] + "tag": "alpha" } diff --git a/.changeset/grumpy-hairs-shop.md b/.changeset/pre/grumpy-hairs-shop.md similarity index 100% rename from .changeset/grumpy-hairs-shop.md rename to .changeset/pre/grumpy-hairs-shop.md diff --git a/.changeset/react-store-use-selector-single-ref.md b/.changeset/pre/react-store-use-selector-single-ref.md similarity index 100% rename from .changeset/react-store-use-selector-single-ref.md rename to .changeset/pre/react-store-use-selector-single-ref.md diff --git a/examples/react/atoms/package.json b/examples/react/atoms/package.json index fdd138e1..bafccee9 100644 --- a/examples/react/atoms/package.json +++ b/examples/react/atoms/package.json @@ -9,7 +9,7 @@ "test:types": "tsc" }, "dependencies": { - "@tanstack/react-store": "^0.11.2", + "@tanstack/react-store": "^1.0.0-alpha.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/react/simple/package.json b/examples/react/simple/package.json index bcab4cc2..08a04ab4 100644 --- a/examples/react/simple/package.json +++ b/examples/react/simple/package.json @@ -9,7 +9,7 @@ "test:types": "tsc" }, "dependencies": { - "@tanstack/react-store": "^0.11.2", + "@tanstack/react-store": "^1.0.0-alpha.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/react/store-actions/package.json b/examples/react/store-actions/package.json index 5a61ccee..38c3a3f5 100644 --- a/examples/react/store-actions/package.json +++ b/examples/react/store-actions/package.json @@ -9,7 +9,7 @@ "test:types": "tsc" }, "dependencies": { - "@tanstack/react-store": "^0.11.2", + "@tanstack/react-store": "^1.0.0-alpha.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/react/store-context/package.json b/examples/react/store-context/package.json index 46ef8457..892012e5 100644 --- a/examples/react/store-context/package.json +++ b/examples/react/store-context/package.json @@ -9,7 +9,7 @@ "test:types": "tsc" }, "dependencies": { - "@tanstack/react-store": "^0.11.2", + "@tanstack/react-store": "^1.0.0-alpha.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/react/stores/package.json b/examples/react/stores/package.json index 97d9526e..a677fd1c 100644 --- a/examples/react/stores/package.json +++ b/examples/react/stores/package.json @@ -9,7 +9,7 @@ "test:types": "tsc" }, "dependencies": { - "@tanstack/react-store": "^0.11.2", + "@tanstack/react-store": "^1.0.0-alpha.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/vue/atoms/package.json b/examples/vue/atoms/package.json index fe066dc2..93676b80 100644 --- a/examples/vue/atoms/package.json +++ b/examples/vue/atoms/package.json @@ -10,7 +10,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/vue-store": "^0.11.2", + "@tanstack/vue-store": "^1.0.0-alpha.0", "vue": "^3.5.32" }, "devDependencies": { diff --git a/examples/vue/simple/package.json b/examples/vue/simple/package.json index 3954b448..8c5a1dde 100644 --- a/examples/vue/simple/package.json +++ b/examples/vue/simple/package.json @@ -10,7 +10,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/vue-store": "^0.11.2", + "@tanstack/vue-store": "^1.0.0-alpha.0", "vue": "^3.5.32" }, "devDependencies": { diff --git a/examples/vue/store-actions/package.json b/examples/vue/store-actions/package.json index 87ef13bf..442e2abc 100644 --- a/examples/vue/store-actions/package.json +++ b/examples/vue/store-actions/package.json @@ -10,7 +10,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/vue-store": "^0.11.2", + "@tanstack/vue-store": "^1.0.0-alpha.0", "vue": "^3.5.32" }, "devDependencies": { diff --git a/examples/vue/store-context/package.json b/examples/vue/store-context/package.json index e37980da..ea8d0605 100644 --- a/examples/vue/store-context/package.json +++ b/examples/vue/store-context/package.json @@ -10,7 +10,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/vue-store": "^0.11.2", + "@tanstack/vue-store": "^1.0.0-alpha.0", "vue": "^3.5.32" }, "devDependencies": { diff --git a/examples/vue/stores/package.json b/examples/vue/stores/package.json index f4ea2940..bae00b86 100644 --- a/examples/vue/stores/package.json +++ b/examples/vue/stores/package.json @@ -10,7 +10,7 @@ "serve": "vite preview" }, "dependencies": { - "@tanstack/vue-store": "^0.11.2", + "@tanstack/vue-store": "^1.0.0-alpha.0", "vue": "^3.5.32" }, "devDependencies": { diff --git a/packages/react-store/CHANGELOG.md b/packages/react-store/CHANGELOG.md index ca9e6f18..4d6afd14 100644 --- a/packages/react-store/CHANGELOG.md +++ b/packages/react-store/CHANGELOG.md @@ -1,5 +1,13 @@ # @tanstack/react-store +## 1.0.0-alpha.0 + +### Major Changes + +- [#362](https://github.com/TanStack/store/pull/362) [`5567af4`](https://github.com/TanStack/store/commit/5567af42a4eee4b5befad3acd2f589d3f4deb5bd) - `@tanstack/react-store` now requires React 18 or newer (`peerDependencies` are `react` and `react-dom` `^18.0.0 || ^19.0.0`); support for React 16.8 and 17 has been dropped. + + `useSelector` builds on React's built-in `useSyncExternalStore` with a single memoized selection ref instead of the `use-sync-external-store/shim/with-selector` helper: fewer hook slots and allocations per subscribed component, no per-component passive effect, and the `use-sync-external-store` dependency is gone from consumer bundles. The public API and selection semantics of `useSelector`, `useAtom`, `_useStore` and `useStore` are unchanged. + ## 0.11.2 ### Patch Changes diff --git a/packages/react-store/package.json b/packages/react-store/package.json index 6c0b13fb..f0decbd8 100644 --- a/packages/react-store/package.json +++ b/packages/react-store/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-store", - "version": "0.11.2", + "version": "1.0.0-alpha.0", "description": "Framework agnostic type-safe store w/ reactive framework adapters", "author": "Tanner Linsley", "license": "MIT", diff --git a/packages/vue-store/CHANGELOG.md b/packages/vue-store/CHANGELOG.md index 592fcc77..30cedd20 100644 --- a/packages/vue-store/CHANGELOG.md +++ b/packages/vue-store/CHANGELOG.md @@ -1,5 +1,13 @@ # @tanstack/vue-store +## 1.0.0-alpha.0 + +### Major Changes + +- [#389](https://github.com/TanStack/store/pull/389) [`c9bef08`](https://github.com/TanStack/store/commit/c9bef08b7b5e5e458e41622cf030463361b2dac9) - Drop Vue 2 support from `@tanstack/vue-store`. The adapter now requires Vue 3 (`vue: ^3.0.0`) and imports Vue APIs and types directly, removing its `vue-demi` dependency and optional `@vue/composition-api` peer dependency. + + Vue 2 applications must upgrade to Vue 3 or keep using a previous version of `@tanstack/vue-store`. The adapter's public hooks and their behavior are unchanged for Vue 3 applications. + ## 0.11.2 ### Patch Changes diff --git a/packages/vue-store/package.json b/packages/vue-store/package.json index 929c7d9e..12aaed04 100644 --- a/packages/vue-store/package.json +++ b/packages/vue-store/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-store", - "version": "0.11.2", + "version": "1.0.0-alpha.0", "description": "Framework agnostic type-safe store w/ reactive framework adapters", "author": "Tanner Linsley", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edc8a422..426062e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -767,7 +767,7 @@ importers: examples/react/atoms: dependencies: '@tanstack/react-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/react-store react: specifier: ^19.2.5 @@ -792,7 +792,7 @@ importers: examples/react/simple: dependencies: '@tanstack/react-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/react-store react: specifier: ^19.2.5 @@ -817,7 +817,7 @@ importers: examples/react/store-actions: dependencies: '@tanstack/react-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/react-store react: specifier: ^19.2.5 @@ -842,7 +842,7 @@ importers: examples/react/store-context: dependencies: '@tanstack/react-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/react-store react: specifier: ^19.2.5 @@ -867,7 +867,7 @@ importers: examples/react/stores: dependencies: '@tanstack/react-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/react-store react: specifier: ^19.2.5 @@ -1127,7 +1127,7 @@ importers: examples/vue/atoms: dependencies: '@tanstack/vue-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/vue-store vue: specifier: ^3.5.32 @@ -1149,7 +1149,7 @@ importers: examples/vue/simple: dependencies: '@tanstack/vue-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/vue-store vue: specifier: ^3.5.32 @@ -1171,7 +1171,7 @@ importers: examples/vue/store-actions: dependencies: '@tanstack/vue-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/vue-store vue: specifier: ^3.5.32 @@ -1193,7 +1193,7 @@ importers: examples/vue/store-context: dependencies: '@tanstack/vue-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/vue-store vue: specifier: ^3.5.32 @@ -1215,7 +1215,7 @@ importers: examples/vue/stores: dependencies: '@tanstack/vue-store': - specifier: ^0.11.2 + specifier: ^1.0.0-alpha.0 version: link:../../../packages/vue-store vue: specifier: ^3.5.32 From 4582ec37e9b5e074ddf84b5ffc4001784207998e Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:33:41 +0200 Subject: [PATCH 4/8] test(react-store): cover selection lifetime and subscription performance --- knip.json | 3 + packages/react-store/package.json | 1 + .../tests/fixtures/selector-retention.mts | 64 ++++++++ packages/react-store/tests/index.test.tsx | 120 +++++++++++++- .../react-store/tests/useSelector.bench.tsx | 155 ++++++++++++++++++ 5 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 packages/react-store/tests/fixtures/selector-retention.mts create mode 100644 packages/react-store/tests/useSelector.bench.tsx diff --git a/knip.json b/knip.json index 84210695..fe7b1246 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,9 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { + "packages/react-store": { + "entry": ["tests/fixtures/selector-retention.mts"] + }, "packages/store": { "entry": ["src/signal.ts"] } diff --git a/packages/react-store/package.json b/packages/react-store/package.json index f0decbd8..ccadd002 100644 --- a/packages/react-store/package.json +++ b/packages/react-store/package.json @@ -29,6 +29,7 @@ "test:types:ts59": "tsc", "test:lib": "vitest", "test:lib:dev": "pnpm run test:lib --watch", + "test:perf": "NODE_ENV=production vitest bench --run --coverage.enabled=false --typecheck.enabled=false", "test:build": "publint --strict", "build": "tsdown --tsconfig tsconfig.build.json" }, diff --git a/packages/react-store/tests/fixtures/selector-retention.mts b/packages/react-store/tests/fixtures/selector-retention.mts new file mode 100644 index 00000000..ecc06f51 --- /dev/null +++ b/packages/react-store/tests/fixtures/selector-retention.mts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import { setImmediate as endTurn } from 'node:timers/promises' +// jsdom is a runtime-only test dependency in this workspace. +// @ts-expect-error No declaration package is installed for jsdom. +import { JSDOM } from 'jsdom' +import { createElement, useLayoutEffect } from 'react' +import { createAtom } from '@tanstack/store' +const { useSelector } = (await import( + new URL('../../src/useSelector.ts', import.meta.url).href +)) as typeof import('../../src/useSelector') + +const dom = new JSDOM('
') +globalThis.window = dom.window +globalThis.document = dom.window.document +Object.defineProperty(globalThis, 'navigator', { + value: dom.window.navigator, + configurable: true, +}) +const { createRoot } = await import('react-dom/client') +const { flushSync } = await import('react-dom') +type Payload = { payload?: Array; update?: number } +const source = createAtom<{ item: Payload }>({ item: {} }) +const previous = new WeakRef({ + payload: new Array(100_000).fill('previous'), +}) +source.set({ item: previous.deref()! }) +const container = document.getElementById('root') +assert.ok(container) +const root = createRoot(container) +let observed: WeakRef | undefined + +function Component({ field }: { field: 'item' }) { + const selected = useSelector(source, (state) => state[field]) + useLayoutEffect(() => { + observed = new WeakRef(selected) + }, [selected]) + return null +} + +async function collect() { + for (let attempt = 0; attempt < 5; attempt++) { + await endTurn() + globalThis.gc!() + } +} + +try { + flushSync(() => root.render(createElement(Component, { field: 'item' }))) + assert.equal(observed!.deref(), previous.deref()) + await endTurn() + for (let update = 0; update < 100; update++) { + flushSync(() => source.set({ item: { update } })) + } + await collect() + assert.equal(observed!.deref()!.update, 99) + assert.equal( + previous.deref() === undefined, + true, + 'The subscription must release the first render selection while mounted', + ) +} finally { + root.unmount() + dom.window.close() +} diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 5de37bb7..0f70f1e7 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -1,4 +1,7 @@ -import { Suspense, startTransition, use, useState } from 'react' +import { execFile } from 'node:child_process' +import { resolve } from 'node:path' +import { promisify } from 'node:util' +import { StrictMode, Suspense, startTransition, use, useState } from 'react' import { act, fireEvent, @@ -776,6 +779,121 @@ describe('useSelector selection memo', () => { }) describe('useSelector subscription cleanup', () => { + it('releases the first render selection while the component stays mounted', async () => { + await promisify(execFile)( + process.execPath, + ['--expose-gc', resolve('tests/fixtures/selector-retention.mts')], + { env: { ...process.env, NODE_ENV: 'production' } }, + ) + }) + + it('keeps the subscription through StrictMode replay and inline selector changes', () => { + class Source { + value = 1 + listeners = new Set<(value: number) => void>() + subscriptions = 0 + cleanups = 0 + + get() { + return this.value + } + + subscribe(listener: (value: number) => void) { + this.subscriptions++ + this.listeners.add(listener) + return { + unsubscribe: () => { + this.cleanups++ + this.listeners.delete(listener) + }, + } + } + } + + const source = new Source() + function Comp({ offset }: { offset: number }) { + const value = useSelector(source, (state) => state + offset, { + compare: (a, b) => a === b, + }) + return

Value: {value}

+ } + + const { getByText, rerender, unmount } = render( + + + , + ) + expect(getByText('Value: 1')).toBeInTheDocument() + expect(source.listeners.size).toBe(1) + expect(source.subscriptions).toBe(source.cleanups + 1) + const subscriptions = source.subscriptions + const cleanups = source.cleanups + + rerender( + + + , + ) + expect(getByText('Value: 11')).toBeInTheDocument() + expect(source.subscriptions).toBe(subscriptions) + expect(source.cleanups).toBe(cleanups) + + unmount() + expect(source.listeners.size).toBe(0) + expect(source.cleanups).toBe(source.subscriptions) + }) + + it('keeps the committed source subscribed while a source change suspends', async () => { + const first = createAtom(1) + const second = createAtom(10) + const subscribeFirst = vi.spyOn(first, 'subscribe') + const subscribeSecond = vi.spyOn(second, 'subscribe') + const pending = new Promise(() => {}) + const readSecond = vi.fn((value: number) => value) + + function Value({ mode }: { mode: 'first' | 'second' }) { + const value = useSelector( + mode === 'first' ? first : second, + mode === 'first' ? undefined : readSecond, + ) + if (mode === 'second') { + use(pending) + } + return {value} + } + + function Comp() { + const [mode, setMode] = useState<'first' | 'second'>('first') + return ( + <> + + Loading

}> + +
+ + ) + } + + const { getByRole, getByTestId } = render() + expect(subscribeFirst).toHaveBeenCalledTimes(1) + fireEvent.click(getByRole('button', { name: 'Switch source' })) + await waitFor(() => expect(readSecond).toHaveBeenCalled()) + expect(subscribeSecond).not.toHaveBeenCalled() + + act(() => { + first.set(2) + second.set(20) + }) + expect(getByTestId('source-value')).toHaveTextContent('2') + + expect(subscribeSecond).not.toHaveBeenCalled() + }) + it('unsubscribes through the subscription object so `this`-based sources clean up', () => { const listeners = new Set<(value: number) => void>() diff --git a/packages/react-store/tests/useSelector.bench.tsx b/packages/react-store/tests/useSelector.bench.tsx new file mode 100644 index 00000000..a97b3bd1 --- /dev/null +++ b/packages/react-store/tests/useSelector.bench.tsx @@ -0,0 +1,155 @@ +/* eslint-disable @eslint-react/dom-no-flush-sync -- Time committed production updates synchronously. */ +import { createElement, useLayoutEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { flushSync } from 'react-dom' +import { afterAll, bench, describe, expect } from 'vitest' +import { createAtom } from '@tanstack/store' +import { useSelector } from '../src/index' + +type State = { value: number; ignored: number } +type Mode = 'stable' | 'inline' | 'equal' +const selectValue = (state: State) => state.value +const compareValue = (a: { value: number }, b: { value: number }) => + a.value === b.value + +function createSource(value: number) { + const atom = createAtom({ value, ignored: 0 }) + let active = 0 + return { + source: { + get: () => atom.get(), + subscribe(listener: (value: State) => void) { + const subscription = atom.subscribe(listener) + active++ + return { + unsubscribe() { + subscription.unsubscribe() + active-- + }, + } + }, + }, + setValue: (next: number) => + atom.set((state) => ({ ...state, value: next })), + setIgnored: (next: number) => + atom.set((state) => ({ ...state, ignored: next })), + active: () => active, + } +} + +function mount(mode: Mode) { + const first = createSource(0) + const second = createSource(10) + const container = document.createElement('div') + const root = createRoot(container) + let observed = 0 + let renders = 0 + + function Component({ + source, + offset, + }: { + source: typeof first.source + offset: number + }) { + const selected = useSelector( + source, + mode === 'stable' + ? selectValue + : mode === 'equal' + ? (state) => ({ value: state.value + offset }) + : (state) => state.value + offset, + mode === 'equal' + ? { + compare: compareValue as ( + a: number | { value: number }, + b: number | { value: number }, + ) => boolean, + } + : undefined, + ) + useLayoutEffect(() => { + observed = typeof selected === 'number' ? selected : selected.value + renders++ + }) + return null + } + + function render(offset = 0, source = first.source) { + flushSync(() => + root.render( + Array.from({ length: 100 }, (_, key) => + createElement(Component, { key, offset, source }), + ), + ), + ) + } + render() + return { + update: (value: number) => flushSync(() => first.setValue(value)), + ignored: (value: number) => flushSync(() => first.setIgnored(value)), + render, + switchSource: (value: number) => + render(0, value % 2 ? second.source : first.source), + read: () => observed, + renders: () => renders, + active: () => [first.active(), second.active()], + unmount: () => root.unmount(), + } +} + +// Validate each workload through rendered values and public subscriptions before +// timing it. An equality fast path must suppress rendering, not skip updates. +for (const mode of ['stable', 'inline', 'equal'] as const) { + const app = mount(mode) + expect(app.active()).toEqual([100, 0]) + app.update(1) + expect(app.read()).toBe(1) + const renders = app.renders() + app.ignored(1) + expect(app.renders()).toBe(renders) + app.render(5) + expect(app.read()).toBe(mode === 'stable' ? 1 : 6) + app.switchSource(1) + expect(app.read()).toBe(10) + expect(app.active()).toEqual([0, 100]) + app.unmount() + expect(app.active()).toEqual([0, 0]) +} + +describe('useSelector with 100 subscribers', () => { + const apps: Array> = [] + afterAll(() => apps.forEach((app) => app.unmount())) + + for (const [name, mode, operation] of [ + ['stable selector updates', 'stable', 'update'], + ['inline selector updates', 'inline', 'update'], + ['equal allocating projections', 'equal', 'ignored'], + ['stable selector parent renders', 'stable', 'render'], + ['captured prop parent renders', 'inline', 'render'], + ['source changes and cleanup', 'inline', 'switchSource'], + ] as const) { + const app = mount(mode) + apps.push(app) + let value = 0 + bench( + name, + () => { + for (let iteration = 0; iteration < 50; iteration++) { + app[operation](++value) + } + }, + { time: 1000, warmupTime: 500, iterations: 100 }, + ) + } + + bench( + 'mount and unmount inline selectors', + () => { + for (let iteration = 0; iteration < 10; iteration++) { + mount('inline').unmount() + } + }, + { time: 1000, warmupTime: 500, iterations: 100 }, + ) +}) From 69d28137dab29441e2fda69793e9eb0e2b67f4bb Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:41:21 +0200 Subject: [PATCH 5/8] fix(react-store): release retained selections and reduce callback allocations --- .changeset/kind-garlics-melt.md | 5 ++ packages/react-store/src/useSelector.ts | 72 +++++++++++++++---------- 2 files changed, 49 insertions(+), 28 deletions(-) create mode 100644 .changeset/kind-garlics-melt.md diff --git a/.changeset/kind-garlics-melt.md b/.changeset/kind-garlics-melt.md new file mode 100644 index 00000000..15bb197f --- /dev/null +++ b/.changeset/kind-garlics-melt.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-store': patch +--- + +Fix selections retained by mounted subscriptions and reduce selector callback allocation overhead. diff --git a/packages/react-store/src/useSelector.ts b/packages/react-store/src/useSelector.ts index 6bf5e4c9..8c9baf73 100644 --- a/packages/react-store/src/useSelector.ts +++ b/packages/react-store/src/useSelector.ts @@ -35,6 +35,44 @@ function defaultCompare(a: T, b: T) { return a === b } +// Keep snapshot inputs out of the subscription's closure scope. This also +// avoids nesting the snapshot callback inside a render's conditional scope. +function createGetSnapshot( + source: SelectionSource, + selector: (snapshot: TSource) => TSelected, + compare: (a: TSelected, b: TSelected) => boolean, + instance: Instance, +): () => TSelected { + const getSnapshot = () => { + const snapshot = source.get() + + if (instance.owner !== getSnapshot || instance.snapshot !== snapshot) { + const selected = selector(snapshot) + + // Keep the previous selection's identity when `compare` considers the + // new one equal so that `useSyncExternalStore` does not re-render the + // component. Like the former `use-sync-external-store/shim/with-selector` + // helper, this compares against the previous selection even when the + // selector identity changed: inline selectors are recreated on every + // render and must still return the same object when the selection is + // equal. + if ( + instance.owner === null || + !compare(instance.selected as TSelected, selected) + ) { + instance.selected = selected + } + + instance.owner = getSnapshot + instance.snapshot = snapshot + } + + return instance.selected as TSelected + } + + return getSnapshot +} + /** * Selects a slice of state from an atom or store and subscribes the component * to that selection. @@ -95,34 +133,12 @@ export function useSelector>( // instance so that a render which suspends with a different selector // cannot change what the committed subscription selects. The selection is // keyed on the closure for the same reason. - const getSnapshot = () => { - const snapshot = source.get() - - if (instance.owner !== getSnapshot || instance.snapshot !== snapshot) { - const selected = selector(snapshot) - - // Keep the previous selection's identity when `compare` considers the - // new one equal so that `useSyncExternalStore` does not re-render the - // component. Like the former `use-sync-external-store/shim/with-selector` - // helper, this compares against the previous selection even when the - // selector identity changed: inline selectors are recreated on every - // render and must still return the same object when the selection is - // equal. - if ( - instance.owner === null || - !compare(instance.selected as TSelected, selected) - ) { - instance.selected = selected - } - - instance.owner = getSnapshot - instance.snapshot = snapshot - } - - return instance.selected as TSelected - } - - instance.getSnapshot = getSnapshot + instance.getSnapshot = createGetSnapshot( + source, + selector, + compare, + instance, + ) } return useSyncExternalStore( From 9a58b1c780197096d32d33c7a19da073640ea880 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:22:34 +0000 Subject: [PATCH 6/8] ci: apply automated fixes and generate docs --- docs/framework/react/reference/functions/useSelector.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/framework/react/reference/functions/useSelector.md b/docs/framework/react/reference/functions/useSelector.md index 4ea95cb5..9d17a5db 100644 --- a/docs/framework/react/reference/functions/useSelector.md +++ b/docs/framework/react/reference/functions/useSelector.md @@ -10,7 +10,7 @@ function useSelector( options?): TSelected; ``` -Defined in: [packages/react-store/src/useSelector.ts:58](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L58) +Defined in: [packages/react-store/src/useSelector.ts:96](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L96) Selects a slice of state from an atom or store and subscribes the component to that selection. From 7948a02fb9ea73892d63d5235f605aacd0c84786 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:39:07 +0200 Subject: [PATCH 7/8] test(react-store): cover falsy selector cache values --- packages/react-store/tests/index.test.tsx | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 0f70f1e7..64c46d71 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -643,6 +643,63 @@ describe('store hooks', () => { describe('useSelector selection memo', () => { type State = { a: number; b: number } + it.each<[unknown, unknown]>([ + [undefined, false], + [null, 0], + [false, null], + [0, undefined], + ])('caches a %s snapshot with a %s selection', (snapshot, selected) => { + const atom = createAtom(snapshot) + const selector = vi.fn((_snapshot: unknown) => selected) + const compare = vi.fn(Object.is) + const { result, rerender } = renderHook(() => + useSelector(atom, selector, { compare }), + ) + + expect(result.current).toBe(selected) + expect(selector).toHaveBeenCalledTimes(1) + expect(selector).toHaveBeenCalledWith(snapshot) + expect(compare).not.toHaveBeenCalled() + + rerender() + + expect(result.current).toBe(selected) + expect(selector).toHaveBeenCalledTimes(1) + expect(compare).not.toHaveBeenCalled() + }) + + it('compares a cached undefined selection when the selector or comparator changes', () => { + type Selected = boolean | undefined + type Props = { + selector: (snapshot: number) => Selected + compare: (a: Selected, b: Selected) => boolean + } + const atom = createAtom(0) + const selectUndefined = vi.fn((_snapshot: number): Selected => undefined) + const selectFalse = vi.fn((_snapshot: number): Selected => false) + const equal = vi.fn((_a: Selected, _b: Selected) => true) + const strict = vi.fn((a: Selected, b: Selected) => a === b) + const { result, rerender } = renderHook( + ({ selector, compare }: Props) => + useSelector(atom, selector, { compare }), + { initialProps: { selector: selectUndefined, compare: equal } }, + ) + + expect(result.current).toBeUndefined() + expect(equal).not.toHaveBeenCalled() + + rerender({ selector: selectFalse, compare: equal }) + + expect(selectFalse).toHaveBeenCalledWith(0) + expect(equal).toHaveBeenCalledWith(undefined, false) + expect(result.current).toBeUndefined() + + rerender({ selector: selectFalse, compare: strict }) + + expect(strict).toHaveBeenCalledWith(undefined, false) + expect(result.current).toBe(false) + }) + it('does not re-run a stable selector when the component re-renders with an unchanged store value', () => { const store = createStore({ a: 1, b: 2 }) const selector = vi.fn((state: State) => state.a) From 3f377943ae3c377503dbd5f5181d1ddabad865d7 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:28:30 +0200 Subject: [PATCH 8/8] perf(react-store): shorten private selection cache keys --- .changeset/kind-garlics-melt.md | 2 +- .../react/reference/functions/useSelector.md | 2 +- packages/react-store/src/useSelector.ts | 25 ++++++++----------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.changeset/kind-garlics-melt.md b/.changeset/kind-garlics-melt.md index 15bb197f..03c0c95f 100644 --- a/.changeset/kind-garlics-melt.md +++ b/.changeset/kind-garlics-melt.md @@ -2,4 +2,4 @@ '@tanstack/react-store': patch --- -Fix selections retained by mounted subscriptions and reduce selector callback allocation overhead. +Fix selections retained by mounted subscriptions and reduce selector callback allocation overhead. Use compact private selection cache fields to reduce consumer bundle size. diff --git a/docs/framework/react/reference/functions/useSelector.md b/docs/framework/react/reference/functions/useSelector.md index 9d17a5db..3ab461bc 100644 --- a/docs/framework/react/reference/functions/useSelector.md +++ b/docs/framework/react/reference/functions/useSelector.md @@ -10,7 +10,7 @@ function useSelector( options?): TSelected; ``` -Defined in: [packages/react-store/src/useSelector.ts:96](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L96) +Defined in: [packages/react-store/src/useSelector.ts:94](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L94) Selects a slice of state from an atom or store and subscribes the component to that selection. diff --git a/packages/react-store/src/useSelector.ts b/packages/react-store/src/useSelector.ts index 8c9baf73..e736ffbe 100644 --- a/packages/react-store/src/useSelector.ts +++ b/packages/react-store/src/useSelector.ts @@ -22,9 +22,10 @@ type Instance = { compare?: (a: TSelected, b: TSelected) => boolean subscribe?: (onStoreChange: () => void) => () => void getSnapshot?: () => TSelected - owner: (() => TSelected) | null - snapshot?: TSource - selected?: TSelected + // Private cache keys stay short because property names survive minification. + o: (() => TSelected) | null // selection owner + s?: TSource // snapshot + v?: TSelected // selected value } function identity(snapshot: TSource): TSelected { @@ -46,7 +47,7 @@ function createGetSnapshot( const getSnapshot = () => { const snapshot = source.get() - if (instance.owner !== getSnapshot || instance.snapshot !== snapshot) { + if (instance.o !== getSnapshot || instance.s !== snapshot) { const selected = selector(snapshot) // Keep the previous selection's identity when `compare` considers the @@ -56,18 +57,15 @@ function createGetSnapshot( // selector identity changed: inline selectors are recreated on every // render and must still return the same object when the selection is // equal. - if ( - instance.owner === null || - !compare(instance.selected as TSelected, selected) - ) { - instance.selected = selected + if (instance.o === null || !compare(instance.v as TSelected, selected)) { + instance.v = selected } - instance.owner = getSnapshot - instance.snapshot = snapshot + instance.o = getSnapshot + instance.s = snapshot } - return instance.selected as TSelected + return instance.v as TSelected } return getSnapshot @@ -106,8 +104,7 @@ export function useSelector>( // when their inputs change. With a stable selector, a re-render that leaves // the store untouched costs no allocations and no effects. const instanceRef = useRef | null>(null) - const instance = - instanceRef.current ?? (instanceRef.current = { owner: null }) + const instance = instanceRef.current ?? (instanceRef.current = { o: null }) const sourceChanged = instance.source !== source if (sourceChanged) {