Skip to content

Commit 4e0c7ef

Browse files
authored
Fix preact selector stability (#318)
* fix: preact adapter handles unstable selector functions now * chore: add changeset
1 parent 83e2978 commit 4e0c7ef

4 files changed

Lines changed: 113 additions & 1 deletion

File tree

.changeset/fancy-colts-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/preact-store': patch
3+
---
4+
5+
useSelector handles unstable selector functions now

packages/preact-store/src/useSelector.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,35 @@ export function useSelector<TSource, TSelected = NoInfer<TSource>>(
142142

143143
const getSnapshot = useCallback(() => source.get(), [source])
144144

145+
// Memoize the selector's output by the source snapshot so that selectors
146+
// returning a fresh reference each call (e.g. `(s) => [s.one, s.two]`) still
147+
// produce a stable reference for unchanged snapshots. Without this, the
148+
// shim's identity-based memo would always miss and trigger an update loop.
149+
const selectorMemoRef = useRef<{
150+
_hasMemo: boolean
151+
_snapshot: TSource | undefined
152+
_selected: TSelected | undefined
153+
}>({ _hasMemo: false, _snapshot: undefined, _selected: undefined })
154+
155+
const memoizedSelector = useCallback(
156+
(snapshot: TSource): TSelected => {
157+
const memo = selectorMemoRef.current
158+
if (memo._hasMemo && Object.is(memo._snapshot, snapshot)) {
159+
return memo._selected as TSelected
160+
}
161+
const next = selector(snapshot)
162+
memo._hasMemo = true
163+
memo._snapshot = snapshot
164+
memo._selected = next
165+
return next
166+
},
167+
[selector],
168+
)
169+
145170
return useSyncExternalStoreWithSelector(
146171
subscribe,
147172
getSnapshot,
148-
selector,
173+
memoizedSelector,
149174
compare,
150175
)
151176
}

packages/preact-store/tests/index.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,4 +800,45 @@ describe('shallow', () => {
800800
const objB = new Date('2025-02-10')
801801
expect(shallow(objA, objB)).toBe(true)
802802
})
803+
804+
test('should handle arrays inside of a useSelector', async () => {
805+
const store = createStore({ one: 1, two: 2 })
806+
807+
function Comp() {
808+
const values = useSelector(store, (state) => [state.one, state.two])
809+
810+
return (
811+
<div>
812+
<p>Values: {values.join(',')}</p>
813+
<button
814+
type="button"
815+
onClick={() =>
816+
store.setState((prev) => ({ ...prev, one: prev.one + 1 }))
817+
}
818+
>
819+
Update one
820+
</button>
821+
<button
822+
type="button"
823+
onClick={() =>
824+
store.setState((prev) => ({ ...prev, two: prev.two + 1 }))
825+
}
826+
>
827+
Update two
828+
</button>
829+
</div>
830+
)
831+
}
832+
833+
const { getByText } = render(<Comp />)
834+
expect(getByText('Values: 1,2')).toBeInTheDocument()
835+
836+
await user.click(getByText('Update one'))
837+
838+
await waitFor(() => expect(getByText('Values: 2,2')).toBeInTheDocument())
839+
840+
await user.click(getByText('Update two'))
841+
842+
await waitFor(() => expect(getByText('Values: 2,3')).toBeInTheDocument())
843+
})
803844
})

packages/react-store/tests/index.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,4 +764,45 @@ describe('shallow', () => {
764764

765765
expect(shallow(objA, objB)).toBe(true)
766766
})
767+
768+
test('should handle arrays inside of a useSelector', async () => {
769+
const store = createStore({ one: 1, two: 2 })
770+
771+
function Comp() {
772+
const values = useSelector(store, (state) => [state.one, state.two])
773+
774+
return (
775+
<div>
776+
<p>Values: {values.join(',')}</p>
777+
<button
778+
type="button"
779+
onClick={() =>
780+
store.setState((prev) => ({ ...prev, one: prev.one + 1 }))
781+
}
782+
>
783+
Update one
784+
</button>
785+
<button
786+
type="button"
787+
onClick={() =>
788+
store.setState((prev) => ({ ...prev, two: prev.two + 1 }))
789+
}
790+
>
791+
Update two
792+
</button>
793+
</div>
794+
)
795+
}
796+
797+
const { getByText } = render(<Comp />)
798+
expect(getByText('Values: 1,2')).toBeInTheDocument()
799+
800+
await user.click(getByText('Update one'))
801+
802+
await waitFor(() => expect(getByText('Values: 2,2')).toBeInTheDocument())
803+
804+
await user.click(getByText('Update two'))
805+
806+
await waitFor(() => expect(getByText('Values: 2,3')).toBeInTheDocument())
807+
})
767808
})

0 commit comments

Comments
 (0)