Skip to content

Commit 131361c

Browse files
fix: handle ref cleanup in React 19 (#5)
1 parent 32d7ec9 commit 131361c

File tree

2 files changed

+25
-9
lines changed

2 files changed

+25
-9
lines changed

src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface Fiber<P = any, I = any, R = any> extends VNode<P> {
3131
container: FiberNode<R>
3232
props: P & { children: ComponentChildren }
3333
memoizedProps?: P & { children: ComponentChildren }
34+
refCleanup: null | (() => void)
3435
sibling: Fiber | null
3536
flags: number
3637
}
@@ -168,9 +169,14 @@ class FiberNode extends HTMLElement {
168169
},
169170
set(value) {
170171
ref = (self) => {
171-
const publicInstance = self === null ? null : HostConfig.getPublicInstance(fiber.stateNode)
172-
if (value && 'current' in value) value.current = publicInstance
173-
else value?.(publicInstance)
172+
const isMounted = self != null
173+
const publicInstance = isMounted ? HostConfig.getPublicInstance(fiber.stateNode) : null
174+
if (value && 'current' in value) {
175+
value.current = publicInstance
176+
} else {
177+
if (isMounted) fiber.refCleanup = value?.(publicInstance)
178+
else fiber.refCleanup?.()
179+
}
174180
}
175181
},
176182
})

tests/index.test.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ for (const label of ['react', 'preact']) {
175175
textInstance: ReconcilerNode
176176
suspenseInstance: ReconcilerNode
177177
hydratableInstance: never
178-
publicInstance: null
178+
publicInstance: {}
179179
formInstance: never
180180
hostContext: {}
181181
childSet: never
@@ -198,6 +198,7 @@ for (const label of ['react', 'preact']) {
198198
}
199199

200200
const NO_CONTEXT: HostConfig['hostContext'] = {}
201+
const PUBLIC_INSTANCE: HostConfig['publicInstance'] = {}
201202

202203
let currentUpdatePriority: number = NoEventPriority
203204

@@ -238,7 +239,7 @@ for (const label of ['react', 'preact']) {
238239
parent.children.splice(parent.children.indexOf(beforeChild), 0, child),
239240
removeChild: (parent, child) => parent.children.splice(parent.children.indexOf(child), 1),
240241
removeChildFromContainer: (container) => (container.head = null),
241-
getPublicInstance: () => null,
242+
getPublicInstance: () => PUBLIC_INSTANCE,
242243
getRootHostContext: () => NO_CONTEXT,
243244
getChildHostContext: () => NO_CONTEXT,
244245
shouldSetTextContent: () => false,
@@ -335,17 +336,19 @@ for (const label of ['react', 'preact']) {
335336
it('should go through lifecycle', async () => {
336337
const lifecycle: string[] = []
337338

339+
const refCallback = vi.fn()
340+
const refCleanup = vi.fn()
341+
338342
function Test() {
339343
React.useState(() => lifecycle.push('useState'))
340344
const ref = React.useRef<any>(null)
341345
ref.current ??= lifecycle.push('render')
342346
React.useImperativeHandle(ref, () => void lifecycle.push('ref'))
343347
React.useLayoutEffect(() => void lifecycle.push('useLayoutEffect'), [])
344348
React.useEffect(() => void lifecycle.push('useEffect'), [])
345-
return null
349+
return <element ref={(self) => (refCallback(self), refCleanup)} />
346350
}
347-
let container!: HostContainer
348-
await act(async () => void (container = render(<Test />)))
351+
await act(async () => void render(<Test />))
349352

350353
expect(lifecycle).toStrictEqual([
351354
'useState',
@@ -355,7 +358,14 @@ for (const label of ['react', 'preact']) {
355358
'useLayoutEffect',
356359
'useEffect',
357360
])
358-
expect(container.head).toBe(null)
361+
expect(refCallback).toHaveBeenCalledWith(PUBLIC_INSTANCE)
362+
expect(refCleanup).not.toHaveBeenCalled()
363+
364+
refCallback.mockClear()
365+
366+
await act(async () => void render(null))
367+
expect(refCallback).not.toHaveBeenCalled()
368+
expect(refCleanup).toHaveBeenCalled()
359369
})
360370

361371
it('should render JSX', async () => {

0 commit comments

Comments
 (0)