Warn for duplicate ViewTransition names (#32752)
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.
Sebastian Markbåge committed
Mar 25, 2025 at 22:03 UTC
8ac25e5201579c65d115d91c211ac719a235d982
2 files changed
+114
packages/react-reconciler/src/ReactFiberCommitWork.js
+43
@@ -109,6 +109,7 @@ import {
109
ForceClientRender,
110
DidCapture,
111
AffectedParentLayout,
112
+ ViewTransitionNamedStatic,
113
} from './ReactFiberFlags';
114
import {
115
commitStartTime,
@@ -254,6 +255,10 @@ import {
255
pushMutationContext,
256
popMutationContext,
257
} from './ReactFiberMutationTracking';
258
+import {
259
+ trackNamedViewTransition,
260
+ untrackNamedViewTransition,
261
+} from './ReactFiberDuplicateViewTransitions';
262
263
// Used during the commit phase to track the state of the Offscreen component stack.
264
// Allows us to avoid traversing the return path to find the nearest Offscreen ancestor.
@@ -738,6 +743,11 @@ function commitLayoutEffectOnFiber(
743
}
744
case ViewTransitionComponent: {
745
if (enableViewTransition) {
746
+ if (__DEV__) {
747
+ if (flags & ViewTransitionNamedStatic) {
748
+ trackNamedViewTransition(finishedWork);
749
+ }
750
+ }
751
recursivelyTraverseLayoutEffects(
752
finishedRoot,
753
finishedWork,
@@ -1551,11 +1561,34 @@ function commitDeletionEffectsOnFiber(
1561
}
1562
break;
1563
}
1564
+ case ViewTransitionComponent: {
1565
+ if (enableViewTransition) {
1566
+ if (__DEV__) {
1567
+ if (deletedFiber.flags & ViewTransitionNamedStatic) {
1568
+ untrackNamedViewTransition(deletedFiber);
1569
+ }
1570
+ }
1571
+ safelyDetachRef(deletedFiber, nearestMountedAncestor);
1572
+ recursivelyTraverseDeletionEffects(
1573
+ finishedRoot,
1574
+ nearestMountedAncestor,
1575
+ deletedFiber,
1576
+ );
1577
+ return;
1578
+ }
1579
+ // Fallthrough
1580
+ }
1581
case Fragment: {
1582
if (enableFragmentRefs) {
1583
if (!offscreenSubtreeWasHidden) {
1584
safelyDetachRef(deletedFiber, nearestMountedAncestor);
1585
}
1586
+ recursivelyTraverseDeletionEffects(
1587
+ finishedRoot,
1588
+ nearestMountedAncestor,
1589
+ deletedFiber,
1590
+ );
1591
+ return;
1592
}
1593
// Fallthrough
1594
}
@@ -2594,6 +2627,11 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
2627
}
2628
case ViewTransitionComponent: {
2629
if (enableViewTransition) {
2630
+ if (__DEV__) {
2631
+ if (finishedWork.flags & ViewTransitionNamedStatic) {
2632
+ untrackNamedViewTransition(finishedWork);
2633
+ }
2634
+ }
2635
safelyDetachRef(finishedWork, finishedWork.return);
2636
}
2637
recursivelyTraverseDisappearLayoutEffects(finishedWork);
@@ -2803,6 +2841,11 @@ export function reappearLayoutEffects(
2841
finishedWork,
2842
includeWorkInProgressEffects,
2843
);
2844
+ if (__DEV__) {
2845
+ if (flags & ViewTransitionNamedStatic) {
2846
+ trackNamedViewTransition(finishedWork);
2847
+ }
2848
+ }
2849
safelyAttachRef(finishedWork, finishedWork.return);
2850
break;
2851
}
packages/react-reconciler/src/ReactFiberDuplicateViewTransitions.js
new
+71
@@ -0,0 +1,71 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import type {Fiber} from './ReactInternalTypes';
11
+import type {ViewTransitionProps} from './ReactFiberViewTransitionComponent';
12
+import {runWithFiberInDEV} from './ReactCurrentFiber';
13
+
14
+// Use in DEV to track mounted named ViewTransitions. This is used to warn for
15
+// duplicate names. This should technically be tracked per Document because you could
16
+// have two different documents that can have separate namespaces, but to keep things
17
+// simple we just use a global Map. Technically it should also include any manually
18
+// assigned view-transition-name outside React too.
19
+const mountedNamedViewTransitions: Map<string, Fiber> = __DEV__
20
+ ? new Map()
21
+ : (null: any);
22
+const didWarnAboutName: {[string]: boolean} = __DEV__ ? {} : (null: any);
23
+
24
+export function trackNamedViewTransition(fiber: Fiber): void {
25
+ if (__DEV__) {
26
+ const name = (fiber.memoizedProps: ViewTransitionProps).name;
27
+ if (name != null && name !== 'auto') {
28
+ const existing = mountedNamedViewTransitions.get(name);
29
+ if (existing !== undefined) {
30
+ if (existing !== fiber && existing !== fiber.alternate) {
31
+ if (!didWarnAboutName[name]) {
32
+ didWarnAboutName[name] = true;
33
+ const stringifiedName = JSON.stringify(name);
34
+ runWithFiberInDEV(fiber, () => {
35
+ console.error(
36
+ 'There are two <ViewTransition name=%s> components with the same name mounted ' +
37
+ 'at the same time. This is not supported and will cause View Transitions ' +
38
+ 'to error. Try to use a more unique name e.g. by using a namespace prefix ' +
39
+ 'and adding the id of an item to the name.',
40
+ stringifiedName,
41
+ );
42
+ });
43
+ runWithFiberInDEV(existing, () => {
44
+ console.error(
45
+ 'The existing <ViewTransition name=%s> duplicate has this stack trace.',
46
+ stringifiedName,
47
+ );
48
+ });
49
+ }
50
+ }
51
+ } else {
52
+ mountedNamedViewTransitions.set(name, fiber);
53
+ }
54
+ }
55
+ }
56
+}
57
+
58
+export function untrackNamedViewTransition(fiber: Fiber): void {
59
+ if (__DEV__) {
60
+ const name = (fiber.memoizedProps: ViewTransitionProps).name;
61
+ if (name != null && name !== 'auto') {
62
+ const existing = mountedNamedViewTransitions.get(name);
63
+ if (
64
+ existing !== undefined &&
65
+ (existing === fiber || existing === fiber.alternate)
66
+ ) {
67
+ mountedNamedViewTransitions.delete(name);
68
+ }
69
+ }
70
+ }
71
+}