Repository: facebook/react
Pull request: Temporarily Mount useInsertionEffect while a Gesture snapshot is being computed

`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.

Changed files:
filename: fixtures/view-transition/src/components/Page.css
patch: @@ -1,12 +1,3 @@
-.roboto-font {
-  font-family: "Roboto", serif;
-  font-optical-sizing: auto;
-  font-weight: 100;
-  font-style: normal;
-  font-variation-settings:
-    "wdth" 100;
-}
-
 .swipe-recognizer {
   width: 300px;
   background: #eee;

filename: fixtures/view-transition/src/components/Page.js
patch: @@ -4,6 +4,7 @@ import React, {
   Activity,
   useLayoutEffect,
   useEffect,
+  useInsertionEffect,
   useState,
   useId,
   useOptimistic,
@@ -41,6 +42,26 @@ const b = (
 );
 
 function Component() {
+  // Test inserting fonts with style tags using useInsertionEffect. This is not recommended but
+  // used to test that gestures etc works with useInsertionEffect so that stylesheet based
+  // libraries can be properly supported.
+  useInsertionEffect(() => {
+    const style = document.createElement('style');
+    style.textContent = `
+      .roboto-font {
+        font-family: "Roboto", serif;
+        font-optical-sizing: auto;
+        font-weight: 100;
+        font-style: normal;
+        font-variation-settings:
+          "wdth" 100;
+      }
+    `;
+    document.head.appendChild(style);
+    return () => {
+      document.head.removeChild(style);
+    };
+  }, []);
   return (
     <ViewTransition
       default={

filename: packages/react-reconciler/src/ReactFiberApplyGesture.js
patch: @@ -43,6 +43,7 @@ import {
 } from './ReactFiberMutationTracking';
 import {
   MutationMask,
+  Placement,
   Update,
   ContentReset,
   NoFlags,
@@ -52,6 +53,14 @@ import {
   AffectedParentLayout,
 } from './ReactFiberFlags';
 import {
+  HasEffect as HookHasEffect,
+  Insertion as HookInsertion,
+} from './ReactHookEffectTags';
+import {
+  FunctionComponent,
+  ForwardRef,
+  MemoComponent,
+  SimpleMemoComponent,
   HostComponent,
   HostHoistable,
   HostSingleton,
@@ -72,6 +81,10 @@ import {
   pushViewTransitionCancelableScope,
   popViewTransitionCancelableScope,
 } from './ReactFiberCommitViewTransitions';
+import {
+  commitHookEffectListMount,
+  commitHookEffectListUnmount,
+} from './ReactFiberCommitEffects';
 import {
   getViewTransitionName,
   getViewTransitionClassName,
@@ -378,9 +391,10 @@ function recursivelyInsertNew(
   if (
     visitPhase === INSERT_APPEARING_PAIR &&
     parentViewTransition === null &&
-    (parentFiber.subtreeFlags & ViewTransitionNamedStatic) === NoFlags
+    (parentFiber.subtreeFlags & (ViewTransitionNamedStatic | Placement)) ===
+      NoFlags
   ) {
-    // We're just searching for pairs but we have reached the end.
+    // We're just searching for pairs or insertion effects but we have reached the end.
     return;
   }
   let child = parentFiber.child;
@@ -402,6 +416,28 @@ function recursivelyInsertNewFiber(
   visitPhase: VisitPhase,
 ): void {
   switch (finishedWork.tag) {
+    case FunctionComponent:
+    case ForwardRef:
+    case MemoComponent:
+    case SimpleMemoComponent: {
+      recursivelyInsertNew(
+        finishedWork,
+        hostParentClone,
+        parentViewTransition,
+        visitPhase,
+      );
+      if (finishedWork.flags & Update) {
+        // Insertion Effects are mounted temporarily during the rendering of the snapshot.
+        // This does not affect cloned Offscreen content since those would've been mounted
+        // while inside the offscreen tree already.
+        // 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.
+        commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork);
+      }
+      break;
+    }
     case HostHoistable: {
       if (supportsResources) {
         // TODO: Hoistables should get optimistically inserted and then removed.
@@ -1039,6 +1075,40 @@ function measureExitViewTransitions(placement: Fiber): void {
   }
 }
 
+function recursivelyRestoreNew(
+  finishedWork: Fiber,
+  nearestMountedAncestor: Fiber,
+): void {
+  // There has to be move a Placement AND an Update flag somewhere below for this
+  // pass to be relevant since we only apply insertion effects for new components here.
+  if (((Placement | Update) & finishedWork.subtreeFlags) !== NoFlags) {
+    let child = finishedWork.child;
+    while (child !== null) {
+      recursivelyRestoreNew(child, nearestMountedAncestor);
+      child = child.sibling;
+    }
+  }
+  switch (finishedWork.tag) {
+    case FunctionComponent:
+    case ForwardRef:
+    case MemoComponent:
+    case SimpleMemoComponent: {
+      const current = finishedWork.alternate;
+      if (current === null && finishedWork.flags & Update) {
+        // Insertion Effects are mounted temporarily during the rendering of the snapshot.
+        // We have now already takes a snapshot of the inserted state so we can now unmount
+        // them to get back into the original state before starting the animation.
+        commitHookEffectListUnmount(
+          HookInsertion | HookHasEffect,
+          finishedWork,
+          nearestMountedAncestor,
+        );
+      }
+      break;
+    }
+  }
+}
+
 function recursivelyApplyViewTransitions(parentFiber: Fiber) {
   const deletions = parentFiber.deletions;
   if (deletions !== null) {
@@ -1055,7 +1125,13 @@ function recursivelyApplyViewTransitions(parentFiber: Fiber) {
     // If we have mutations or if this is a newly inserted tree, clone as we go.
     let child = parentFiber.child;
     while (child !== null) {
-      applyViewTransitionsOnFiber(child);
+      const current = child.alternate;
+      if (current === null) {
+        measureExitViewTransitions(child);
+        recursivelyRestoreNew(child, parentFiber);
+      } else {
+        applyViewTransitionsOnFiber(child, current);
+      }
       child = child.sibling;
     }
   } else {
@@ -1066,14 +1142,7 @@ function recursivelyApplyViewTransitions(parentFiber: Fiber) {
   }
 }
 
-function applyViewTransitionsOnFiber(finishedWork: Fiber) {
-  const current = finishedWork.alternate;
-  if (current === null) {
-    measureExitViewTransitions(finishedWork);
-    return;
-  }
-
-  const flags = finishedWork.flags;
+function applyViewTransitionsOnFiber(finishedWork: Fiber, current: Fiber) {
   // The effect flag should be checked *after* we refine the type of fiber,
   // because the fiber tag is more specific. An exception is any flag related
   // to reconciliation, because those can be set on all fiber types.
@@ -1083,12 +1152,18 @@ function applyViewTransitionsOnFiber(finishedWork: Fiber) {
       break;
     }
     case OffscreenComponent: {
-      if (flags & Visibility) {
-        const newState: OffscreenState | null = finishedWork.memoizedState;
-        const isHidden = newState !== null;
-        if (!isHidden) {
+      const newState: OffscreenState | null = finishedWork.memoizedState;
+      const isHidden = newState !== null;
+      const wasHidden = current.memoizedState !== null;
+      if (!isHidden) {
+        if (wasHidden) {
           measureExitViewTransitions(finishedWork);
-        } else if (current !== null && current.memoizedState === null) {
+          recursivelyRestoreNew(finishedWork, finishedWork);
+        } else {
+          recursivelyApplyViewTransitions(finishedWork);
+        }
+      } else {
+        if (!wasHidden) {
           // Was previously mounted as visible but is now hidden.
           commitEnterViewTransitions(current, true);
         }