@samitouri / QOS-React / commits / ca8f91f6f6

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();