@samitouri / QOS-React-2 / commits / 95671b4eb3

Mark the root as animating if any Portal mutates or resizes (#32772)

Portals and `<ViewTransition>` are tricky because they leave the React tree. You might think of a Portal's container conceptually as also being part of a React tree but that's not quite how they're modeled today. They're more like their own roots. So instead, of trying to find a conceptual place in the React tree we treat Portals as their own root. We have two ways of tracking whether an update to a ViewTransition boundary has occurred. Either a DOM mutation has happened within it, or a resize of a child has caused it to potentially relayout its parent. Normally that just follows the tree structure of React, but not when it's a Portal. When it's a Portal we don't know which DOM parent it might have affected. For all we know it's at the root (and in fact, in most cases that's where Portals go). With this PR we mark the root as having been affected by a mutation or resize. This means that the whole document will animate and we can't optimize away from it. This ensures that a mutation to the root of a Portal doesn't go unanimated with other things are animating such as its parent. You can regain this optimization by adding a `<ViewTransition>` boundary directly inside the Portal itself so it owns its own animation. If that DOM node is also absolutely positioned it doesn't leak. Conversely this also means that a mutation inside a Portal doesn't affect its React parent so it won't trigger its parent's animation if this was the only thing animating. That could be unfortunate if this container is actually inside the same React parent. However, because this would have been an update we would've marked it for "maybe animating" and updates can't only get their animations cancelled if the root is cancelled, in practice this will actually animate anyway.

Sebastian Markbåge committed Mar 31, 2025 at 15:13 UTC 95671b4eb3ceb51278a2ba959667da04f0b09809
3 files changed +54 -1
fixtures/view-transition/src/components/Page.css
+7
@@ -20,4 +20,11 @@
20 border: 0px;
21 border-radius: 5px;
22 padding: 10px;
23 +}
24 +
25 +.portal {
26 + position: fixed;
27 + top: 10px;
28 + left: 360px;
29 + border: 1px solid #ccc;
30 }
\ No newline at end of file
fixtures/view-transition/src/components/Page.js
+20
@@ -6,7 +6,9 @@ import React, {
6 useEffect,
7 useState,
8 useId,
9 + startTransition,
10 } from 'react';
11 +import {createPortal} from 'react-dom';
12
13 import SwipeRecognizer from './SwipeRecognizer';
14
@@ -79,6 +81,23 @@ export default function Page({url, navigate}) {
81 // });
82 }, [show]);
83
84 + const [showModal, setShowModal] = useState(false);
85 + const portal = showModal ? (
86 + createPortal(
87 + <div className="portal">
88 + Portal: {!show ? 'A' : 'B'}
89 + <ViewTransition>
90 + <div>{!show ? 'A' : 'B'}</div>
91 + </ViewTransition>
92 + </div>,
93 + document.body
94 + )
95 + ) : (
96 + <button onClick={() => startTransition(() => setShowModal(true))}>
97 + Show Modal
98 + </button>
99 + );
100 +
101 const exclamation = (
102 <ViewTransition name="exclamation" onShare={onTransition}>
103 <span>!</span>
@@ -153,6 +172,7 @@ export default function Page({url, navigate}) {
172 <p>content</p>
173 <p>out</p>
174 <p>of</p>
175 + {portal}
176 <p>the</p>
177 <p>viewport</p>
178 {show ? <Component /> : null}
packages/react-reconciler/src/ReactFiberCommitWork.js
+27 -1
@@ -283,6 +283,7 @@ export let shouldFireAfterActiveInstanceBlur: boolean = false;
283 // Used during the commit phase to track whether a parent ViewTransition component
284 // might have been affected by any mutations / relayouts below.
285 let viewTransitionContextChanged: boolean = false;
286 +let rootViewTransitionAffected: boolean = false;
287
288 export function commitBeforeMutationEffects(
289 root: FiberRoot,
@@ -1750,6 +1751,8 @@ export function commitMutationEffects(
1751 inProgressLanes = committedLanes;
1752 inProgressRoot = root;
1753
1754 + rootViewTransitionAffected = false;
1755 +
1756 resetComponentEffectTimers();
1757
1758 commitMutationEffectsOnFiber(finishedWork, root, committedLanes);
@@ -2068,6 +2071,7 @@ function commitMutationEffectsOnFiber(
2071 break;
2072 }
2073 case HostPortal: {
2074 + const prevMutationContext = pushMutationContext();
2075 if (supportsResources) {
2076 const previousHoistableRoot = currentHoistableRoot;
2077 currentHoistableRoot = getHoistableRoot(
@@ -2080,6 +2084,14 @@ function commitMutationEffectsOnFiber(
2084 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2085 commitReconciliationEffects(finishedWork, lanes);
2086 }
2087 + if (viewTransitionMutationContext) {
2088 + // A Portal doesn't necessarily exist within the context of this subtree.
2089 + // Ideally we would track which React ViewTransition component nests the container
2090 + // but that's costly. Instead, we treat each Portal as if it's a new React root.
2091 + // Therefore any leaked mutation means that the root should animate.
2092 + rootViewTransitionAffected = true;
2093 + }
2094 + popMutationContext(prevMutationContext);
2095
2096 if (flags & Update) {
2097 if (supportsPersistence) {
@@ -2432,7 +2444,7 @@ function commitAfterMutationEffectsOnFiber(
2444 viewTransitionContextChanged = false;
2445 pushViewTransitionCancelableScope();
2446 recursivelyTraverseAfterMutationEffects(root, finishedWork, lanes);
2435 - if (!viewTransitionContextChanged) {
2447 + if (!viewTransitionContextChanged && !rootViewTransitionAffected) {
2448 // If we didn't leak any resizing out to the root, we don't have to transition
2449 // the root itself. This means that we can now safely cancel any cancellations
2450 // that bubbled all the way up.
@@ -2456,6 +2468,20 @@ function commitAfterMutationEffectsOnFiber(
2468 recursivelyTraverseAfterMutationEffects(root, finishedWork, lanes);
2469 break;
2470 }
2471 + case HostPortal: {
2472 + const prevContextChanged = viewTransitionContextChanged;
2473 + viewTransitionContextChanged = false;
2474 + recursivelyTraverseAfterMutationEffects(root, finishedWork, lanes);
2475 + if (viewTransitionContextChanged) {
2476 + // A Portal doesn't necessarily exist within the context of this subtree.
2477 + // Ideally we would track which React ViewTransition component nests the container
2478 + // but that's costly. Instead, we treat each Portal as if it's a new React root.
2479 + // Therefore any leaked resize of a child could affect the root so the root should animate.
2480 + rootViewTransitionAffected = true;
2481 + }
2482 + viewTransitionContextChanged = prevContextChanged;
2483 + break;
2484 + }
2485 case OffscreenComponent: {
2486 const isModernRoot =
2487 disableLegacyMode || (finishedWork.mode & ConcurrentMode) !== NoMode;