@samitouri / QOS-React / commits / 8ac25e5201

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 +}