Repository: facebook/react
Pull request: Execute layout phase before after mutation phase inside view transition

This allows mutations and scrolling in the layout phase to be counted towards the mutation. This would maybe not be the case for gestures but it is useful for fire-and-forget.

This also avoids the issue that if you resolve navigation in useLayoutEffect that it ends up dead locked.

It also means that useLayoutEffect does not observe the scroll restoration and in fact, the scroll restoration would win over any manual scrolling in layout effects. For better or worse, this is more in line with how things worked before and how it works in popstate. So it's less of a breaking change. This does mean that we can't unify the after mutation phase with the layout phase though.

To do this we need split out flushSpawnedWork from the flushLayoutEffect call.

Spawned work from setState inside the layout phase is done outside and not counted towards the transition. They're sync updates and so are not eligible for their own View Transitions. It's also tricky to support this since it's unclear what things like exits in that update would mean. This work will still be able to mutate the live DOM but it's just not eligible to trigger new transitions or adjust the target of those.

One difference between popstate is that this spawned work is after scroll restoration. So any scrolling spawned from a second pass would now win over scroll restoration.

Another consequence of this change is that you can't safely animate pseudo elements in useLayoutEffect. We'll introduce a better event for that anyway.

Changed files:
filename: fixtures/view-transition/src/components/App.js
patch: @@ -1,6 +1,6 @@
 import React, {
   startTransition,
-  useInsertionEffect,
+  useLayoutEffect,
   useEffect,
   useState,
 } from 'react';
@@ -68,7 +68,7 @@ export default function App({assets, initialURL}) {
     }
   }, []);
   const pendingNav = routerState.pendingNav;
