[Fiber] Collect Host Singleton children of Fragments (#37063)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sebastian "Sebbie" Silbermann committed
Jul 31, 2026 at 15:20 UTC
3a717e42438afac81020cdec297dadb5613a4304
5 files changed
+279
-28
packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
+5
-1
@@ -491,7 +491,11 @@ function addTrappedEventListener(
491
492
targetContainer =
493
enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport
494
- ? (targetContainer as any).ownerDocument
494
+ ? // A Document container's ownerDocument is null, so it must be used
495
+ // as the deferral target itself.
496
+ (targetContainer as any).nodeType === DOCUMENT_NODE
497
+ ? targetContainer
498
+ : (targetContainer as any).ownerDocument
499
: targetContainer;
500
501
let unsubscribeListener;
packages/react-dom/src/__tests__/ReactDOMFragmentRefsDocument-test.js
+188
-1
@@ -30,6 +30,8 @@ describe('FragmentRefs', () => {
30
document = jsdom.window.document;
31
global.window = jsdom.window;
32
global.document = global.window.document;
33
+ global.navigator = global.window.navigator;
34
+ global.Event = global.window.Event;
35
});
36
37
describe('focus methods', () => {
@@ -54,7 +56,9 @@ describe('FragmentRefs', () => {
56
});
57
58
await act(() => {
57
- fragmentRef.current.focus();
59
+ // focus() would stop at <body>, which is a child of the fragment
60
+ // and usually already the activeElement.
61
+ document.getElementById('child-a').focus();
62
});
63
expect(document.activeElement.id).toEqual('child-a');
64
@@ -65,4 +69,187 @@ describe('FragmentRefs', () => {
69
});
70
});
71
});
72
+
73
+ describe('events', () => {
74
+ describe('dispatchEvent()', () => {
75
+ // @gate enableFragmentRefs
76
+ it('fires events when the fragment is a child of a HostSingleton in a document root', async () => {
77
+ const fragmentRef = React.createRef();
78
+ const bodyRef = React.createRef();
79
+ const root = ReactDOMClient.createRoot(document);
80
+
81
+ await act(() => {
82
+ root.render(
83
+ <html>
84
+ <body ref={bodyRef}>
85
+ <Fragment ref={fragmentRef} />
86
+ </body>
87
+ </html>,
88
+ );
89
+ });
90
+
91
+ const fragmentListener = jest.fn();
92
+ fragmentRef.current.addEventListener('custom', fragmentListener);
93
+ const bodyListener = jest.fn();
94
+ bodyRef.current.addEventListener('custom', bodyListener);
95
+
96
+ // The <body> is the fragment's host parent, so the
97
+ // temporary event target is appended there.
98
+ fragmentRef.current.dispatchEvent(new Event('custom', {bubbles: true}));
99
+
100
+ expect(fragmentListener).toHaveBeenCalledTimes(1);
101
+ expect(bodyListener).toHaveBeenCalledTimes(1);
102
+ });
103
+ });
104
+
105
+ describe('addEventListener()', () => {
106
+ // @gate enableFragmentRefs
107
+ it('attaches listeners to the host children inside singletons', async () => {
108
+ const fragmentRef = React.createRef();
109
+ const childRef = React.createRef();
110
+ const root = ReactDOMClient.createRoot(document);
111
+
112
+ await act(() => {
113
+ root.render(
114
+ <Fragment ref={fragmentRef}>
115
+ <html>
116
+ <body>
117
+ <div ref={childRef} id="child" />
118
+ </body>
119
+ </html>
120
+ </Fragment>,
121
+ );
122
+ });
123
+
124
+ const currentTargets = [];
125
+ fragmentRef.current.addEventListener('click', event => {
126
+ currentTargets.push(event.currentTarget);
127
+ });
128
+
129
+ childRef.current.dispatchEvent(new Event('click', {bubbles: true}));
130
+
131
+ // The <html> singleton is the fragment's child, so the listener is
132
+ // attached there and receives the bubbling event.
133
+ expect(currentTargets).toEqual([document.documentElement]);
134
+ });
135
+
136
+ // @gate enableFragmentRefs
137
+ it('attaches listeners to a singleton mounted into the fragment, but not to its content', async () => {
138
+ const fragmentRef = React.createRef();
139
+ const childRef = React.createRef();
140
+ const root = ReactDOMClient.createRoot(document);
141
+
142
+ function Test({showShell}) {
143
+ return (
144
+ <Fragment ref={fragmentRef}>
145
+ {showShell && (
146
+ <html>
147
+ <body>
148
+ <div ref={childRef} id="child" />
149
+ </body>
150
+ </html>
151
+ )}
152
+ </Fragment>
153
+ );
154
+ }
155
+
156
+ await act(() => {
157
+ root.render(<Test showShell={false} />);
158
+ });
159
+
160
+ const currentTargets = [];
161
+ fragmentRef.current.addEventListener('click', event => {
162
+ currentTargets.push(event.currentTarget);
163
+ });
164
+
165
+ await act(() => {
166
+ root.render(<Test showShell={true} />);
167
+ });
168
+
169
+ childRef.current.dispatchEvent(new Event('click', {bubbles: true}));
170
+
171
+ // The placed <html> singleton receives the fragment's listener as a
172
+ // new child. Its content is not attributed to the fragment, so the
173
+ // event only fires once when it bubbles to <html>.
174
+ expect(currentTargets).toEqual([document.documentElement]);
175
+ });
176
+
177
+ // @gate enableFragmentRefs
178
+ it('attributes new children inside a singleton to fragments below it, not above it', async () => {
179
+ const outerFragmentRef = React.createRef();
180
+ const innerFragmentRef = React.createRef();
181
+ const lateChildRef = React.createRef();
182
+ const root = ReactDOMClient.createRoot(document);
183
+
184
+ function Test({showLateChild}) {
185
+ return (
186
+ <Fragment ref={outerFragmentRef}>
187
+ <html>
188
+ <body>
189
+ <Fragment ref={innerFragmentRef}>
190
+ <div id="child" />
191
+ {showLateChild && <span ref={lateChildRef} id="late" />}
192
+ </Fragment>
193
+ </body>
194
+ </html>
195
+ </Fragment>
196
+ );
197
+ }
198
+
199
+ await act(() => {
200
+ root.render(<Test showLateChild={false} />);
201
+ });
202
+
203
+ const outerCurrentTargets = [];
204
+ outerFragmentRef.current.addEventListener('click', event => {
205
+ outerCurrentTargets.push(event.currentTarget);
206
+ });
207
+ const innerCurrentTargets = [];
208
+ innerFragmentRef.current.addEventListener('click', event => {
209
+ innerCurrentTargets.push(event.currentTarget);
210
+ });
211
+
212
+ await act(() => {
213
+ root.render(<Test showLateChild={true} />);
214
+ });
215
+
216
+ lateChildRef.current.dispatchEvent(new Event('click', {bubbles: true}));
217
+
218
+ // The inner fragment owns the new child directly and attaches its
219
+ // listener on insertion. The outer fragment's child is the <html>
220
+ // singleton, so the new child inside <body> is not attributed to it
221
+ // and its listener only fires once via bubbling.
222
+ expect(innerCurrentTargets).toEqual([lateChildRef.current]);
223
+ expect(outerCurrentTargets).toEqual([document.documentElement]);
224
+ });
225
+ });
226
+ });
227
+
228
+ describe('getClientRects()', () => {
229
+ // @gate enableFragmentRefs
230
+ it('measures the host children inside singletons', async () => {
231
+ const fragmentRef = React.createRef();
232
+ const childRef = React.createRef();
233
+ const root = ReactDOMClient.createRoot(document);
234
+
235
+ await act(() => {
236
+ root.render(
237
+ <Fragment ref={fragmentRef}>
238
+ <html>
239
+ <body>
240
+ <div ref={childRef} id="child" />
241
+ </body>
242
+ </html>
243
+ </Fragment>,
244
+ );
245
+ });
246
+
247
+ childRef.current.getClientRects = jest.fn(() => ['child-rect']);
248
+ document.documentElement.getClientRects = jest.fn(() => ['html-rect']);
249
+
250
+ // The <html> singleton is the fragment's child, so it is measured
251
+ // instead of the elements inside it
252
+ expect(fragmentRef.current.getClientRects()).toEqual(['html-rect']);
253
+ });
254
+ });
255
});
packages/react-reconciler/src/ReactFiberCommitHostEffects.js
+59
-21
@@ -262,6 +262,7 @@ export function commitNewChildToFragmentInstances(
262
): void {
263
if (
264
(fiber.tag !== HostComponent &&
265
+ fiber.tag !== HostSingleton &&
266
!(enableFragmentRefsTextNodes && fiber.tag === HostText)) ||
267
// Only run fragment insertion effects for initial insertions
268
fiber.alternate !== null ||
@@ -283,7 +284,7 @@ export function commitFragmentInstanceInsertionEffects(fiber: Fiber): void {
284
commitNewChildToFragmentInstance(fiber.stateNode, fragmentInstance);
285
}
286
286
- if (isHostParent(parent)) {
287
+ if (isFragmentInstanceHostParent(parent)) {
288
return;
289
}
290
@@ -299,7 +300,7 @@ export function commitFragmentInstanceDeletionEffects(fiber: Fiber): void {
300
deleteChildFromFragmentInstance(fiber.stateNode, fragmentInstance);
301
}
302
302
- if (isHostParent(parent)) {
303
+ if (isFragmentInstanceHostParent(parent)) {
304
return;
305
}
306
@@ -325,6 +326,19 @@ function isFragmentInstanceParent(fiber: Fiber): boolean {
326
return fiber && fiber.tag === Fragment && fiber.stateNode !== null;
327
}
328
329
+// Fragments collect HostSingleton children regardless of whether the
330
+// singleton is a scope for placement, so their host parent boundary is
331
+// wider than `isHostParent`.
332
+function isFragmentInstanceHostParent(fiber: Fiber): boolean {
333
+ return (
334
+ fiber.tag === HostComponent ||
335
+ // $FlowFixMe[constant-condition]
336
+ (supportsSingletons ? fiber.tag === HostSingleton : false) ||
337
+ fiber.tag === HostRoot ||
338
+ fiber.tag === HostPortal
339
+ );
340
+}
341
+
342
function getHostSibling(fiber: Fiber): ?Instance {
343
// We're going to search forward into the tree until we find a sibling host
344
// node. Unfortunately, if multiple insertions are done in a row we have to
@@ -411,15 +425,20 @@ function insertOrAppendPlacementNodeIntoContainer(
425
return;
426
}
427
414
- if (
415
- // $FlowFixMe[constant-condition]
416
- (supportsSingletons ? tag === HostSingleton : false) &&
417
- isSingletonScope(node.type)
418
- ) {
419
- // This singleton is the parent of deeper nodes and needs to become
420
- // the parent for child insertions and appends
421
- parent = node.stateNode;
422
- before = null;
428
+ // $FlowFixMe[constant-condition]
429
+ if (supportsSingletons ? tag === HostSingleton : false) {
430
+ if (enableFragmentRefs) {
431
+ // The singleton is the fragment child. Its own children are not
432
+ // attributed to the fragment instances above it.
433
+ commitNewChildToFragmentInstances(node, parentFragmentInstances);
434
+ parentFragmentInstances = null;
435
+ }
436
+ if (isSingletonScope(node.type)) {
437
+ // This singleton is the parent of deeper nodes and needs to become
438
+ // the parent for child insertions and appends
439
+ parent = node.stateNode;
440
+ before = null;
441
+ }
442
}
443
444
const child = node.child;
@@ -470,14 +489,19 @@ function insertOrAppendPlacementNode(
489
return;
490
}
491
473
- if (
474
- // $FlowFixMe[constant-condition]
475
- (supportsSingletons ? tag === HostSingleton : false) &&
476
- isSingletonScope(node.type)
477
- ) {
478
- // This singleton is the parent of deeper nodes and needs to become
479
- // the parent for child insertions and appends
480
- parent = node.stateNode;
492
+ // $FlowFixMe[constant-condition]
493
+ if (supportsSingletons ? tag === HostSingleton : false) {
494
+ if (enableFragmentRefs) {
495
+ // The singleton is the fragment child. Its own children are not
496
+ // attributed to the fragment instances above it.
497
+ commitNewChildToFragmentInstances(node, parentFragmentInstances);
498
+ parentFragmentInstances = null;
499
+ }
500
+ if (isSingletonScope(node.type)) {
501
+ // This singleton is the parent of deeper nodes and needs to become
502
+ // the parent for child insertions and appends
503
+ parent = node.stateNode;
504
+ }
505
}
506
507
const child = node.child;
@@ -500,9 +524,10 @@ function commitPlacement(finishedWork: Fiber): void {
524
// Recursively insert all host nodes into the parent.
525
let hostParentFiber;
526
let parentFragmentInstances = null;
527
+ let collectFragmentInstances = enableFragmentRefs;
528
let parentFiber = finishedWork.return;
529
while (parentFiber !== null) {
505
- if (enableFragmentRefs && isFragmentInstanceParent(parentFiber)) {
530
+ if (collectFragmentInstances && isFragmentInstanceParent(parentFiber)) {
531
const fragmentInstance: FragmentInstanceType = parentFiber.stateNode;
532
if (parentFragmentInstances === null) {
533
parentFragmentInstances = [fragmentInstance];
@@ -510,6 +535,14 @@ function commitPlacement(finishedWork: Fiber): void {
535
parentFragmentInstances.push(fragmentInstance);
536
}
537
}
538
+ if (collectFragmentInstances && isFragmentInstanceHostParent(parentFiber)) {
539
+ // Fragments collect children only down to the nearest host fiber.
540
+ // The search for the placement parent can continue past host fibers
541
+ // that are not valid placement parents, like HostSingletons outside
542
+ // a singleton scope, but fragments above them own that host fiber
543
+ // as a child, not the placed node.
544
+ collectFragmentInstances = false;
545
+ }
546
if (isHostParent(parentFiber)) {
547
hostParentFiber = parentFiber;
548
break;
@@ -600,8 +633,13 @@ function commitImmutablePlacementNodeToFragmentInstances(
633
if (!enableFragmentRefs) {
634
return;
635
}
603
- const isHost = finishedWork.tag === HostComponent;
636
+ const isHost =
637
+ finishedWork.tag === HostComponent ||
638
+ // $FlowFixMe[constant-condition]
639
+ (supportsSingletons ? finishedWork.tag === HostSingleton : false);
640
if (isHost) {
641
+ // A singleton is the fragment child itself, so its own children are
642
+ // not attributed to the fragment instances above it.
643
commitNewChildToFragmentInstances(finishedWork, parentFragmentInstances);
644
return;
645
} else if (finishedWork.tag === HostPortal) {
packages/react-reconciler/src/ReactFiberCommitWork.js
+9
-1
@@ -1527,6 +1527,9 @@ function commitDeletionEffectsOnFiber(
1527
if (!offscreenSubtreeWasHidden) {
1528
safelyDetachRef(deletedFiber, nearestMountedAncestor);
1529
}
1530
+ if (enableFragmentRefs) {
1531
+ commitFragmentInstanceDeletionEffects(deletedFiber);
1532
+ }
1533
1534
const prevHostParent = hostParent;
1535
const prevHostParentIsContainer = hostParentIsContainer;
@@ -3102,6 +3105,7 @@ function disappearLayoutEffects(
3105
if (
3106
enableFragmentRefs &&
3107
(finishedWork.tag === HostComponent ||
3108
+ finishedWork.tag === HostSingleton ||
3109
(enableFragmentRefsTextNodes && finishedWork.tag === HostText))
3110
) {
3111
commitFragmentInstanceDeletionEffects(finishedWork);
@@ -3287,7 +3291,11 @@ function reappearLayoutEffects(
3291
case HostHoistable:
3292
case HostComponent: {
3293
// TODO: Enable HostText for RN
3290
- if (enableFragmentRefs && finishedWork.tag === HostComponent) {
3294
+ if (
3295
+ enableFragmentRefs &&
3296
+ (finishedWork.tag === HostComponent ||
3297
+ finishedWork.tag === HostSingleton)
3298
+ ) {
3299
commitFragmentInstanceInsertionEffects(finishedWork);
3300
}
3301
recursivelyTraverseReappearLayoutEffects(
packages/react-reconciler/src/ReactFiberTreeReflection.js
+18
-4
@@ -392,6 +392,7 @@ function traverseVisibleInstancesAndTextInstances<A, B, C>(
392
while (child !== null) {
393
const isHostNode =
394
child.tag === HostComponent ||
395
+ child.tag === HostSingleton ||
396
(enableFragmentRefsTextNodes && child.tag === HostText);
397
if (isHostNode && fn(child, a, b, c)) {
398
return true;
@@ -402,7 +403,8 @@ function traverseVisibleInstancesAndTextInstances<A, B, C>(
403
// Skip hidden subtrees
404
} else {
405
if (
405
- (searchWithinHosts || child.tag !== HostComponent) &&
406
+ (searchWithinHosts ||
407
+ (child.tag !== HostComponent && child.tag !== HostSingleton)) &&
408
traverseVisibleInstancesAndTextInstances(
409
child.child,
410
searchWithinHosts,
@@ -425,7 +427,11 @@ export function getFragmentParentInstanceOrContainerFiber(
427
): null | Fiber {
428
let parent = fiber.return;
429
while (parent !== null) {
428
- if (parent.tag === HostRoot || parent.tag === HostComponent) {
430
+ if (
431
+ parent.tag === HostRoot ||
432
+ parent.tag === HostComponent ||
433
+ parent.tag === HostSingleton
434
+ ) {
435
return parent;
436
}
437
parent = parent.return;
@@ -441,7 +447,11 @@ export function fiberIsPortaledIntoHost(fiber: Fiber): boolean {
447
if (parent.tag === HostPortal) {
448
foundPortalParent = true;
449
}
444
- if (parent.tag === HostRoot || parent.tag === HostComponent) {
450
+ if (
451
+ parent.tag === HostRoot ||
452
+ parent.tag === HostComponent ||
453
+ parent.tag === HostSingleton
454
+ ) {
455
break;
456
}
457
parent = parent.return;
@@ -486,6 +496,7 @@ function findFragmentInstanceOrTextInstanceSiblings(
496
}
497
if (
498
child.tag === HostComponent ||
499
+ child.tag === HostSingleton ||
500
(enableFragmentRefsTextNodes && child.tag === HostText)
501
) {
502
if (foundSelf) {
@@ -521,6 +532,7 @@ export function getInstanceFromHostFiber<
532
>(fiber: Fiber): I {
533
switch (fiber.tag) {
534
case HostComponent:
535
+ case HostSingleton:
536
case HostText:
537
return fiber.stateNode;
538
case HostRoot:
@@ -589,7 +601,9 @@ export function isFragmentContainedByFiber(
601
getFragmentParentInstanceOrContainerFiber(fragmentFiber);
602
while (current !== null) {
603
if (
592
- (current.tag === HostComponent || current.tag === HostRoot) &&
604
+ (current.tag === HostComponent ||
605
+ current.tag === HostRoot ||
606
+ current.tag === HostSingleton) &&
607
(current === fiberHostParent || current.alternate === fiberHostParent)
608
) {
609
return true;