Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fancy-colts-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/preact-store': patch
---

useSelector handles unstable selector functions now
27 changes: 26 additions & 1 deletion packages/preact-store/src/useSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,35 @@ export function useSelector<TSource, TSelected = NoInfer<TSource>>(

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,
)
}
41 changes: 41 additions & 0 deletions packages/preact-store/tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<p>Values: {values.join(',')}</p>
<button
type="button"
onClick={() =>
store.setState((prev) => ({ ...prev, one: prev.one + 1 }))
}
>
Update one
</button>
<button
type="button"
onClick={() =>
store.setState((prev) => ({ ...prev, two: prev.two + 1 }))
}
>
Update two
</button>
</div>
)
}

const { getByText } = render(<Comp />)
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())
})
})
41 changes: 41 additions & 0 deletions packages/react-store/tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<p>Values: {values.join(',')}</p>
<button
type="button"
onClick={() =>
store.setState((prev) => ({ ...prev, one: prev.one + 1 }))
}
>
Update one
</button>
<button
type="button"
onClick={() =>
store.setState((prev) => ({ ...prev, two: prev.two + 1 }))
}
>
Update two
</button>
</div>
)
}

const { getByText } = render(<Comp />)
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())
})
})
Loading