[DOM] Fix Fragment compareDocumentPosition for documentElement and empty portals (#37163)
Accept documentElement in the CONTAINS fiber fallback when there is no React fiber, and position empty portaled fragments against the portal container instead of the React host parent.
Jack Pope committed
Aug 11, 2026 at 18:19 UTC
809280d595b589981758f5372986b66eb9150f6c
4 files changed
+128
-5
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+19
-4
@@ -70,6 +70,7 @@ import {
70
getFragmentInstanceOrTextInstanceSiblings,
71
traverseFragmentInstancesAndTextInstancesDeeply,
72
fiberIsPortaledIntoHost,
73
+ getFragmentPortalContainerInfo,
74
isFiberContainedByFragment,
75
isFragmentContainedByFiber,
76
} from 'react-reconciler/src/ReactFiberTreeReflection';
@@ -3420,9 +3421,20 @@ FragmentInstance.prototype.compareDocumentPosition = function (
3421
);
3422
3423
if (children.length === 0) {
3424
+ // Match non-empty CDP: when portaled, position against the portal
3425
+ // container rather than the React host parent.
3426
+ let emptyParentHostInstance = parentHostInstance;
3427
+ if (fiberIsPortaledIntoHost(this._fragmentFiber)) {
3428
+ const portalContainer = getFragmentPortalContainerInfo(
3429
+ this._fragmentFiber,
3430
+ );
3431
+ if (portalContainer != null) {
3432
+ emptyParentHostInstance = portalContainer;
3433
+ }
3434
+ }
3435
return compareDocumentPositionForEmptyFragment(
3436
this._fragmentFiber,
3425
- parentHostInstance,
3437
+ emptyParentHostInstance,
3438
otherNode,
3439
getInstanceFromHostFiber,
3440
);
@@ -3525,10 +3537,13 @@ function validateDocumentPositionWithFiberTree(
3537
}
3538
if (documentPosition & Node.DOCUMENT_POSITION_CONTAINS) {
3539
if (otherFiber === null) {
3528
- // otherFiber could be null if its the document or body element
3540
+ // otherFiber could be null if its the document, documentElement, or body
3541
const ownerDocument = otherNode.ownerDocument;
3530
- // $FlowFixMe[invalid-compare]
3531
- return otherNode === ownerDocument || otherNode === ownerDocument.body;
3542
+ return (
3543
+ (otherNode as Instance | Document) === ownerDocument ||
3544
+ otherNode === ownerDocument.documentElement ||
3545
+ otherNode === ownerDocument.body
3546
+ );
3547
}
3548
return isFragmentContainedByFiber(fragmentFiber, otherFiber);
3549
}
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+45
-1
@@ -2391,7 +2391,7 @@ describe('FragmentRefs', () => {
2391
expectPosition(
2392
fragmentRef.current.compareDocumentPosition(document.body),
2393
{
2394
- preceding: true,
2394
+ preceding: false,
2395
following: false,
2396
contains: true,
2397
containedBy: false,
@@ -2422,6 +2422,50 @@ describe('FragmentRefs', () => {
2422
},
2423
);
2424
});
2425
+
2426
+ // @gate enableFragmentRefs
2427
+ it('positions empty portaled fragments against the portal container', async () => {
2428
+ const fragmentRef = React.createRef();
2429
+ const reactParentRef = React.createRef();
2430
+ const portalTarget = document.createElement('div');
2431
+ portalTarget.id = 'portal-target';
2432
+ document.body.appendChild(portalTarget);
2433
+ const root = ReactDOMClient.createRoot(container);
2434
+
2435
+ function Test() {
2436
+ return (
2437
+ <div id="react-parent" ref={reactParentRef}>
2438
+ {createPortal(<Fragment ref={fragmentRef} />, portalTarget)}
2439
+ </div>
2440
+ );
2441
+ }
2442
+
2443
+ await act(() => root.render(<Test />));
2444
+
2445
+ // Empty CDP must use the portal container as parent
2446
+ expectPosition(
2447
+ fragmentRef.current.compareDocumentPosition(portalTarget),
2448
+ {
2449
+ preceding: false,
2450
+ following: false,
2451
+ contains: true,
2452
+ containedBy: false,
2453
+ disconnected: false,
2454
+ implementationSpecific: true,
2455
+ },
2456
+ );
2457
+ expectPosition(
2458
+ fragmentRef.current.compareDocumentPosition(reactParentRef.current),
2459
+ {
2460
+ preceding: true,
2461
+ following: false,
2462
+ contains: false,
2463
+ containedBy: false,
2464
+ disconnected: false,
2465
+ implementationSpecific: true,
2466
+ },
2467
+ );
2468
+ });
2469
});
2470
});
2471
packages/react-dom/src/__tests__/ReactDOMFragmentRefsDocument-test.js
+46
@@ -16,6 +16,7 @@ let ReactDOMClient;
16
let act;
17
let document;
18
let Fragment;
19
+let Node;
20
21
describe('FragmentRefs', () => {
22
beforeEach(() => {
@@ -28,10 +29,12 @@ describe('FragmentRefs', () => {
29
30
const jsdom = new JSDOM.JSDOM('');
31
document = jsdom.window.document;
32
+ Node = jsdom.window.Node;
33
global.window = jsdom.window;
34
global.document = global.window.document;
35
global.navigator = global.window.navigator;
36
global.Event = global.window.Event;
37
+ global.Node = Node;
38
});
39
40
describe('focus methods', () => {
@@ -252,4 +255,47 @@ describe('FragmentRefs', () => {
255
expect(fragmentRef.current.getClientRects()).toEqual(['html-rect']);
256
});
257
});
258
+
259
+ describe('compareDocumentPosition', () => {
260
+ function expectPosition(position, spec) {
261
+ const positionResult = {
262
+ following: (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
263
+ preceding: (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0,
264
+ contains: (position & Node.DOCUMENT_POSITION_CONTAINS) !== 0,
265
+ containedBy: (position & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0,
266
+ disconnected: (position & Node.DOCUMENT_POSITION_DISCONNECTED) !== 0,
267
+ implementationSpecific:
268
+ (position & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC) !== 0,
269
+ };
270
+ expect(positionResult).toEqual(spec);
271
+ }
272
+
273
+ // @gate enableFragmentRefs
274
+ it('treats documentElement as containing the fragment', async () => {
275
+ const fragmentRef = React.createRef();
276
+ const container = document.createElement('div');
277
+ document.body.appendChild(container);
278
+ const root = ReactDOMClient.createRoot(container);
279
+
280
+ await act(() => {
281
+ root.render(
282
+ <Fragment ref={fragmentRef}>
283
+ <div id="child" />
284
+ </Fragment>,
285
+ );
286
+ });
287
+
288
+ expectPosition(
289
+ fragmentRef.current.compareDocumentPosition(document.documentElement),
290
+ {
291
+ preceding: true,
292
+ following: false,
293
+ contains: true,
294
+ containedBy: false,
295
+ disconnected: false,
296
+ implementationSpecific: false,
297
+ },
298
+ );
299
+ });
300
+ });
301
});
packages/react-reconciler/src/ReactFiberTreeReflection.js
+18
@@ -459,6 +459,24 @@ export function fiberIsPortaledIntoHost(fiber: Fiber): boolean {
459
return foundPortalParent;
460
}
461
462
+export function getFragmentPortalContainerInfo(fiber: Fiber): null | Container {
463
+ let parent = fiber.return;
464
+ while (parent !== null) {
465
+ if (parent.tag === HostPortal) {
466
+ return parent.stateNode.containerInfo as Container;
467
+ }
468
+ if (
469
+ parent.tag === HostRoot ||
470
+ parent.tag === HostComponent ||
471
+ parent.tag === HostSingleton
472
+ ) {
473
+ break;
474
+ }
475
+ parent = parent.return;
476
+ }
477
+ return null;
478
+}
479
+
480
export function getFragmentInstanceOrTextInstanceSiblings(
481
fiber: Fiber,
482
): [Fiber | null, Fiber | null] {