-  useInsertionEffect(() => {
+  useLayoutEffect(() => {
     pendingNav();
   }, [pendingNav]);
   return (

filename: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
patch: @@ -1201,8 +1201,9 @@ export function hasInstanceAffectedParent(
 export function startViewTransition(
   rootContainer: Container,
   mutationCallback: () => void,
-  afterMutationCallback: () => void,
   layoutCallback: () => void,
+  afterMutationCallback: () => void,
+  spawnedWorkCallback: () => void,
   passiveCallback: () => mixed,
 ): boolean {
   const ownerDocument: Document =
@@ -1213,11 +1214,15 @@ export function startViewTransition(
     // $FlowFixMe[prop-missing]
     const transition = ownerDocument.startViewTransition({
       update() {
-        mutationCallback();
-        // TODO: Wait for fonts.
+        // Note: We read the existence of a pending navigation before we apply the
+        // mutations. That way we're not waiting on a navigation that we spawned
+        // from this update. Only navigations that started before this commit.
         const ownerWindow = ownerDocument.defaultView;
         const pendingNavigation =
           ownerWindow.navigation && ownerWindow.navigation.transition;
+        mutationCallback();
+        // TODO: Wait for fonts.
+        layoutCallback();
         if (pendingNavigation) {
           return pendingNavigation.finished.then(
             afterMutationCallback,
@@ -1241,13 +1246,13 @@ export function startViewTransition(
           console.error(
             'A ViewTransition timed out because a Navigation stalled. ' +
               'This can happen if a Navigation is blocked on React itself. ' +
-              "Such as if it's resolved inside useLayoutEffect. " +
-              'This can be solved by moving the resolution to useInsertionEffect.',
+              "Such as if it's resolved inside useEffect. " +
+              'This can be solved by moving the resolution to useLayoutEffect.',
           );
         }
       });
     }
-    transition.ready.then(layoutCallback, layoutCallback);
+    transition.ready.then(spawnedWorkCallback, spawnedWorkCallback);
     transition.finished.then(() => {
       // $FlowFixMe[prop-missing]
       ownerDocument.__reactViewTransition = null;

filename: packages/react-native-renderer/src/ReactFiberConfigNative.js
patch: @@ -583,8 +583,9 @@ export function hasInstanceAffectedParent(
 export function startViewTransition(
   rootContainer: Container,
   mutationCallback: () => void,
-  afterMutationCallback: () => void,
   layoutCallback: () => void,
+  afterMutationCallback: () => void,
+  spawnedWorkCallback: () => void,
   passiveCallback: () => mixed,
 ): boolean {
   return false;

filename: packages/react-reconciler/src/ReactFiberWorkLoop.js
patch: @@ -637,10 +637,11 @@ const THROTTLED_COMMIT = 2;
 
 const NO_PENDING_EFFECTS = 0;
 const PENDING_MUTATION_PHASE = 1;
-const PENDING_AFTER_MUTATION_PHASE = 2;
-const PENDING_LAYOUT_PHASE = 3;
-const PENDING_PASSIVE_PHASE = 4;
-let pendingEffectsStatus: 0 | 1 | 2 | 3 | 4 = 0;
+const PENDING_LAYOUT_PHASE = 2;
+const PENDING_AFTER_MUTATION_PHASE = 3;
+const PENDING_SPAWNED_WORK = 4;
+const PENDING_PASSIVE_PHASE = 5;
+let pendingEffectsStatus: 0 | 1 | 2 | 3 | 4 | 5 = 0;
 let pendingEffectsRoot: FiberRoot = (null: any);
 let pendingFinishedWork: Fiber = (null: any);
 let pendingEffectsLanes: Lanes = NoLanes;
@@ -3432,19 +3433,17 @@ function commitRoot(
     startViewTransition(
       root.containerInfo,
       flushMutationEffects,
-      flushAfterMutationEffects,
       flushLayoutEffects,
-      // TODO: This flushes passive effects at the end of the transition but
-      // we also schedule work to flush them separately which we really shouldn't.
-      // We use flushPendingEffects instead of
+      flushAfterMutationEffects,
+      flushSpawnedWork,
       flushPassiveEffects,
     );
   if (!startedViewTransition) {
     // Flush synchronously.
     flushMutationEffects();
-    // Skip flushAfterMutationEffects
-    pendingEffectsStatus = PENDING_LAYOUT_PHASE;
     flushLayoutEffects();
+    // Skip flushAfterMutationEffects
+    flushSpawnedWork();
   }
 }
 
@@ -3457,7 +3456,7 @@ function flushAfterMutationEffects(): void {
   const finishedWork = pendingFinishedWork;
   const lanes = pendingEffectsLanes;
   commitAfterMutationEffects(root, finishedWork, lanes);
-  pendingEffectsStatus = PENDING_LAYOUT_PHASE;
+  pendingEffectsStatus = PENDING_SPAWNED_WORK;
 }
 
 function flushMutationEffects(): void {
@@ -3503,27 +3502,18 @@ function flushMutationEffects(): void {
   // componentWillUnmount, but before the layout phase, so that the finished
   // work is current during componentDidMount/Update.
   root.current = finishedWork;
-  pendingEffectsStatus = PENDING_AFTER_MUTATION_PHASE;
+  pendingEffectsStatus = PENDING_LAYOUT_PHASE;
 }
 
 function flushLayoutEffects(): void {
-  if (
-    pendingEffectsStatus !== PENDING_LAYOUT_PHASE &&
-    // If a startViewTransition times out, we might flush this earlier than
-    // after mutation phase. In that case, we just skip the after mutation phase.
-    pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE
-  ) {
+  if (pendingEffectsStatus !== PENDING_LAYOUT_PHASE) {
     return;
   }
   pendingEffectsStatus = NO_PENDING_EFFECTS;
 
   const root = pendingEffectsRoot;
   const finishedWork = pendingFinishedWork;
   const lanes = pendingEffectsLanes;
-  const completedRenderEndTime = pendingEffectsRenderEndTime;
-  const recoverableErrors = pendingRecoverableErrors;
-  const didIncludeRenderPhaseUpdate = pendingDidIncludeRenderPhaseUpdate;
-  const suspendedCommitReason = pendingSuspendedCommitReason;
 
   const subtreeHasLayoutEffects =
     (finishedWork.subtreeFlags & LayoutMask) !== NoFlags;
@@ -3554,11 +3544,32 @@ function flushLayoutEffects(): void {
       ReactSharedInternals.T = prevTransition;
     }
   }
+  pendingEffectsStatus = PENDING_AFTER_MUTATION_PHASE;
+}
+
+function flushSpawnedWork(): void {
+  if (
+    pendingEffectsStatus !== PENDING_SPAWNED_WORK &&
+    // If a startViewTransition times out, we might flush this earlier than
+    // after mutation phase. In that case, we just skip the after mutation phase.
+    pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE
+  ) {
+    return;
+  }
+  pendingEffectsStatus = NO_PENDING_EFFECTS;
 
   // Tell Scheduler to yield at the end of the frame, so the browser has an
   // opportunity to paint.
   requestPaint();
 
+  const root = pendingEffectsRoot;
+  const finishedWork = pendingFinishedWork;
+  const lanes = pendingEffectsLanes;
+  const completedRenderEndTime = pendingEffectsRenderEndTime;
+  const recoverableErrors = pendingRecoverableErrors;
+  const didIncludeRenderPhaseUpdate = pendingDidIncludeRenderPhaseUpdate;
+  const suspendedCommitReason = pendingSuspendedCommitReason;
+
   if (enableProfilerTimer && enableComponentPerformanceTrack) {
     recordCommitEndTime();
     logCommitPhase(
@@ -3795,6 +3806,8 @@ export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
   // Returns whether passive effects were flushed.
   flushMutationEffects();
   flushLayoutEffects();
+  // Skip flushAfterMutation if we're forcing this early.
+  flushSpawnedWork();
   return flushPassiveEffects(wasDelayedCommit);
 }
 

filename: packages/react-test-renderer/src/ReactFiberConfigTestHost.js
patch: @@ -365,8 +365,9 @@ export function hasInstanceAffectedParent(
 export function startViewTransition(
   rootContainer: Container,
   mutationCallback: () => void,
-  afterMutationCallback: () => void,
   layoutCallback: () => void,
+  afterMutationCallback: () => void,
+  spawnedWorkCallback: () => void,
   passiveCallback: () => mixed,
 ): boolean {
   return false;