From 14951c3f848ac2e25c77bbf65f4935f7c36a323c Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 12 Mar 2025 17:28:32 -0400 Subject: [PATCH 1/6] Search the deleted tree to find pairs in the appearingViewTransitions map --- .../src/ReactFiberApplyGesture.js | 86 ++++++++++++++++++- .../src/ReactFiberCommitViewTransitions.js | 3 +- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index a83c52c57cad8..a4b1628b5313d 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -60,7 +60,12 @@ import { import { restoreEnterOrExitViewTransitions, restoreNestedViewTransitions, + appearingViewTransitions, } from './ReactFiberCommitViewTransitions'; +import { + getViewTransitionName, + getViewTransitionClassName, +} from './ReactFiberViewTransitionComponent'; let didWarnForRootClone = false; @@ -78,7 +83,27 @@ const INSERT_APPEND = 6; // Inside a newly mounted tree before the next HostComp const INSERT_APPEARING_PAIR = 7; // Inside a newly mounted tree only finding pairs. type VisitPhase = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; +function applyViewTransitionToClones( + name: string, + className: ?string, + clones: Array, +): void { + // This gets called when we have found a pair, but after the clone in created. The clone is + // created by the insertion side. If the insertion side if found before the deletion side + // then this is called by the deletion. If the deletion is visited first then this is called + // later by the insertion when the clone has been created. + // TODO: Actually apply names to clones. +} + function trackDeletedPairViewTransitions(deletion: Fiber): void { + if ( + appearingViewTransitions === null || + appearingViewTransitions.size === 0 + ) { + // We've found all. + return; + } + const pairs = appearingViewTransitions; if ((deletion.subtreeFlags & ViewTransitionNamedStatic) === NoFlags) { // This has no named view transitions in its subtree. return; @@ -95,7 +120,34 @@ function trackDeletedPairViewTransitions(deletion: Fiber): void { const props: ViewTransitionProps = child.memoizedProps; const name = props.name; if (name != null && name !== 'auto') { - // TODO: Find a pair + const pair = pairs.get(name); + if (pair !== undefined) { + // Delete the entry so that we know when we've found all of them + // and can stop searching (size reaches zero). + pairs.delete(name); + const className: ?string = getViewTransitionClassName( + props.className, + props.share, + ); + if (className !== 'none') { + // TODO: Should we measure if this in the viewport first? + // The "old" instance is actually the one we're inserting. + const oldInstance: ViewTransitionState = pair; + // The "new" instance is the already mounted one we're deleting. + const newInstance: ViewTransitionState = child.stateNode; + oldInstance.paired = newInstance; + const clones = oldInstance.clones; + if (clones !== null) { + // If we have clones that means that we've already visited this + // ViewTransition boundary before and we can now apply the name + // to those clones. Otherwise, we have to wait until we clone it. + applyViewTransitionToClones(name, className, clones); + } + } + if (pairs.size === 0) { + break; + } + } } } trackDeletedPairViewTransitions(child); @@ -107,9 +159,35 @@ function trackDeletedPairViewTransitions(deletion: Fiber): void { function trackEnterViewTransitions(deletion: Fiber): void { if (deletion.tag === ViewTransitionComponent) { const props: ViewTransitionProps = deletion.memoizedProps; - const name = props.name; - if (name != null && name !== 'auto') { - // TODO: Find a pair + const name = getViewTransitionName(props, deletion.stateNode); + const pair = + appearingViewTransitions !== null + ? appearingViewTransitions.get(name) + : undefined; + const className: ?string = getViewTransitionClassName( + props.className, + pair !== undefined ? props.share : props.enter, + ); + if (className !== 'none') { + // TODO: Should we measure if this in the viewport first? + if (pair !== undefined) { + // Delete the entry so that we know when we've found all of them + // and can stop searching (size reaches zero). + // $FlowFixMe[incompatible-use]: Refined by the pair. + appearingViewTransitions.delete(name); + // The "old" instance is actually the one we're inserting. + const oldInstance: ViewTransitionState = pair; + // The "new" instance is the already mounted one we're deleting. + const newInstance: ViewTransitionState = deletion.stateNode; + oldInstance.paired = newInstance; + const clones = oldInstance.clones; + if (clones !== null) { + // If we have clones that means that we've already visited this + // ViewTransition boundary before and we can now apply the name + // to those clones. Otherwise, we have to wait until we clone it. + applyViewTransitionToClones(name, className, clones); + } + } } // Look for more pairs deeper in the tree. trackDeletedPairViewTransitions(deletion); diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index 09f749bd22a3a..a07882b6e42fe 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -49,7 +49,8 @@ export function resetShouldStartViewTransition(): void { // This tracks named ViewTransition components found in the accumulateSuspenseyCommit // phase that might need to find deleted pairs in the beforeMutation phase. -let appearingViewTransitions: Map | null = null; +export let appearingViewTransitions: Map | null = + null; export function resetAppearingViewTransitions(): void { appearingViewTransitions = null; From 32f31294bdc46ad828506508ea47b62ea09a0d42 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 12 Mar 2025 21:48:08 -0400 Subject: [PATCH 2/6] Apply exit transitions to insertions/appearing clones and find pairs This is a little different because we have forked paths for insertions of new DOM nodes, vs cloning of existing DOM nodes inside a hidden Offscreen. But same principle. Note that we don't know for sure when we find an insertion whether we'll find a deletion later but if we do then that will override anyway. --- .../src/ReactFiberApplyGesture.js | 94 ++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index a4b1628b5313d..9241ef9ae4bbf 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -202,6 +202,67 @@ function trackEnterViewTransitions(deletion: Fiber): void { } } +function applyAppearingPairViewTransition(child: Fiber): void { + // Normally these helpers do recursive calls but since insertion/offscreen is forked + // we call this helper from those loops instead. This must be called only on + // ViewTransitionComponent that has already had their clones filled. + if ((child.flags & ViewTransitionNamedStatic) !== NoFlags) { + const state: ViewTransitionState = child.stateNode; + // If this is not yet paired, it doesn't mean that we won't pair it later when + // we find the deletion side. If that's the case then we'll add the names to + // the clones then. + if (state.paired) { + const props: ViewTransitionProps = child.memoizedProps; + if (props.name == null || props.name === 'auto') { + throw new Error( + 'Found a pair with an auto name. This is a bug in React.', + ); + } + const name = props.name; + // Note that this class name that doesn't actually really matter because the + // "new" side will be the one that wins in practice. + const className: ?string = getViewTransitionClassName( + props.className, + props.share, + ); + if (className !== 'none') { + // TODO: Should we measure if this in the viewport first? + const clones = state.clones; + // If there are no clones at this point, that should mean that there are no + // HostComponent children in this ViewTransition. + if (clones !== null) { + applyViewTransitionToClones(name, className, clones); + } + } + } + } +} + +function applyExitViewTransition(placement: Fiber): void { + // Normally these helpers do recursive calls but since insertion/offscreen is forked + // we call this helper from those loops instead. This must be called only on + // ViewTransitionComponent that has already had their clones filled. + const state: ViewTransitionState = placement.stateNode; + const props: ViewTransitionProps = placement.memoizedProps; + const name = getViewTransitionName(props, state); + const className: ?string = getViewTransitionClassName( + props.className, + // Note that just because we don't have a pair yet doesn't mean we won't find one + // later. However, that doesn't matter because if we do the class name that wins + // is the one applied by the "new" side anyway. + state.paired ? props.share : props.exit, + ); + if (className !== 'none') { + // TODO: Should we measure if this in the viewport first? + const clones = state.clones; + // If there are no clones at this point, that should mean that there are no + // HostComponent children in this ViewTransition. + if (clones !== null) { + applyViewTransitionToClones(name, className, clones); + } + } +} + function recursivelyInsertNew( parentFiber: Fiber, hostParentClone: Instance, @@ -343,7 +404,6 @@ function recursivelyInsertNewFiber( // This was an Enter of a ViewTransition. We now move onto inserting the inner // HostComponents and finding inner pairs. nextPhase = INSERT_APPEND; - // TODO: Mark the name and find a pair. } else { nextPhase = visitPhase; } @@ -353,6 +413,16 @@ function recursivelyInsertNewFiber( viewTransitionState, nextPhase, ); + // After we've inserted the new nodes into the "clones" set we can apply share + // or exit transitions to them. + if (visitPhase === INSERT_EXIT) { + applyExitViewTransition(finishedWork); + } else if ( + visitPhase === INSERT_APPEARING_PAIR || + visitPhase === INSERT_APPEND + ) { + applyAppearingPairViewTransition(finishedWork); + } popMutationContext(prevMutationContext); break; default: { @@ -490,8 +560,16 @@ function recursivelyInsertClonesFromExistingTree( viewTransitionState, nextPhase, ); - // TODO: Only the first level should track if this was s - // child.flags |= Update; + // After we've collected the cloned instances, we can apply exit or share transitions + // to them. + if (visitPhase === CLONE_EXIT) { + applyExitViewTransition(child); + } else if ( + visitPhase === CLONE_APPEARING_PAIR || + visitPhase === CLONE_UNHIDE + ) { + applyAppearingPairViewTransition(child); + } popMutationContext(prevMutationContext); break; default: { @@ -753,6 +831,16 @@ function insertDestinationClonesOfFiber( // whether it resized or not. finishedWork.flags |= Update; } + // After we've collected the cloned instances, we can apply exit or share transitions + // to them. + if (visitPhase === CLONE_EXIT) { + applyExitViewTransition(finishedWork); + } else if ( + visitPhase === CLONE_APPEARING_PAIR || + visitPhase === CLONE_UNHIDE + ) { + applyAppearingPairViewTransition(finishedWork); + } popMutationContext(prevMutationContext); break; default: { From 4cd2192f56ffb4a2cc5800113ee310662defb4bb Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 12 Mar 2025 21:51:11 -0400 Subject: [PATCH 3/6] Actually apply the view-transition-name to the clones in the "old" phase --- .../src/ReactFiberApplyGesture.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index 9241ef9ae4bbf..450956d677a4c 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -25,6 +25,7 @@ import { removeRootViewTransitionClone, cancelRootViewTransitionName, restoreRootViewTransitionName, + applyViewTransitionName, appendChild, commitUpdate, commitTextUpdate, @@ -92,7 +93,18 @@ function applyViewTransitionToClones( // created by the insertion side. If the insertion side if found before the deletion side // then this is called by the deletion. If the deletion is visited first then this is called // later by the insertion when the clone has been created. - // TODO: Actually apply names to clones. + // TODO: Should we measure if this in the viewport first? + for (let i = 0; i < clones.length; i++) { + applyViewTransitionName( + clones[i], + i === 0 + ? name + : // If we have multiple Host Instances below, we add a suffix to the name to give + // each one a unique name. + name + '_' + i, + className, + ); + } } function trackDeletedPairViewTransitions(deletion: Fiber): void { @@ -130,7 +142,6 @@ function trackDeletedPairViewTransitions(deletion: Fiber): void { props.share, ); if (className !== 'none') { - // TODO: Should we measure if this in the viewport first? // The "old" instance is actually the one we're inserting. const oldInstance: ViewTransitionState = pair; // The "new" instance is the already mounted one we're deleting. @@ -169,7 +180,6 @@ function trackEnterViewTransitions(deletion: Fiber): void { pair !== undefined ? props.share : props.enter, ); if (className !== 'none') { - // TODO: Should we measure if this in the viewport first? if (pair !== undefined) { // Delete the entry so that we know when we've found all of them // and can stop searching (size reaches zero). @@ -226,7 +236,6 @@ function applyAppearingPairViewTransition(child: Fiber): void { props.share, ); if (className !== 'none') { - // TODO: Should we measure if this in the viewport first? const clones = state.clones; // If there are no clones at this point, that should mean that there are no // HostComponent children in this ViewTransition. @@ -253,7 +262,6 @@ function applyExitViewTransition(placement: Fiber): void { state.paired ? props.share : props.exit, ); if (className !== 'none') { - // TODO: Should we measure if this in the viewport first? const clones = state.clones; // If there are no clones at this point, that should mean that there are no // HostComponent children in this ViewTransition. From fd58974ed0716627022f6160a1b782d22eb82bfe Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 12 Mar 2025 23:00:49 -0400 Subject: [PATCH 4/6] Also apply names to updates This applies the "old" side of the update to the clones. --- .../src/ReactFiberApplyGesture.js | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index 450956d677a4c..f5cb38d752251 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -271,6 +271,59 @@ function applyExitViewTransition(placement: Fiber): void { } } +function applyNestedViewTransition(child: Fiber): void { + const state: ViewTransitionState = child.stateNode; + const props: ViewTransitionProps = child.memoizedProps; + const name = getViewTransitionName(props, state); + const className: ?string = getViewTransitionClassName( + props.className, + props.layout, + ); + if (className !== 'none') { + const clones = state.clones; + // If there are no clones at this point, that should mean that there are no + // HostComponent children in this ViewTransition. + if (clones !== null) { + applyViewTransitionToClones(name, className, clones); + } + } +} + +function applyUpdateViewTransition(current: Fiber, finishedWork: Fiber): void { + const state: ViewTransitionState = finishedWork.stateNode; + // Updates can have conflicting names and classNames. + // Since we're doing a reverse animation the "new" state is actually the current + // and the "old" state is the finishedWork. + const newProps: ViewTransitionProps = current.memoizedProps; + const oldProps: ViewTransitionProps = finishedWork.memoizedProps; + const oldName = getViewTransitionName(oldProps, state); + // This className applies only if there are fewer child DOM nodes than + // before or if this update should've been cancelled but we ended up with + // a parent animating so we need to animate the child too. Otherwise + // the "new" state wins. Since "new" normally wins, that's usually what + // we would use. However, since this animation is going in reverse we actually + // want the props from "current" since that's the class that would've won if + // it was the normal direction. To preserve the same effect in either direction. + let className: ?string = getViewTransitionClassName( + newProps.className, + newProps.update, + ); + if (className === 'none') { + className = getViewTransitionClassName(newProps.className, newProps.layout); + if (className === 'none') { + // If both update and layout are both "none" then we don't have to + // apply a name. Since we won't animate this boundary. + return; + } + } + const clones = state.clones; + // If there are no clones at this point, that should mean that there are no + // HostComponent children in this ViewTransition. + if (clones !== null) { + applyViewTransitionToClones(oldName, className, clones); + } +} + function recursivelyInsertNew( parentFiber: Fiber, hostParentClone: Instance, @@ -577,6 +630,8 @@ function recursivelyInsertClonesFromExistingTree( visitPhase === CLONE_UNHIDE ) { applyAppearingPairViewTransition(child); + } else if (visitPhase === CLONE_UPDATE) { + applyNestedViewTransition(child); } popMutationContext(prevMutationContext); break; @@ -848,6 +903,8 @@ function insertDestinationClonesOfFiber( visitPhase === CLONE_UNHIDE ) { applyAppearingPairViewTransition(finishedWork); + } else if (visitPhase === CLONE_UPDATE) { + applyUpdateViewTransition(current, finishedWork); } popMutationContext(prevMutationContext); break; From 6348e23300ff53d74d4db1950d8f2ea9ac92229e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 13 Mar 2025 17:23:44 -0400 Subject: [PATCH 5/6] Clarify viewport comments. --- .../src/ReactFiberApplyGesture.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index f5cb38d752251..8dc459bf485b6 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -93,7 +93,6 @@ function applyViewTransitionToClones( // created by the insertion side. If the insertion side if found before the deletion side // then this is called by the deletion. If the deletion is visited first then this is called // later by the insertion when the clone has been created. - // TODO: Should we measure if this in the viewport first? for (let i = 0; i < clones.length; i++) { applyViewTransitionName( clones[i], @@ -142,6 +141,12 @@ function trackDeletedPairViewTransitions(deletion: Fiber): void { props.share, ); if (className !== 'none') { + // TODO: Since the deleted instance already has layout we could + // check if it's in the viewport and if not skip the pairing. + // It would currently cause layout thrash though so if we did that + // we need to avoid inserting the root of the cloned trees until + // the end. + // The "old" instance is actually the one we're inserting. const oldInstance: ViewTransitionState = pair; // The "new" instance is the already mounted one we're deleting. @@ -181,6 +186,12 @@ function trackEnterViewTransitions(deletion: Fiber): void { ); if (className !== 'none') { if (pair !== undefined) { + // TODO: Since the deleted instance already has layout we could + // check if it's in the viewport and if not skip the pairing. + // It would currently cause layout thrash though so if we did that + // we need to avoid inserting the root of the cloned trees until + // the end. + // Delete the entry so that we know when we've found all of them // and can stop searching (size reaches zero). // $FlowFixMe[incompatible-use]: Refined by the pair. @@ -262,6 +273,10 @@ function applyExitViewTransition(placement: Fiber): void { state.paired ? props.share : props.exit, ); if (className !== 'none') { + // TODO: Ideally we could determine if this exit is in the viewport and + // exclude it otherwise but that would require waiting until we insert + // and layout the clones first. Currently wait until the view transition + // starts before reading the layout. const clones = state.clones; // If there are no clones at this point, that should mean that there are no // HostComponent children in this ViewTransition. From 851d21fdbe1a509958cf3c6417adff3d0e327ab9 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 13 Mar 2025 21:23:59 -0400 Subject: [PATCH 6/6] Connect pairs on both sides so we can get to the other pair from either state This doesn't matter for CommitViewTransitions since we only read on one side but it matters for ApplyGesture when we read on the reverse end. --- .../react-reconciler/src/ReactFiberApplyGesture.js | 2 ++ .../src/ReactFiberCommitViewTransitions.js | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index 8dc459bf485b6..918a36d66151f 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -152,6 +152,7 @@ function trackDeletedPairViewTransitions(deletion: Fiber): void { // The "new" instance is the already mounted one we're deleting. const newInstance: ViewTransitionState = child.stateNode; oldInstance.paired = newInstance; + newInstance.paired = oldInstance; const clones = oldInstance.clones; if (clones !== null) { // If we have clones that means that we've already visited this @@ -201,6 +202,7 @@ function trackEnterViewTransitions(deletion: Fiber): void { // The "new" instance is the already mounted one we're deleting. const newInstance: ViewTransitionState = deletion.stateNode; oldInstance.paired = newInstance; + newInstance.paired = oldInstance; const clones = oldInstance.clones; if (clones !== null) { // If we have clones that means that we've already visited this diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index a07882b6e42fe..dc3074a8ecd95 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -348,9 +348,10 @@ function commitDeletedPairViewTransitions(deletion: Fiber): void { restoreViewTransitionOnHostInstances(child.child, false); } else { // We'll transition between them. - const oldinstance: ViewTransitionState = child.stateNode; + const oldInstance: ViewTransitionState = child.stateNode; const newInstance: ViewTransitionState = pair; - newInstance.paired = oldinstance; + newInstance.paired = oldInstance; + oldInstance.paired = newInstance; // Note: If the other side ends up outside the viewport, we'll still run this. // Therefore it's possible for onShare to be called with only an old snapshot. scheduleViewTransitionEvent(child, props.onShare); @@ -399,9 +400,10 @@ export function commitExitViewTransitions(deletion: Fiber): void { } else if (pair !== undefined) { // We found a new appearing view transition with the same name as this deletion. // We'll transition between them instead of running the normal exit. - const oldinstance: ViewTransitionState = deletion.stateNode; + const oldInstance: ViewTransitionState = deletion.stateNode; const newInstance: ViewTransitionState = pair; - newInstance.paired = oldinstance; + newInstance.paired = oldInstance; + oldInstance.paired = newInstance; // Delete the entry so that we know when we've found all of them // and can stop searching (size reaches zero). // $FlowFixMe[incompatible-use]: Refined by the pair.