@samitouri / QOS-React-1 / commits / fd9cfa416f

Execute layout phase before after mutation phase inside view transition (#32029)

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.

Sebastian Markbåge committed Jan 8, 2025 at 19:13 UTC fd9cfa416f7c01ecdf76b10ab776a43f2430786d
5 files changed +52 -32
fixtures/view-transition/src/components/App.js
+2 -2
@@ -1,6 +1,6 @@
1 import React, {
2 startTransition,
3 - useInsertionEffect,
3 + useLayoutEffect,
4 useEffect,
5 useState,
6 } from 'react';
@@ -68,7 +68,7 @@ export default function App({assets, initialURL}) {
68 }
69 }, []);
70 const pendingNav = routerState.pendingNav;
71 - useInsertionEffect(() => {
71 + useLayoutEffect(() => {
72 pendingNav();
73 }, [pendingNav]);
74 return (
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+11 -6
@@ -1201,8 +1201,9 @@ export function hasInstanceAffectedParent(
1201 export function startViewTransition(
1202 rootContainer: Container,
1203 mutationCallback: () => void,
1204 - afterMutationCallback: () => void,
1204 layoutCallback: () => void,
1205 + afterMutationCallback: () => void,
1206 + spawnedWorkCallback: () => void,
1207 passiveCallback: () => mixed,
1208 ): boolean {
1209 const ownerDocument: Document =
@@ -1213,11 +1214,15 @@ export function startViewTransition(
1214 // $FlowFixMe[prop-missing]
1215 const transition = ownerDocument.startViewTransition({
1216 update() {
1216 - mutationCallback();
1217 - // TODO: Wait for fonts.
1217 + // Note: We read the existence of a pending navigation before we apply the
1218 + // mutations. That way we're not waiting on a navigation that we spawned
1219 + // from this update. Only navigations that started before this commit.
1220 const ownerWindow = ownerDocument.defaultView;
1221 const pendingNavigation =
1222 ownerWindow.navigation && ownerWindow.navigation.transition;
1223 + mutationCallback();
1224 + // TODO: Wait for fonts.
1225 + layoutCallback();
1226 if (pendingNavigation) {
1227 return pendingNavigation.finished.then(
1228 afterMutationCallback,
@@ -1241,13 +1246,13 @@ export function startViewTransition(
1246 console.error(
1247 'A ViewTransition timed out because a Navigation stalled. ' +
1248 'This can happen if a Navigation is blocked on React itself. ' +
1244 - "Such as if it's resolved inside useLayoutEffect. " +
1245 - 'This can be solved by moving the resolution to useInsertionEffect.',
1249 + "Such as if it's resolved inside useEffect. " +
1250 + 'This can be solved by moving the resolution to useLayoutEffect.',
1251 );
1252 }
1253 });
1254 }
1250 - transition.ready.then(layoutCallback, layoutCallback);
1255 + transition.ready.then(spawnedWorkCallback, spawnedWorkCallback);
1256 transition.finished.then(() => {
1257 // $FlowFixMe[prop-missing]
1258 ownerDocument.__reactViewTransition = null;
packages/react-native-renderer/src/ReactFiberConfigNative.js
+2 -1
@@ -583,8 +583,9 @@ export function hasInstanceAffectedParent(
583 export function startViewTransition(
584 rootContainer: Container,
585 mutationCallback: () => void,
586 - afterMutationCallback: () => void,
586 layoutCallback: () => void,
587 + afterMutationCallback: () => void,
588 + spawnedWorkCallback: () => void,
589 passiveCallback: () => mixed,
590 ): boolean {
591 return false;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+35 -22
@@ -637,10 +637,11 @@ const THROTTLED_COMMIT = 2;
637
638 const NO_PENDING_EFFECTS = 0;
639 const PENDING_MUTATION_PHASE = 1;
640 -const PENDING_AFTER_MUTATION_PHASE = 2;
641 -const PENDING_LAYOUT_PHASE = 3;
642 -const PENDING_PASSIVE_PHASE = 4;
643 -let pendingEffectsStatus: 0 | 1 | 2 | 3 | 4 = 0;
640 +const PENDING_LAYOUT_PHASE = 2;
641 +const PENDING_AFTER_MUTATION_PHASE = 3;
642 +const PENDING_SPAWNED_WORK = 4;
643 +const PENDING_PASSIVE_PHASE = 5;
644 +let pendingEffectsStatus: 0 | 1 | 2 | 3 | 4 | 5 = 0;
645 let pendingEffectsRoot: FiberRoot = (null: any);
646 let pendingFinishedWork: Fiber = (null: any);
647 let pendingEffectsLanes: Lanes = NoLanes;
@@ -3432,19 +3433,17 @@ function commitRoot(
3433 startViewTransition(
3434 root.containerInfo,
3435 flushMutationEffects,
3435 - flushAfterMutationEffects,
3436 flushLayoutEffects,
3437 - // TODO: This flushes passive effects at the end of the transition but
3438 - // we also schedule work to flush them separately which we really shouldn't.
3439 - // We use flushPendingEffects instead of
3437 + flushAfterMutationEffects,
3438 + flushSpawnedWork,
3439 flushPassiveEffects,
3440 );
3441 if (!startedViewTransition) {
3442 // Flush synchronously.
3443 flushMutationEffects();
3445 - // Skip flushAfterMutationEffects
3446 - pendingEffectsStatus = PENDING_LAYOUT_PHASE;
3444 flushLayoutEffects();
3445 + // Skip flushAfterMutationEffects
3446 + flushSpawnedWork();
3447 }
3448 }
3449
@@ -3457,7 +3456,7 @@ function flushAfterMutationEffects(): void {
3456 const finishedWork = pendingFinishedWork;
3457 const lanes = pendingEffectsLanes;
3458 commitAfterMutationEffects(root, finishedWork, lanes);
3460 - pendingEffectsStatus = PENDING_LAYOUT_PHASE;
3459 + pendingEffectsStatus = PENDING_SPAWNED_WORK;
3460 }
3461
3462 function flushMutationEffects(): void {
@@ -3503,16 +3502,11 @@ function flushMutationEffects(): void {
3502 // componentWillUnmount, but before the layout phase, so that the finished
3503 // work is current during componentDidMount/Update.
3504 root.current = finishedWork;
3506 - pendingEffectsStatus = PENDING_AFTER_MUTATION_PHASE;
3505 + pendingEffectsStatus = PENDING_LAYOUT_PHASE;
3506 }
3507
3508 function flushLayoutEffects(): void {
3510 - if (
3511 - pendingEffectsStatus !== PENDING_LAYOUT_PHASE &&
3512 - // If a startViewTransition times out, we might flush this earlier than
3513 - // after mutation phase. In that case, we just skip the after mutation phase.
3514 - pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE
3515 - ) {
3509 + if (pendingEffectsStatus !== PENDING_LAYOUT_PHASE) {
3510 return;
3511 }
3512 pendingEffectsStatus = NO_PENDING_EFFECTS;
@@ -3520,10 +3514,6 @@ function flushLayoutEffects(): void {
3514 const root = pendingEffectsRoot;
3515 const finishedWork = pendingFinishedWork;
3516 const lanes = pendingEffectsLanes;
3523 - const completedRenderEndTime = pendingEffectsRenderEndTime;
3524 - const recoverableErrors = pendingRecoverableErrors;
3525 - const didIncludeRenderPhaseUpdate = pendingDidIncludeRenderPhaseUpdate;
3526 - const suspendedCommitReason = pendingSuspendedCommitReason;
3517
3518 const subtreeHasLayoutEffects =
3519 (finishedWork.subtreeFlags & LayoutMask) !== NoFlags;
@@ -3554,11 +3544,32 @@ function flushLayoutEffects(): void {
3544 ReactSharedInternals.T = prevTransition;
3545 }
3546 }
3547 + pendingEffectsStatus = PENDING_AFTER_MUTATION_PHASE;
3548 +}
3549 +
3550 +function flushSpawnedWork(): void {
3551 + if (
3552 + pendingEffectsStatus !== PENDING_SPAWNED_WORK &&
3553 + // If a startViewTransition times out, we might flush this earlier than
3554 + // after mutation phase. In that case, we just skip the after mutation phase.
3555 + pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE
3556 + ) {
3557 + return;
3558 + }
3559 + pendingEffectsStatus = NO_PENDING_EFFECTS;
3560
3561 // Tell Scheduler to yield at the end of the frame, so the browser has an
3562 // opportunity to paint.
3563 requestPaint();
3564
3565 + const root = pendingEffectsRoot;
3566 + const finishedWork = pendingFinishedWork;
3567 + const lanes = pendingEffectsLanes;
3568 + const completedRenderEndTime = pendingEffectsRenderEndTime;
3569 + const recoverableErrors = pendingRecoverableErrors;
3570 + const didIncludeRenderPhaseUpdate = pendingDidIncludeRenderPhaseUpdate;
3571 + const suspendedCommitReason = pendingSuspendedCommitReason;
3572 +
3573 if (enableProfilerTimer && enableComponentPerformanceTrack) {
3574 recordCommitEndTime();
3575 logCommitPhase(
@@ -3795,6 +3806,8 @@ export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
3806 // Returns whether passive effects were flushed.
3807 flushMutationEffects();
3808 flushLayoutEffects();
3809 + // Skip flushAfterMutation if we're forcing this early.
3810 + flushSpawnedWork();
3811 return flushPassiveEffects(wasDelayedCommit);
3812 }
3813
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+2 -1
@@ -365,8 +365,9 @@ export function hasInstanceAffectedParent(
365 export function startViewTransition(
366 rootContainer: Container,
367 mutationCallback: () => void,
368 - afterMutationCallback: () => void,
368 layoutCallback: () => void,
369 + afterMutationCallback: () => void,
370 + spawnedWorkCallback: () => void,
371 passiveCallback: () => mixed,
372 ): boolean {
373 return false;