@samitouri / QOS-React / commits / 3a717e4243

[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;