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

Temporarily Mount useInsertionEffect while a Gesture snapshot is being computed (#35565)

`useInsertionEffect` is meant to be used to insert `<style>` tags that affect the layout. It allows precomputing a layout before it mounts. Since we're not normally firing any effects during the "apply gesture" phase where we create the clones, it's possible for the target snapshot to be missing styles. This makes it so that `useInsertionEffect` for a new tree are mounted before the snapshot is taken and then unmounted before the animation starts. Note that because we are mounting a clone of the DOM tree and the previous DOM tree remains mounted during the snapshot, we can't unmount any previous insertion effects. This can lead to conflicts but that is similar to what can happen with conflicts for two mounted Activity boundaries since insertion effects can remain mounted inside those. A revealed Activity will have already had their insertion effects fired while offscreen. However, one thing this doesn't yet do is handle the case where a `useInsertionEffect` is *updated* as part of a gesture being applied. This means it's still possible for it to miss some styles in that case. The interesting thing there is that since the old state and the new state will both be applicable to the global DOM in this phase, what should really happen is that we should mount the new updated state without unmounting the old state and then unmount the updated state. Meaning you can have the same hook in the mounted state twice at the same time.

Sebastian Markbåge committed Jan 19, 2026 at 19:27 UTC 1ecd99c774131a6e5c689ee6fe38236e2032f693
3 files changed +112 -25
fixtures/view-transition/src/components/Page.css
-9
@@ -1,12 +1,3 @@
1 -.roboto-font {
2 - font-family: "Roboto", serif;
3 - font-optical-sizing: auto;
4 - font-weight: 100;
5 - font-style: normal;
6 - font-variation-settings:
7 - "wdth" 100;
8 -}
9 -
1 .swipe-recognizer {
2 width: 300px;
3 background: #eee;
fixtures/view-transition/src/components/Page.js
+21
@@ -4,6 +4,7 @@ import React, {
4 Activity,
5 useLayoutEffect,
6 useEffect,
7 + useInsertionEffect,
8 useState,
9 useId,
10 useOptimistic,
@@ -41,6 +42,26 @@ const b = (
42 );
43
44 function Component() {
45 + // Test inserting fonts with style tags using useInsertionEffect. This is not recommended but
46 + // used to test that gestures etc works with useInsertionEffect so that stylesheet based
47 + // libraries can be properly supported.
48 + useInsertionEffect(() => {
49 + const style = document.createElement('style');
50 + style.textContent = `
51 + .roboto-font {
52 + font-family: "Roboto", serif;
53 + font-optical-sizing: auto;
54 + font-weight: 100;
55 + font-style: normal;
56 + font-variation-settings:
57 + "wdth" 100;
58 + }
59 + `;
60 + document.head.appendChild(style);
61 + return () => {
62 + document.head.removeChild(style);
63 + };
64 + }, []);
65 return (
66 <ViewTransition
67 default={
packages/react-reconciler/src/ReactFiberApplyGesture.js
+91 -16
@@ -43,6 +43,7 @@ import {
43 } from './ReactFiberMutationTracking';
44 import {
45 MutationMask,
46 + Placement,
47 Update,
48 ContentReset,
49 NoFlags,
@@ -52,6 +53,14 @@ import {
53 AffectedParentLayout,
54 } from './ReactFiberFlags';
55 import {
56 + HasEffect as HookHasEffect,
57 + Insertion as HookInsertion,
58 +} from './ReactHookEffectTags';
59 +import {
60 + FunctionComponent,
61 + ForwardRef,
62 + MemoComponent,
63 + SimpleMemoComponent,
64 HostComponent,
65 HostHoistable,
66 HostSingleton,
@@ -72,6 +81,10 @@ import {
81 pushViewTransitionCancelableScope,
82 popViewTransitionCancelableScope,
83 } from './ReactFiberCommitViewTransitions';
84 +import {
85 + commitHookEffectListMount,
86 + commitHookEffectListUnmount,
87 +} from './ReactFiberCommitEffects';
88 import {
89 getViewTransitionName,
90 getViewTransitionClassName,
@@ -378,9 +391,10 @@ function recursivelyInsertNew(
391 if (
392 visitPhase === INSERT_APPEARING_PAIR &&
393 parentViewTransition === null &&
381 - (parentFiber.subtreeFlags & ViewTransitionNamedStatic) === NoFlags
394 + (parentFiber.subtreeFlags & (ViewTransitionNamedStatic | Placement)) ===
395 + NoFlags
396 ) {
383 - // We're just searching for pairs but we have reached the end.
397 + // We're just searching for pairs or insertion effects but we have reached the end.
398 return;
399 }
400 let child = parentFiber.child;
@@ -402,6 +416,28 @@ function recursivelyInsertNewFiber(
416 visitPhase: VisitPhase,
417 ): void {
418 switch (finishedWork.tag) {
419 + case FunctionComponent:
420 + case ForwardRef:
421 + case MemoComponent:
422 + case SimpleMemoComponent: {
423 + recursivelyInsertNew(
424 + finishedWork,
425 + hostParentClone,
426 + parentViewTransition,
427 + visitPhase,
428 + );
429 + if (finishedWork.flags & Update) {
430 + // Insertion Effects are mounted temporarily during the rendering of the snapshot.
431 + // This does not affect cloned Offscreen content since those would've been mounted
432 + // while inside the offscreen tree already.
433 + // Note that because we are mounting a clone of the DOM tree and the previous DOM
434 + // tree remains mounted during the snapshot, we can't unmount any previous insertion
435 + // effects. This can lead to conflicts but that is similar to what can happen with
436 + // conflicts for two mounted Activity boundaries.
437 + commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork);
438 + }
439 + break;
440 + }
441 case HostHoistable: {
442 if (supportsResources) {
443 // TODO: Hoistables should get optimistically inserted and then removed.
@@ -1039,6 +1075,40 @@ function measureExitViewTransitions(placement: Fiber): void {
1075 }
1076 }
1077
1078 +function recursivelyRestoreNew(
1079 + finishedWork: Fiber,
1080 + nearestMountedAncestor: Fiber,
1081 +): void {
1082 + // There has to be move a Placement AND an Update flag somewhere below for this
1083 + // pass to be relevant since we only apply insertion effects for new components here.
1084 + if (((Placement | Update) & finishedWork.subtreeFlags) !== NoFlags) {
1085 + let child = finishedWork.child;
1086 + while (child !== null) {
1087 + recursivelyRestoreNew(child, nearestMountedAncestor);
1088 + child = child.sibling;
1089 + }
1090 + }
1091 + switch (finishedWork.tag) {
1092 + case FunctionComponent:
1093 + case ForwardRef:
1094 + case MemoComponent:
1095 + case SimpleMemoComponent: {
1096 + const current = finishedWork.alternate;
1097 + if (current === null && finishedWork.flags & Update) {
1098 + // Insertion Effects are mounted temporarily during the rendering of the snapshot.
1099 + // We have now already takes a snapshot of the inserted state so we can now unmount
1100 + // them to get back into the original state before starting the animation.
1101 + commitHookEffectListUnmount(
1102 + HookInsertion | HookHasEffect,
1103 + finishedWork,
1104 + nearestMountedAncestor,
1105 + );
1106 + }
1107 + break;
1108 + }
1109 + }
1110 +}
1111 +
1112 function recursivelyApplyViewTransitions(parentFiber: Fiber) {
1113 const deletions = parentFiber.deletions;
1114 if (deletions !== null) {
@@ -1055,7 +1125,13 @@ function recursivelyApplyViewTransitions(parentFiber: Fiber) {
1125 // If we have mutations or if this is a newly inserted tree, clone as we go.
1126 let child = parentFiber.child;
1127 while (child !== null) {
1058 - applyViewTransitionsOnFiber(child);
1128 + const current = child.alternate;
1129 + if (current === null) {
1130 + measureExitViewTransitions(child);
1131 + recursivelyRestoreNew(child, parentFiber);
1132 + } else {
1133 + applyViewTransitionsOnFiber(child, current);
1134 + }
1135 child = child.sibling;
1136 }
1137 } else {
@@ -1066,14 +1142,7 @@ function recursivelyApplyViewTransitions(parentFiber: Fiber) {
1142 }
1143 }
1144
1069 -function applyViewTransitionsOnFiber(finishedWork: Fiber) {
1070 - const current = finishedWork.alternate;
1071 - if (current === null) {
1072 - measureExitViewTransitions(finishedWork);
1073 - return;
1074 - }
1075 -
1076 - const flags = finishedWork.flags;
1145 +function applyViewTransitionsOnFiber(finishedWork: Fiber, current: Fiber) {
1146 // The effect flag should be checked *after* we refine the type of fiber,
1147 // because the fiber tag is more specific. An exception is any flag related
1148 // to reconciliation, because those can be set on all fiber types.
@@ -1083,12 +1152,18 @@ function applyViewTransitionsOnFiber(finishedWork: Fiber) {
1152 break;
1153 }
1154 case OffscreenComponent: {
1086 - if (flags & Visibility) {
1087 - const newState: OffscreenState | null = finishedWork.memoizedState;
1088 - const isHidden = newState !== null;
1089 - if (!isHidden) {
1155 + const newState: OffscreenState | null = finishedWork.memoizedState;
1156 + const isHidden = newState !== null;
1157 + const wasHidden = current.memoizedState !== null;
1158 + if (!isHidden) {
1159 + if (wasHidden) {
1160 measureExitViewTransitions(finishedWork);
1091 - } else if (current !== null && current.memoizedState === null) {
1161 + recursivelyRestoreNew(finishedWork, finishedWork);
1162 + } else {
1163 + recursivelyApplyViewTransitions(finishedWork);
1164 + }
1165 + } else {
1166 + if (!wasHidden) {
1167 // Was previously mounted as visible but is now hidden.
1168 commitEnterViewTransitions(current, true);
1169 }