Repository: facebook/react
Pull request: Warn for duplicate ViewTransition names

This adds early logging when two ViewTransitions with the same name are mounted at the same time. Whether they're part of a View Transition or not.

This lets us include the owner stack of each one. I do two logs so that you can get the stack trace of each one of the duplicates.

It currently only logs once for each name which also avoids the scenario when you have many hits for the same name in one commit. However, we could also possibly log a stack for each of them but seems noisy.

Currently we don't log if a SwipeTransition is the first time the pair gets mounted which could lead to a View Transition error before we've warned. That could be a separate improvement.

Changed files:
filename: packages/react-reconciler/src/ReactFiberCommitWork.js
patch: @@ -109,6 +109,7 @@ import {
   ForceClientRender,
   DidCapture,
   AffectedParentLayout,
+  ViewTransitionNamedStatic,
 } from './ReactFiberFlags';
 import {
   commitStartTime,
@@ -254,6 +255,10 @@ import {
   pushMutationContext,
   popMutationContext,
 } from './ReactFiberMutationTracking';
+import {
+  trackNamedViewTransition,
+  untrackNamedViewTransition,
+} from './ReactFiberDuplicateViewTransitions';
 
 // Used during the commit phase to track the state of the Offscreen component stack.
 // Allows us to avoid traversing the return path to find the nearest Offscreen ancestor.
@@ -738,6 +743,11 @@ function commitLayoutEffectOnFiber(
     }
     case ViewTransitionComponent: {
       if (enableViewTransition) {
+        if (__DEV__) {
+          if (flags & ViewTransitionNamedStatic) {
+            trackNamedViewTransition(finishedWork);
+          }
+        }
         recursivelyTraverseLayoutEffects(
           finishedRoot,
           finishedWork,
@@ -1551,11 +1561,34 @@ function commitDeletionEffectsOnFiber(
       }
       break;
     }
+    case ViewTransitionComponent: {
+      if (enableViewTransition) {
+        if (__DEV__) {
+          if (deletedFiber.flags & ViewTransitionNamedStatic) {
+            untrackNamedViewTransition(deletedFiber);
+          }
+        }
+        safelyDetachRef(deletedFiber, nearestMountedAncestor);
+        recursivelyTraverseDeletionEffects(
+          finishedRoot,
+          nearestMountedAncestor,
+          deletedFiber,
+        );
+        return;
+      }
+      // Fallthrough
+    }
     case Fragment: {
       if (enableFragmentRefs) {
         if (!offscreenSubtreeWasHidden) {
           safelyDetachRef(deletedFiber, nearestMountedAncestor);
         }
+        recursivelyTraverseDeletionEffects(
+          finishedRoot,
+          nearestMountedAncestor,
+          deletedFiber,
+        );
+        return;
       }
       // Fallthrough
     }
@@ -2594,6 +2627,11 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
     }
     case ViewTransitionComponent: {
       if (enableViewTransition) {
+        if (__DEV__) {
+          if (finishedWork.flags & ViewTransitionNamedStatic) {
+            untrackNamedViewTransition(finishedWork);
+          }
+        }
         safelyDetachRef(finishedWork, finishedWork.return);
       }
       recursivelyTraverseDisappearLayoutEffects(finishedWork);
@@ -2803,6 +2841,11 @@ export function reappearLayoutEffects(
           finishedWork,
           includeWorkInProgressEffects,
         );
+        if (__DEV__) {
+          if (flags & ViewTransitionNamedStatic) {
+            trackNamedViewTransition(finishedWork);
+          }
+        }
         safelyAttachRef(finishedWork, finishedWork.return);
         break;
       }

filename: packages/react-reconciler/src/ReactFiberDuplicateViewTransitions.js
patch: @@ -0,0 +1,71 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import type {Fiber} from './ReactInternalTypes';
+import type {ViewTransitionProps} from './ReactFiberViewTransitionComponent';
+import {runWithFiberInDEV} from './ReactCurrentFiber';
+
+// Use in DEV to track mounted named ViewTransitions. This is used to warn for
+// duplicate names. This should technically be tracked per Document because you could
+// have two different documents that can have separate namespaces, but to keep things
+// simple we just use a global Map. Technically it should also include any manually
+// assigned view-transition-name outside React too.
+const mountedNamedViewTransitions: Map<string, Fiber> = __DEV__
+  ? new Map()
+  : (null: any);
+const didWarnAboutName: {[string]: boolean} = __DEV__ ? {} : (null: any);
+
+export function trackNamedViewTransition(fiber: Fiber): void {
+  if (__DEV__) {
+    const name = (fiber.memoizedProps: ViewTransitionProps).name;
+    if (name != null && name !== 'auto') {
+      const existing = mountedNamedViewTransitions.get(name);
+      if (existing !== undefined) {
+        if (existing !== fiber && existing !== fiber.alternate) {
+          if (!didWarnAboutName[name]) {
+            didWarnAboutName[name] = true;
+            const stringifiedName = JSON.stringify(name);
+            runWithFiberInDEV(fiber, () => {
+              console.error(
+                'There are two <ViewTransition name=%s> components with the same name mounted ' +
+                  'at the same time. This is not supported and will cause View Transitions ' +
+                  'to error. Try to use a more unique name e.g. by using a namespace prefix ' +
+                  'and adding the id of an item to the name.',
+                stringifiedName,
+              );
+            });
+            runWithFiberInDEV(existing, () => {
+              console.error(
+                'The existing <ViewTransition name=%s> duplicate has this stack trace.',
+                stringifiedName,
+              );
+            });
+          }
+        }
+      } else {
+        mountedNamedViewTransitions.set(name, fiber);
+      }
+    }
+  }
+}
+
+export function untrackNamedViewTransition(fiber: Fiber): void {
+  if (__DEV__) {
+    const name = (fiber.memoizedProps: ViewTransitionProps).name;
+    if (name != null && name !== 'auto') {
+      const existing = mountedNamedViewTransitions.get(name);
+      if (
+        existing !== undefined &&
+        (existing === fiber || existing === fiber.alternate)
+      ) {
+        mountedNamedViewTransitions.delete(name);
+      }
+    }
+  }
+}