Log errors from startViewTransition to onRecoverableError (#32540)
We customize the messages only in DEV to keep it small in prod. We skip some messages that are not really errors but more like information.
Sebastian Markbåge committed
Mar 11, 2025 at 23:02 UTC
ca8f91f6f6b1b31023eee06c1e2a827ee178b68b
5 files changed
+109
-20
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+91
-20
@@ -1576,6 +1576,69 @@ function cancelAllViewTransitionAnimations(scope: Element) {
1576
// either cached the font or preloaded it earlier.
1577
const SUSPENSEY_FONT_TIMEOUT = 500;
1578
1579
+function customizeViewTransitionError(error: Object): mixed {
1580
+ if (typeof error === 'object' && error !== null) {
1581
+ switch (error.name) {
1582
+ case 'TimeoutError': {
1583
+ // We assume that the only reason a Timeout can happen is because the Navigation
1584
+ // promise. We expect any other work to either be fast or have a timeout (fonts).
1585
+ if (__DEV__) {
1586
+ // eslint-disable-next-line react-internal/prod-error-codes
1587
+ return new Error(
1588
+ 'A ViewTransition timed out because a Navigation stalled. ' +
1589
+ 'This can happen if a Navigation is blocked on React itself. ' +
1590
+ "Such as if it's resolved inside useEffect. " +
1591
+ 'This can be solved by moving the resolution to useLayoutEffect.',
1592
+ {cause: error},
1593
+ );
1594
+ }
1595
+ break;
1596
+ }
1597
+ case 'AbortError': {
1598
+ if (__DEV__) {
1599
+ // eslint-disable-next-line react-internal/prod-error-codes
1600
+ return new Error(
1601
+ 'A ViewTransition was aborted early. This might be because you have ' +
1602
+ 'other View Transition libraries on the page and only one can run at ' +
1603
+ "a time. To avoid this, use only React's built-in <ViewTransition> " +
1604
+ 'to coordinate.',
1605
+ {cause: error},
1606
+ );
1607
+ }
1608
+ break;
1609
+ }
1610
+ case 'InvalidStateError': {
1611
+ if (
1612
+ error.message ===
1613
+ 'View transition was skipped because document visibility state is hidden.' ||
1614
+ error.message ===
1615
+ 'Skipping view transition because document visibility state has become hidden.' ||
1616
+ error.message ===
1617
+ 'Skipping view transition because viewport size changed.'
1618
+ ) {
1619
+ // Skip logging this. This is not considered an error.
1620
+ return null;
1621
+ }
1622
+ if (__DEV__) {
1623
+ if (
1624
+ error.message === 'Transition was aborted because of invalid state'
1625
+ ) {
1626
+ // Chrome doesn't include the reason in the message but logs it in the console..
1627
+ // Redirect the user to look there.
1628
+ // eslint-disable-next-line react-internal/prod-error-codes
1629
+ return new Error(
1630
+ 'A ViewTransition could not start. See the console for more details.',
1631
+ {cause: error},
1632
+ );
1633
+ }
1634
+ }
1635
+ break;
1636
+ }
1637
+ }
1638
+ }
1639
+ return error;
1640
+}
1641
+
1642
export function startViewTransition(
1643
rootContainer: Container,
1644
transitionTypes: null | TransitionTypes,
@@ -1584,6 +1647,7 @@ export function startViewTransition(
1647
afterMutationCallback: () => void,
1648
spawnedWorkCallback: () => void,
1649
passiveCallback: () => mixed,
1650
+ errorCallback: mixed => void,
1651
): boolean {
1652
const ownerDocument: Document =
1653
rootContainer.nodeType === DOCUMENT_NODE
@@ -1641,24 +1705,19 @@ export function startViewTransition(
1705
});
1706
// $FlowFixMe[prop-missing]
1707
ownerDocument.__reactViewTransition = transition;
1644
- if (__DEV__) {
1645
- transition.ready.then(undefined, (reason: mixed) => {
1646
- if (
1647
- typeof reason === 'object' &&
1648
- reason !== null &&
1649
- reason.name === 'TimeoutError'
1650
- ) {
1651
- console.error(
1652
- 'A ViewTransition timed out because a Navigation stalled. ' +
1653
- 'This can happen if a Navigation is blocked on React itself. ' +
1654
- "Such as if it's resolved inside useEffect. " +
1655
- 'This can be solved by moving the resolution to useLayoutEffect.',
1656
- );
1708
+ const handleError = (error: mixed) => {
1709
+ try {
1710
+ error = customizeViewTransitionError(error);
1711
+ if (error !== null) {
1712
+ errorCallback(error);
1713
}
1658
- });
1659
- }
1660
- transition.ready.then(spawnedWorkCallback, spawnedWorkCallback);
1661
- transition.finished.then(() => {
1714
+ } finally {
1715
+ // Continue the reset of the work.
1716
+ spawnedWorkCallback();
1717
+ }
1718
+ };
1719
+ transition.ready.then(spawnedWorkCallback, handleError);
1720
+ transition.finished.finally(() => {
1721
cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
1722
// $FlowFixMe[prop-missing]
1723
if (ownerDocument.__reactViewTransition === transition) {
@@ -1802,6 +1861,7 @@ export function startGestureTransition(
1861
transitionTypes: null | TransitionTypes,
1862
mutationCallback: () => void,
1863
animateCallback: () => void,
1864
+ errorCallback: mixed => void,
1865
): null | RunningGestureTransition {
1866
const ownerDocument: Document =
1867
rootContainer.nodeType === DOCUMENT_NODE
@@ -1815,7 +1875,7 @@ export function startGestureTransition(
1875
});
1876
// $FlowFixMe[prop-missing]
1877
ownerDocument.__reactViewTransition = transition;
1818
- const readyCallback = (x: any) => {
1878
+ const readyCallback = () => {
1879
const documentElement: Element = (ownerDocument.documentElement: any);
1880
// Loop through all View Transition Animations.
1881
const animations = documentElement.getAnimations({subtree: true});
@@ -1935,8 +1995,19 @@ export function startGestureTransition(
1995
navigator.userAgent.indexOf('Chrome') !== -1
1996
? () => requestAnimationFrame(readyCallback)
1997
: readyCallback;
1938
- transition.ready.then(readyForAnimations, readyCallback);
1939
- transition.finished.then(() => {
1998
+ const handleError = (error: mixed) => {
1999
+ try {
2000
+ error = customizeViewTransitionError(error);
2001
+ if (error !== null) {
2002
+ errorCallback(error);
2003
+ }
2004
+ } finally {
2005
+ // Continue the reset of the work.
2006
+ readyCallback();
2007
+ }
2008
+ };
2009
+ transition.ready.then(readyForAnimations, handleError);
2010
+ transition.finished.finally(() => {
2011
cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
2012
// $FlowFixMe[prop-missing]
2013
if (ownerDocument.__reactViewTransition === transition) {
packages/react-native-renderer/src/ReactFiberConfigNative.js
+2
@@ -619,6 +619,7 @@ export function startViewTransition(
619
afterMutationCallback: () => void,
620
spawnedWorkCallback: () => void,
621
passiveCallback: () => mixed,
622
+ errorCallback: mixed => void,
623
): boolean {
624
return false;
625
}
@@ -633,6 +634,7 @@ export function startGestureTransition(
634
transitionTypes: null | TransitionTypes,
635
mutationCallback: () => void,
636
animateCallback: () => void,
637
+ errorCallback: mixed => void,
638
): RunningGestureTransition {
639
mutationCallback();
640
animateCallback();
packages/react-noop-renderer/src/createReactNoop.js
+2
@@ -809,6 +809,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
809
afterMutationCallback: () => void,
810
layoutCallback: () => void,
811
passiveCallback: () => mixed,
812
+ errorCallback: mixed => void,
813
): boolean {
814
return false;
815
},
@@ -821,6 +822,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
822
transitionTypes: null | TransitionTypes,
823
mutationCallback: () => void,
824
animateCallback: () => void,
825
+ errorCallback: mixed => void,
826
): RunningGestureTransition {
827
mutationCallback();
828
animateCallback();
packages/react-reconciler/src/ReactFiberWorkLoop.js
+12
@@ -3511,6 +3511,7 @@ function commitRoot(
3511
flushAfterMutationEffects,
3512
flushSpawnedWork,
3513
flushPassiveEffects,
3514
+ reportViewTransitionError,
3515
);
3516
if (!startedViewTransition) {
3517
// Flush synchronously.
@@ -3521,6 +3522,16 @@ function commitRoot(
3522
}
3523
}
3524
3525
+function reportViewTransitionError(error: mixed) {
3526
+ // Report errors that happens while preparing a View Transition.
3527
+ if (pendingEffectsStatus === NO_PENDING_EFFECTS) {
3528
+ return;
3529
+ }
3530
+ const root = pendingEffectsRoot;
3531
+ const onRecoverableError = root.onRecoverableError;
3532
+ onRecoverableError(error, makeErrorInfo(null));
3533
+}
3534
+
3535
function flushAfterMutationEffects(): void {
3536
if (pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE) {
3537
return;
@@ -3911,6 +3922,7 @@ function commitGestureOnRoot(
3922
pendingTransitionTypes,
3923
flushGestureMutations,
3924
flushGestureAnimations,
3925
+ reportViewTransitionError,
3926
);
3927
}
3928
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+2
@@ -417,6 +417,7 @@ export function startViewTransition(
417
afterMutationCallback: () => void,
418
spawnedWorkCallback: () => void,
419
passiveCallback: () => mixed,
420
+ errorCallback: mixed => void,
421
): boolean {
422
return false;
423
}
@@ -431,6 +432,7 @@ export function startGestureTransition(
432
transitionTypes: null | TransitionTypes,
433
mutationCallback: () => void,
434
animateCallback: () => void,
435
+ errorCallback: mixed => void,
436
): RunningGestureTransition {
437
mutationCallback();
438
animateCallback();