@samitouri / QOS-React-2 / commits / 809280d595

[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] {