diff --git a/.changeset/fancy-colts-rush.md b/.changeset/fancy-colts-rush.md new file mode 100644 index 00000000..fcbcd9f6 --- /dev/null +++ b/.changeset/fancy-colts-rush.md @@ -0,0 +1,5 @@ +--- +'@tanstack/preact-store': patch +--- + +useSelector handles unstable selector functions now diff --git a/packages/preact-store/src/useSelector.ts b/packages/preact-store/src/useSelector.ts index 988d2681..3db1e975 100644 --- a/packages/preact-store/src/useSelector.ts +++ b/packages/preact-store/src/useSelector.ts @@ -142,10 +142,35 @@ export function useSelector>( const getSnapshot = useCallback(() => source.get(), [source]) + // Memoize the selector's output by the source snapshot so that selectors + // returning a fresh reference each call (e.g. `(s) => [s.one, s.two]`) still + // produce a stable reference for unchanged snapshots. Without this, the + // shim's identity-based memo would always miss and trigger an update loop. + const selectorMemoRef = useRef<{ + _hasMemo: boolean + _snapshot: TSource | undefined + _selected: TSelected | undefined + }>({ _hasMemo: false, _snapshot: undefined, _selected: undefined }) + + const memoizedSelector = useCallback( + (snapshot: TSource): TSelected => { + const memo = selectorMemoRef.current + if (memo._hasMemo && Object.is(memo._snapshot, snapshot)) { + return memo._selected as TSelected + } + const next = selector(snapshot) + memo._hasMemo = true + memo._snapshot = snapshot + memo._selected = next + return next + }, + [selector], + ) + return useSyncExternalStoreWithSelector( subscribe, getSnapshot, - selector, + memoizedSelector, compare, ) } diff --git a/packages/preact-store/tests/index.test.tsx b/packages/preact-store/tests/index.test.tsx index d3cb60a7..514ba377 100644 --- a/packages/preact-store/tests/index.test.tsx +++ b/packages/preact-store/tests/index.test.tsx @@ -800,4 +800,45 @@ describe('shallow', () => { const objB = new Date('2025-02-10') expect(shallow(objA, objB)).toBe(true) }) + + test('should handle arrays inside of a useSelector', async () => { + const store = createStore({ one: 1, two: 2 }) + + function Comp() { + const values = useSelector(store, (state) => [state.one, state.two]) + + return ( +
+

Values: {values.join(',')}

+ + +
+ ) + } + + const { getByText } = render() + expect(getByText('Values: 1,2')).toBeInTheDocument() + + await user.click(getByText('Update one')) + + await waitFor(() => expect(getByText('Values: 2,2')).toBeInTheDocument()) + + await user.click(getByText('Update two')) + + await waitFor(() => expect(getByText('Values: 2,3')).toBeInTheDocument()) + }) }) diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 60369d3f..e431e96d 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -764,4 +764,45 @@ describe('shallow', () => { expect(shallow(objA, objB)).toBe(true) }) + + test('should handle arrays inside of a useSelector', async () => { + const store = createStore({ one: 1, two: 2 }) + + function Comp() { + const values = useSelector(store, (state) => [state.one, state.two]) + + return ( +
+

Values: {values.join(',')}

+ + +
+ ) + } + + const { getByText } = render() + expect(getByText('Values: 1,2')).toBeInTheDocument() + + await user.click(getByText('Update one')) + + await waitFor(() => expect(getByText('Values: 2,2')).toBeInTheDocument()) + + await user.click(getByText('Update two')) + + await waitFor(() => expect(getByText('Values: 2,3')).toBeInTheDocument()) + }) })