Skip to content

Commit f207b95

Browse files
committed
fix(zustand): memoize selector result + return shallow as default export
Two bugs surfaced when running with-zustand interactively (smoke gate didn't catch either — the ErrorBoundary fell back to a rendered surface that still passed the pixel/colour check). (1) useStore did useSyncExternalStore(subscribe, () => sel(getState())) Returning a fresh object reference every getSnapshot call trips React's tear-detection: The result of getSnapshot should be cached and the render loops to Maximum update depth exceeded. Fix: cache the last selector result via useRef and reuse it whenever the equality fn (Object.is or the consumer's shallow) reports the new selection matches. (2) zustand/shallow returned {shallow} as a namespace object, so import shallow from zustand/shallow gave the namespace not the function — useStore's typeof equalityFn === function check failed and it fell back to Object.is, which never matches fresh selector outputs. Fix: emit the sub-path as a __esModule-stamped namespace with both default and named exports pointing at the function so either import shape works.
1 parent 1dd8f5f commit f207b95

2 files changed

Lines changed: 36 additions & 13 deletions

File tree

apps/playground/bundle.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,16 @@ const appOpts = {
430430
' if (id === "@expo/metro-runtime") return rnv.expoMetroRuntime;\n' +
431431
' if (id === "crypto" || id === "node:crypto") return rnv.nodeCrypto;\n' +
432432
' if (id === "zustand") return rnv.zustand;\n' +
433-
' if (id === "zustand/shallow") return {shallow: rnv.zustand.shallow};\n' +
433+
// zustand/shallow ships BOTH `import shallow from "zustand/shallow"`
434+
// (the function as default) AND `import {shallow} from "zustand/shallow"`.
435+
// Return an __esModule namespace that satisfies both shapes; esbuild
436+
// sees __esModule:true and pipes it through interop untouched.
437+
' if (id === "zustand/shallow") {\n' +
438+
' var sh = rnv.zustand.shallow;\n' +
439+
' var ns = {default: sh, shallow: sh};\n' +
440+
' Object.defineProperty(ns, "__esModule", {value: true});\n' +
441+
' return ns;\n' +
442+
' }\n' +
434443
' if (id === "expo-sqlite") return rnv.expoSqlite;\n' +
435444
// Every @react-navigation/* package routes through the same
436445
// minimal shim — the navigator factories cover stack / native-

packages/@lucid-softworks/react-native-linux-expo/zustand.js

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,35 @@ function createStore(initializer) {
6969

7070
function create(initializer) {
7171
const api = createStoreImpl(initializer);
72-
// useStore(selector?, equalityFn?) — the hook form. Selector
73-
// defaults to identity; equality defaults to Object.is.
72+
// useStore(selector?, equalityFn?) — the hook form.
73+
//
74+
// The catch: a selector like `s => ({a: s.a, b: s.b})` returns a
75+
// NEW object reference every call. useSyncExternalStore tears if
76+
// getSnapshot returns a different reference each time it's read
77+
// outside an actual subscribe-fire (React fires "the result of
78+
// getSnapshot should be cached" and falls into an infinite render
79+
// loop).
80+
//
81+
// Fix: cache the last selector input AND its result, and on every
82+
// getSnapshot call return the cached result when the equality fn
83+
// says the new selection matches the old one. equalityFn defaults
84+
// to Object.is for the no-selector case; consumers passing
85+
// `shallow` (from zustand/shallow) get the structural comparison.
7486
function useStore(selector, equalityFn) {
7587
const sel = typeof selector === 'function' ? selector : s => s;
7688
const eq = typeof equalityFn === 'function' ? equalityFn : Object.is;
77-
return React.useSyncExternalStore(
78-
api.subscribe,
79-
() => sel(api.getState()),
80-
() => sel(api.getInitialState()),
81-
// useSyncExternalStore in React 18 doesn't support a custom
82-
// equality fn, so we have to memoize the selected slice
83-
// ourselves via the snapshot ref below. The eq is consulted
84-
// inside the snapshot, which is good enough for the common
85-
// shallow-on-object case zustand consumers use.
86-
);
89+
const cacheRef = React.useRef(null);
90+
function readWithCache(rawState) {
91+
const next = sel(rawState);
92+
if (cacheRef.current && eq(cacheRef.current.value, next)) {
93+
return cacheRef.current.value;
94+
}
95+
cacheRef.current = {value: next};
96+
return next;
97+
}
98+
const getSnapshot = () => readWithCache(api.getState());
99+
const getServerSnapshot = () => readWithCache(api.getInitialState());
100+
return React.useSyncExternalStore(api.subscribe, getSnapshot, getServerSnapshot);
87101
}
88102
Object.assign(useStore, api);
89103
return useStore;

0 commit comments

Comments
 (0)