You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When React Compiler is enabled, downstream pure functions that take a Legend-State observable's value as an argument can be silently memoized against an unchanged reference, returning stale results even though the observable was correctly updated. The bug is invisible from a console.log inside the React component (which still fires) but visible from a console.log inside the pure function (which never fires after the first call). The UI shows yesterday's data until the screen unmounts and remounts.
functionuseFavoritesList(){constfavorites=useSyncExternalStore((cb)=>favorites$.onChange(cb),()=>favorites$.get(),);// ↓ React Compiler memoizes this call by argument identityreturnjoinFavoritesWithContent(favorites??{},articles??{}, ...);}
Steps:
Render useFavoritesList with 5 entries. UI shows 5.
From another screen, call favorites$[oneId].delete(). The observable internally drops the key — but the root object reference is unchanged (Legend-State mutates in place; this is an intentional perf optimization).
The screen re-renders (the console.log at the top of useFavoritesList fires, showing Object.keys(favorites).length === 4).
ButjoinFavoritesWithContent is not called. A console.log at the top of that function never fires. React Compiler's cache returns the previous result (5 items).
UI shows 5 entries. Reloading the screen "fixes" it.
Root cause
Two intentional behaviors collide:
Legend-State intentionally mutates the root object in place to avoid copying large maps on every child set. peek() and get() return that same reference.
React Compiler auto-memoizes function calls by argument reference identity. With Object.is(prev, next) returning true, the cached call result is reused.
Neither side is buggy in isolation; the pair is.
Why useSyncExternalStore alone doesn't help
useSyncExternalStore has its own check that runs before any re-render: if getSnapshot() returns the same reference, the subscriber is not notified at all. So () => obs$.get() as the snapshot getter also fails on in-place mutations (separate React-level bug).
Workaround: maintain a version counter in the subscribe closure, return it from getSnapshot. That fixes the React-level notify, but Compiler-level memoization remains stale unless the observable's exposed value identity also changes.
Suggested fixes (in order of preference)
Document the pattern. Add a "React Compiler" section to the docs explaining that direct obs$.get() / peek() returns must not be passed to compiler-visible pure functions, and provide an official wrapper (see useComputed doesn't update on deps change #2).
Ship an official useObservableValue (or similar) hook that internally:
Subscribes via obs$.onChange
Maintains a version counter
Returns Object.assign({}, peek()) (or equivalent shallow clone) inside useMemo([obs$, version]) so each notified change yields a new identity
Make use$ work in mid-tree with observer()-wrapped ancestors. Today use$ branches on the global reactGlobals.inObserver flag and shifts hook order when the flag flips between renders — which forces some projects to bypass use$ entirely. Making use$ unconditional in its hook calls (so the hook count is stable regardless of inObserver) would let projects use the official API and inherit any future Compiler-aware fixes for free.
Trade-off: a shallow clone is allocated on every change. Negligible for record-shaped observables with up to a few hundred entries; would need a different strategy for very large maps.
Why this matters now
React Compiler is moving toward stable / default-on. As more apps adopt it, this trap will surface in many code bases and from the user's perspective it manifests as "delete doesn't work" or "data is wrong but reload fixes it," which is hard to root-cause without log-instrumenting the suspected pure function.
Happy to PR a docs note or a useObservableValue helper if there's interest.
Summary
When React Compiler is enabled, downstream pure functions that take a Legend-State observable's value as an argument can be silently memoized against an unchanged reference, returning stale results even though the observable was correctly updated. The bug is invisible from a
console.loginside the React component (which still fires) but visible from aconsole.loginside the pure function (which never fires after the first call). The UI shows yesterday's data until the screen unmounts and remounts.Versions
@legendapp/statev3 (/sync-plugins/supabase+/sync-plugins/crud)react-compiler-runtimereact-native0.81 / Expo SDK 53transform.reactCompiler=truein the Metro bundlerRepro
Setup: an observable backed by
syncedSupabase, mutated client-side via the toggle pattern.Consumer:
Steps:
useFavoritesListwith 5 entries. UI shows 5.favorites$[oneId].delete(). The observable internally drops the key — but the root object reference is unchanged (Legend-State mutates in place; this is an intentional perf optimization).console.logat the top ofuseFavoritesListfires, showingObject.keys(favorites).length === 4).joinFavoritesWithContentis not called. Aconsole.logat the top of that function never fires. React Compiler's cache returns the previous result (5 items).Root cause
Two intentional behaviors collide:
peek()andget()return that same reference.Object.is(prev, next)returningtrue, the cached call result is reused.Neither side is buggy in isolation; the pair is.
Why
useSyncExternalStorealone doesn't helpuseSyncExternalStorehas its own check that runs before any re-render: ifgetSnapshot()returns the same reference, the subscriber is not notified at all. So() => obs$.get()as the snapshot getter also fails on in-place mutations (separate React-level bug).Workaround: maintain a version counter in the subscribe closure, return it from
getSnapshot. That fixes the React-level notify, but Compiler-level memoization remains stale unless the observable's exposed value identity also changes.Suggested fixes (in order of preference)
Document the pattern. Add a "React Compiler" section to the docs explaining that direct
obs$.get()/peek()returns must not be passed to compiler-visible pure functions, and provide an official wrapper (see useComputed doesn't update on deps change #2).Ship an official
useObservableValue(or similar) hook that internally:obs$.onChangeObject.assign({}, peek())(or equivalent shallow clone) insideuseMemo([obs$, version])so each notified change yields a new identityMake
use$work in mid-tree withobserver()-wrapped ancestors. Todayuse$branches on the globalreactGlobals.inObserverflag and shifts hook order when the flag flips between renders — which forces some projects to bypassuse$entirely. Makinguse$unconditional in its hook calls (so the hook count is stable regardless ofinObserver) would let projects use the official API and inherit any future Compiler-aware fixes for free.Workaround we shipped
Trade-off: a shallow clone is allocated on every change. Negligible for record-shaped observables with up to a few hundred entries; would need a different strategy for very large maps.
Why this matters now
React Compiler is moving toward stable / default-on. As more apps adopt it, this trap will surface in many code bases and from the user's perspective it manifests as "delete doesn't work" or "data is wrong but reload fixes it," which is hard to root-cause without log-instrumenting the suspected pure function.
Happy to PR a docs note or a
useObservableValuehelper if there's interest.