Skip to content

Commit 95ad182

Browse files
bowheartclaude
andauthored
fix(react): defer unmaterializedNodes cleanup during active Suspense (#360)
When a component inside a <Suspense> boundary creates a SelectorInstance during render and a sibling throws a promise, React discards the entire subtree — useEffect never runs, so the selector never gets an observer. A component above the boundary can then commit its useEffect, triggering unmaterializedNodes cleanup that destroys the orphaned selector. If that selector was the only observer of a ttl:0 atom, the atom is destroyed too, causing an infinite Suspense loop. Track active Suspense promises via a WeakSet and counter. Defer unmaterializedNodes cleanup while any Suspense promise is pending. When the promise settles, React re-renders the children, useEffect creates proper observers, and cleanup runs naturally — by which point selectors have observers and destroy() respects their ref count. Uses .then(fn, fn) instead of .finally() to avoid propagating rejections into unhandled promise rejections (ErrorBoundary handles those). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7164e92 commit 95ad182

File tree

2 files changed

+719
-1
lines changed

2 files changed

+719
-1
lines changed

packages/react/src/hooks/useAtomInstance.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ import { useReactComponentId } from './useReactComponentId'
2626
const unmaterializedNodes = new Set<ZeduxNode>()
2727
let isQueued = false
2828

29+
// Track active Suspense promises so we can defer unmaterializedNodes cleanup
30+
// while any Suspense is pending. Without this, a component above the Suspense
31+
// boundary can commit its useEffect and destroy orphaned selectors before
32+
// suspended components get a chance to materialize them.
33+
const trackedSuspensePromises = new WeakSet<Promise<any>>()
34+
let activeSuspenseCount = 0
35+
2936
/**
3037
* Creates an atom instance for the passed atom template based on the passed
3138
* params. If an instance has already been created for the passed params, reuses
@@ -180,6 +187,12 @@ export const useAtomInstance: {
180187
isQueued = true
181188
ecosystem.asyncScheduler.queue(() => {
182189
isQueued = false
190+
191+
// Defer cleanup while any Suspense is active — nodes from suspended
192+
// renders are not yet abandoned and may still materialize when the
193+
// promise settles.
194+
if (activeSuspenseCount > 0) return
195+
183196
unmaterializedNodes.forEach(node => node.destroy())
184197
unmaterializedNodes.clear()
185198
})
@@ -205,7 +218,24 @@ export const useAtomInstance: {
205218
const status = (instance as AtomInstance).promiseStatus
206219

207220
if (status === 'loading') {
208-
throw (instance as AtomInstance).promise
221+
const p = (instance as AtomInstance).promise!
222+
223+
if (!trackedSuspensePromises.has(p)) {
224+
trackedSuspensePromises.add(p)
225+
activeSuspenseCount++
226+
227+
// Use .then(fn, fn) instead of .finally() to avoid creating an
228+
// unhandled rejection when the promise rejects (which is expected —
229+
// ErrorBoundary handles it, not this tracking chain).
230+
const cleanup = () => {
231+
trackedSuspensePromises.delete(p)
232+
activeSuspenseCount--
233+
}
234+
235+
p.then(cleanup, cleanup)
236+
}
237+
238+
throw p
209239
} else if (status === 'error') {
210240
throw (instance as AtomInstance).promiseError
211241
}

0 commit comments

Comments
 (0)