Skip to content

Commit 5d4e515

Browse files
authored
fix(react): defer ExternalNode creation until useEffect runs (#321)
* fix(react): defer ExternalNode creation until useEffect runs * update snapshots
1 parent 2bcedb6 commit 5d4e515

File tree

3 files changed

+129
-33
lines changed

3 files changed

+129
-33
lines changed

packages/react/src/hooks/useAtomInstance.ts

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ParamsOf,
1010
Selectable,
1111
SelectorInstance,
12+
ZeduxNode,
1213
zi,
1314
} from '@zedux/atoms'
1415
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
@@ -22,6 +23,9 @@ import {
2223
import { useEcosystem } from './useEcosystem'
2324
import { useReactComponentId } from './useReactComponentId'
2425

26+
const unmaterializedNodes = new Set<ZeduxNode>()
27+
let isQueued = false
28+
2529
/**
2630
* Creates an atom instance for the passed atom template based on the passed
2731
* params. If an instance has already been created for the passed params, reuses
@@ -116,31 +120,13 @@ export const useAtomInstance: {
116120
const renderedValue = instance.v
117121
const isSelector = is(instance, SelectorInstance)
118122

119-
if (isSelector) render.i = instance as SelectorInstance
120-
121-
let node =
122-
(ecosystem.n.get(observerId) as ExternalNode) ??
123-
new ExternalNode(ecosystem, observerId, render)
124-
125-
const addEdge = () => {
126-
node.l === zi.D && (node = new ExternalNode(ecosystem, observerId, render))
127-
128-
// cancel edge cleanup if the below effect cleanup ran and scheduled it but
129-
// the component rerendered or the effect ran again before it happened
130-
node.c?.()
131-
132-
if (node.i !== instance) {
133-
node.u(
134-
instance,
135-
operation,
136-
External | (subscribe ? Eventless : EventlessStatic)
137-
)
123+
if (isSelector) {
124+
if (!render.i && !instance.o.size) {
125+
unmaterializedNodes.add(instance)
138126
}
139-
}
140127

141-
// Yes, subscribe during render. This operation is idempotent and we handle
142-
// React's StrictMode specifically.
143-
addEdge()
128+
render.i = instance as SelectorInstance
129+
}
144130

145131
// Only remove the graph edge when the instance id changes or on component
146132
// destruction.
@@ -153,11 +139,41 @@ export const useAtomInstance: {
153139

154140
if (isSelector) render.i = instance as SelectorInstance
155141

142+
let node =
143+
(ecosystem.n.get(observerId) as ExternalNode) ??
144+
new ExternalNode(ecosystem, observerId, render)
145+
146+
const addEdge = () => {
147+
node.l === zi.D &&
148+
(node = new ExternalNode(ecosystem, observerId, render))
149+
150+
// cancel edge cleanup if the below effect cleanup ran and scheduled it but
151+
// the component rerendered or the effect ran again before it happened
152+
node.c?.()
153+
154+
if (node.i !== instance) {
155+
node.u(
156+
instance,
157+
operation,
158+
External | (subscribe ? Eventless : EventlessStatic)
159+
)
160+
}
161+
}
162+
156163
// Try adding the edge again (will be a no-op unless React's StrictMode ran
157164
// this effect's cleanup unnecessarily)
158165
addEdge()
159166
render.m = true
160167

168+
if (!isQueued) {
169+
isQueued = true
170+
ecosystem.asyncScheduler.queue(() => {
171+
isQueued = false
172+
unmaterializedNodes.forEach(node => node.destroy())
173+
unmaterializedNodes.clear()
174+
})
175+
}
176+
161177
// an unmounting component's effect cleanup can update or force-destroy the
162178
// atom instance before this component is mounted. If that happened, trigger
163179
// a rerender to recreate the atom instance and/or get its new state
@@ -172,7 +188,7 @@ export const useAtomInstance: {
172188
node.k(instance, true)
173189
// don't set `render.m = false` here
174190
}
175-
}, [instance.id])
191+
}, [instance])
176192

177193
if (suspend !== false) {
178194
const status = (instance as AtomInstance).promiseStatus

packages/react/test/integrations/suspense.test.tsx

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import {
33
atom,
44
injectAtomValue,
55
injectPromise,
6+
injectSignal,
67
StateOf,
78
useAtomInstance,
89
useAtomState,
910
useAtomValue,
1011
} from '@zedux/react'
11-
import React, { Suspense } from 'react'
12+
import React, { Suspense, useState } from 'react'
1213
import { ErrorBoundary } from '../utils/ErrorBoundary'
1314
import { renderInEcosystem } from '../utils/renderInEcosystem'
1415
import { mockConsole } from '../utils/console'
@@ -330,4 +331,83 @@ describe('suspense', () => {
330331

331332
expect(calls).toEqual([undefined, undefined, 1, 1])
332333
})
334+
335+
test('suspending component does not create extra ExternalNodes or register extra observers', async () => {
336+
const testSuspenseAtom = atom(
337+
'testSuspenseAtom',
338+
() => {
339+
const signal = injectSignal({ loaded: false })
340+
341+
const { promise } = injectPromise(async () => {
342+
await new Promise(resolve => setTimeout(resolve, 500))
343+
signal.set({ loaded: true })
344+
}, [])
345+
346+
return api(signal).setPromise(promise)
347+
},
348+
{ ttl: 0 }
349+
)
350+
351+
const TestComponent: React.FC = () => {
352+
const instance = useAtomInstance(testSuspenseAtom)
353+
354+
return (
355+
<div data-testid="mounted">
356+
<p>Atom ID: {instance.id}</p>
357+
</div>
358+
)
359+
}
360+
361+
const TestWrapper: React.FC = () => {
362+
const [showComponent, setShowComponent] = useState(false)
363+
364+
return (
365+
<>
366+
<button
367+
data-testid="toggle"
368+
onClick={() => setShowComponent(prev => !prev)}
369+
>
370+
{showComponent ? 'Hide Component' : 'Show Component'}
371+
</button>
372+
373+
{showComponent && (
374+
<Suspense fallback={<div data-testid="loading">Loading...</div>}>
375+
<TestComponent />
376+
</Suspense>
377+
)}
378+
</>
379+
)
380+
}
381+
382+
const { findByTestId, queryByTestId, getByTestId } = renderInEcosystem(
383+
<TestWrapper />
384+
)
385+
386+
expect(ecosystem.find(testSuspenseAtom)).toBeUndefined()
387+
388+
act(() => {
389+
getByTestId('toggle').click()
390+
})
391+
392+
await findByTestId('loading')
393+
await findByTestId('mounted')
394+
395+
expect(ecosystem.find(testSuspenseAtom)?.o.size).toBe(1)
396+
397+
act(() => {
398+
getByTestId('toggle').click()
399+
})
400+
401+
expect(queryByTestId('mounted')).toBeNull()
402+
403+
// atom should be destroyed when component is unmounted
404+
await act(async () => {
405+
await Promise.resolve()
406+
})
407+
408+
expect(ecosystem.find(testSuspenseAtom)).toBeUndefined()
409+
})
410+
411+
// TODO: Would be nice if we could support this. It's really out of React's realm of practicality:
412+
// test('floating suspense nodes are cleaned up when the component finally mounts', () => {})
333413
})

packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ exports[`useAtomSelector inline selector that returns a different object referen
115115
"1": {
116116
"className": "AtomInstance",
117117
"observers": {
118-
"@selector(unknown)-1": {
118+
"@selector(unknown)-2": {
119119
"flags": 1,
120120
"operation": "get",
121121
"p": undefined,
@@ -132,7 +132,7 @@ exports[`useAtomSelector inline selector that returns a different object referen
132132
"className": "ExternalNode",
133133
"observers": {},
134134
"sources": {
135-
"@selector(unknown)-1": {
135+
"@selector(unknown)-2": {
136136
"flags": 5,
137137
"operation": "useAtomValue",
138138
},
@@ -141,7 +141,7 @@ exports[`useAtomSelector inline selector that returns a different object referen
141141
"status": "Active",
142142
"weight": 3,
143143
},
144-
"@selector(unknown)-1": {
144+
"@selector(unknown)-2": {
145145
"className": "SelectorInstance",
146146
"observers": {
147147
"@component(Test)-:rb:": {
@@ -170,7 +170,7 @@ exports[`useAtomSelector inline selector that returns a different object referen
170170
"1": {
171171
"className": "AtomInstance",
172172
"observers": {
173-
"@selector(unknown)-1": {
173+
"@selector(unknown)-2": {
174174
"flags": 1,
175175
"operation": "get",
176176
"p": undefined,
@@ -187,7 +187,7 @@ exports[`useAtomSelector inline selector that returns a different object referen
187187
"className": "ExternalNode",
188188
"observers": {},
189189
"sources": {
190-
"@selector(unknown)-1": {
190+
"@selector(unknown)-2": {
191191
"flags": 5,
192192
"operation": "useAtomValue",
193193
},
@@ -196,7 +196,7 @@ exports[`useAtomSelector inline selector that returns a different object referen
196196
"status": "Active",
197197
"weight": 3,
198198
},
199-
"@selector(unknown)-1": {
199+
"@selector(unknown)-2": {
200200
"className": "SelectorInstance",
201201
"observers": {
202202
"@component(Test)-:rb:": {
@@ -222,7 +222,7 @@ exports[`useAtomSelector inline selector that returns a different object referen
222222

223223
exports[`useAtomSelector inline selectors are swapped out in strict mode double-renders 1`] = `
224224
{
225-
"@selector(unknown)-1": {
225+
"@selector(unknown)-2": {
226226
"className": "SelectorInstance",
227227
"observers": {
228228
"@component(Test)-:r9:": {
@@ -246,7 +246,7 @@ exports[`useAtomSelector inline selectors are swapped out in strict mode double-
246246

247247
exports[`useAtomSelector inline selectors are swapped out in strict mode double-renders 2`] = `
248248
{
249-
"@selector(unknown)-1": {
249+
"@selector(unknown)-2": {
250250
"className": "SelectorInstance",
251251
"observers": {
252252
"@component(Test)-:r9:": {

0 commit comments

Comments
 (0)