/** * 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, FiberRoot} from './ReactInternalTypes'; import type {Lane, Lanes} from './ReactFiberLane'; import type {CapturedValue} from './ReactCapturedValue'; import type {Update} from './ReactFiberClassUpdateQueue'; import type {Wakeable} from 'shared/ReactTypes'; import type {OffscreenQueue} from './ReactFiberActivityComponent'; import type {RetryQueue} from './ReactFiberSuspenseComponent'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import { ClassComponent, HostRoot, IncompleteClassComponent, IncompleteFunctionComponent, FunctionComponent, ForwardRef, SimpleMemoComponent, SuspenseComponent, OffscreenComponent, } from './ReactWorkTags'; import { DidCapture, Incomplete, NoFlags, ShouldCapture, LifecycleEffectMask, ForceUpdateForLegacySuspense, ForceClientRender, ScheduleRetry, } from './ReactFiberFlags'; import {NoMode, ConcurrentMode} from './ReactTypeOfMode'; import { enableUpdaterTracking, enablePostpone, disableLegacyMode, } from 'shared/ReactFeatureFlags'; import {createCapturedValueAtFiber} from './ReactCapturedValue'; import { enqueueCapturedUpdate, createUpdate, CaptureUpdate, ForceUpdate, enqueueUpdate, } from './ReactFiberClassUpdateQueue'; import {markFailedErrorBoundaryForHotReloading} from './ReactFiberHotReloading'; import { getShellBoundary, getSuspenseHandler, } from './ReactFiberSuspenseContext'; import { renderDidError, queueConcurrentError, renderDidSuspendDelayIfPossible, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, attachPingListener, restorePendingUpdaters, renderDidSuspend, } from './ReactFiberWorkLoop'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext'; import {logUncaughtError, logCaughtError} from './ReactFiberErrorLogger'; import {isDevToolsPresent} from './ReactFiberDevToolsHook'; import { SyncLane, includesSomeLane, mergeLanes, pickArbitraryLane, } from './ReactFiberLane'; import { getIsHydrating, markDidThrowWhileHydratingDEV, queueHydrationError, HydrationMismatchException, } from './ReactFiberHydrationContext'; import {ConcurrentRoot} from './ReactRootTags'; import {noopSuspenseyCommitThenable} from './ReactFiberThenable'; import {REACT_POSTPONE_TYPE} from 'shared/ReactSymbols'; import {runWithFiberInDEV} from './ReactCurrentFiber'; import {callComponentDidCatchInDEV} from './ReactFiberCallUserSpace'; function createRootErrorUpdate( root: FiberRoot, errorInfo: CapturedValue, lane: Lane, ): Update { const update = createUpdate(lane); // Unmount the root by rendering null. update.tag = CaptureUpdate; // Caution: React DevTools currently depends on this property // being called "element". update.payload = {element: null}; update.callback = () => { if (__DEV__) { runWithFiberInDEV(errorInfo.source, logUncaughtError, root, errorInfo); } else { logUncaughtError(root, errorInfo); } }; return update; } function createClassErrorUpdate(lane: Lane): Update { const update = createUpdate(lane); update.tag = CaptureUpdate; return update; } function initializeClassErrorUpdate( update: Update, root: FiberRoot, fiber: Fiber, errorInfo: CapturedValue, ): void { const getDerivedStateFromError = fiber.type.getDerivedStateFromError; if (typeof getDerivedStateFromError === 'function') { const error = errorInfo.value; update.payload = () => { return getDerivedStateFromError(error); }; update.callback = () => { if (__DEV__) { markFailedErrorBoundaryForHotReloading(fiber); } if (__DEV__) { runWithFiberInDEV( errorInfo.source, logCaughtError, root, fiber, errorInfo, ); } else { logCaughtError(root, fiber, errorInfo); } }; } const inst = fiber.stateNode; if (inst !== null && typeof inst.componentDidCatch === 'function') { // $FlowFixMe[missing-this-annot] update.callback = function callback() { if (__DEV__) { markFailedErrorBoundaryForHotReloading(fiber); } if (__DEV__) { runWithFiberInDEV( errorInfo.source, logCaughtError, root, fiber, errorInfo, ); } else { logCaughtError(root, fiber, errorInfo); } if (typeof getDerivedStateFromError !== 'function') { // To preserve the preexisting retry behavior of error boundaries, // we keep track of which ones already failed during this batch. // This gets reset before we yield back to the browser. // TODO: Warn in strict mode if getDerivedStateFromError is // not defined. markLegacyErrorBoundaryAsFailed(this); } if (__DEV__) { callComponentDidCatchInDEV(this, errorInfo); } else { const error = errorInfo.value; const stack = errorInfo.stack; this.componentDidCatch(error, { componentStack: stack !== null ? stack : '', }); } if (__DEV__) { if (typeof getDerivedStateFromError !== 'function') { // If componentDidCatch is the only error boundary method defined, // then it needs to call setState to recover from errors. // If no state update is scheduled then the boundary will swallow the error. if (!includesSomeLane(fiber.lanes, (SyncLane: Lane))) { console.error( '%s: Error boundaries should implement getDerivedStateFromError(). ' + 'In that method, return a state update to display an error message or fallback UI.', getComponentNameFromFiber(fiber) || 'Unknown', ); } } } }; } } function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { const currentSourceFiber = sourceFiber.alternate; if (currentSourceFiber !== null) { // Since we never visited the children of the suspended component, we // need to propagate the context change now, to ensure that we visit // them during the retry. // // We don't have to do this for errors because we retry errors without // committing in between. So this is specific to Suspense. propagateParentContextChangesToDeferredTree( currentSourceFiber, sourceFiber, rootRenderLanes, ); } // Reset the memoizedState to what it was before we attempted to render it. // A legacy mode Suspense quirk, only relevant to hook components. const tag = sourceFiber.tag; if ( !disableLegacyMode && (sourceFiber.mode & ConcurrentMode) === NoMode && (tag === FunctionComponent || tag === ForwardRef || tag === SimpleMemoComponent) ) { const currentSource = sourceFiber.alternate; if (currentSource) { sourceFiber.updateQueue = currentSource.updateQueue; sourceFiber.memoizedState = currentSource.memoizedState; sourceFiber.lanes = currentSource.lanes; } else { sourceFiber.updateQueue = null; sourceFiber.memoizedState = null; } } } function markSuspenseBoundaryShouldCapture( suspenseBoundary: Fiber, returnFiber: Fiber | null, sourceFiber: Fiber, root: FiberRoot, rootRenderLanes: Lanes, ): Fiber | null { // This marks a Suspense boundary so that when we're unwinding the stack, // it captures the suspended "exception" and does a second (fallback) pass. if ( !disableLegacyMode && (suspenseBoundary.mode & ConcurrentMode) === NoMode ) { // Legacy Mode Suspense // // If the boundary is in legacy mode, we should *not* // suspend the commit. Pretend as if the suspended component rendered // null and keep rendering. When the Suspense boundary completes, // we'll do a second pass to render the fallback. if (suspenseBoundary === returnFiber) { // Special case where we suspended while reconciling the children of // a Suspense boundary's inner Offscreen wrapper fiber. This happens // when a React.lazy component is a direct child of a // Suspense boundary. // // Suspense boundaries are implemented as multiple fibers, but they // are a single conceptual unit. The legacy mode behavior where we // pretend the suspended fiber committed as `null` won't work, // because in this case the "suspended" fiber is the inner // Offscreen wrapper. // // Because the contents of the boundary haven't started rendering // yet (i.e. nothing in the tree has partially rendered) we can // switch to the regular, concurrent mode behavior: mark the // boundary with ShouldCapture and enter the unwind phase. suspenseBoundary.flags |= ShouldCapture; } else { suspenseBoundary.flags |= DidCapture; sourceFiber.flags |= ForceUpdateForLegacySuspense; // We're going to commit this fiber even though it didn't complete. // But we shouldn't call any lifecycle methods or callbacks. Remove // all lifecycle effect tags. sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete); if (sourceFiber.tag === ClassComponent) { const currentSourceFiber = sourceFiber.alternate; if (currentSourceFiber === null) { // This is a new mount. Change the tag so it's not mistaken for a // completed class component. For example, we should not call // componentWillUnmount if it is deleted. sourceFiber.tag = IncompleteClassComponent; } else { // When we try rendering again, we should not reuse the current fiber, // since it's known to be in an inconsistent state. Use a force update to // prevent a bail out. const update = createUpdate(SyncLane); update.tag = ForceUpdate; enqueueUpdate(sourceFiber, update, SyncLane); } } else if (sourceFiber.tag === FunctionComponent) { const currentSourceFiber = sourceFiber.alternate; if (currentSourceFiber === null) { // This is a new mount. Change the tag so it's not mistaken for a // completed function component. sourceFiber.tag = IncompleteFunctionComponent; } } // The source fiber did not complete. Mark it with Sync priority to // indicate that it still has pending work. sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane); } return suspenseBoundary; } // Confirmed that the boundary is in a concurrent mode tree. Continue // with the normal suspend path. // // After this we'll use a set of heuristics to determine whether this // render pass will run to completion or restart or "suspend" the commit. // The actual logic for this is spread out in different places. // // This first principle is that if we're going to suspend when we complete // a root, then we should also restart if we get an update or ping that // might unsuspend it, and vice versa. The only reason to suspend is // because you think you might want to restart before committing. However, // it doesn't make sense to restart only while in the period we're suspended. // // Restarting too aggressively is also not good because it starves out any // intermediate loading state. So we use heuristics to determine when. // Suspense Heuristics // // If nothing threw a Promise or all the same fallbacks are already showing, // then don't suspend/restart. // // If this is an initial render of a new tree of Suspense boundaries and // those trigger a fallback, then don't suspend/restart. We want to ensure // that we can show the initial loading state as quickly as possible. // // If we hit a "Delayed" case, such as when we'd switch from content back into // a fallback, then we should always suspend/restart. Transitions apply // to this case. If none is defined, JND is used instead. // // If we're already showing a fallback and it gets "retried", allowing us to show // another level, but there's still an inner boundary that would show a fallback, // then we suspend/restart for 500ms since the last time we showed a fallback // anywhere in the tree. This effectively throttles progressive loading into a // consistent train of commits. This also gives us an opportunity to restart to // get to the completed state slightly earlier. // // If there's ambiguity due to batching it's resolved in preference of: // 1) "delayed", 2) "initial render", 3) "retry". // // We want to ensure that a "busy" state doesn't get force committed. We want to // ensure that new initial loading states can commit as soon as possible. suspenseBoundary.flags |= ShouldCapture; // TODO: I think we can remove this, since we now use `DidCapture` in // the begin phase to prevent an early bailout. suspenseBoundary.lanes = rootRenderLanes; return suspenseBoundary; } function throwException( root: FiberRoot, returnFiber: Fiber | null, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, ): boolean { // The source fiber did not complete. sourceFiber.flags |= Incomplete; if (enableUpdaterTracking) { if (isDevToolsPresent) { // If we have pending work still, restore the original updaters restorePendingUpdaters(root, rootRenderLanes); } } if (value !== null && typeof value === 'object') { if (enablePostpone && value.$$typeof === REACT_POSTPONE_TYPE) { // Act as if this is an infinitely suspending promise. value = {then: function () {}}; } if (typeof value.then === 'function') { // This is a wakeable. The component suspended. const wakeable: Wakeable = (value: any); resetSuspendedComponent(sourceFiber, rootRenderLanes); if (__DEV__) { if ( getIsHydrating() && (disableLegacyMode || sourceFiber.mode & ConcurrentMode) ) { markDidThrowWhileHydratingDEV(); } } // Mark the nearest Suspense boundary to switch to rendering a fallback. const suspenseBoundary = getSuspenseHandler(); if (suspenseBoundary !== null) { switch (suspenseBoundary.tag) { case SuspenseComponent: { // If this suspense boundary is not already showing a fallback, mark // the in-progress render as suspended. We try to perform this logic // as soon as soon as possible during the render phase, so the work // loop can know things like whether it's OK to switch to other tasks, // or whether it can wait for data to resolve before continuing. // TODO: Most of these checks are already performed when entering a // Suspense boundary. We should track the information on the stack so // we don't have to recompute it on demand. This would also allow us // to unify with `use` which needs to perform this logic even sooner, // before `throwException` is called. if (disableLegacyMode || sourceFiber.mode & ConcurrentMode) { if (getShellBoundary() === null) { // Suspended in the "shell" of the app. This is an undesirable // loading state. We should avoid committing this tree. renderDidSuspendDelayIfPossible(); } else { // If we suspended deeper than the shell, we don't need to delay // the commmit. However, we still call renderDidSuspend if this is // a new boundary, to tell the work loop that a new fallback has // appeared during this render. // TODO: Theoretically we should be able to delete this branch. // It's currently used for two things: 1) to throttle the // appearance of successive loading states, and 2) in // SuspenseList, to determine whether the children include any // pending fallbacks. For 1, we should apply throttling to all // retries, not just ones that render an additional fallback. For // 2, we should check subtreeFlags instead. Then we can delete // this branch. const current = suspenseBoundary.alternate; if (current === null) { renderDidSuspend(); } } } suspenseBoundary.flags &= ~ForceClientRender; markSuspenseBoundaryShouldCapture( suspenseBoundary, returnFiber, sourceFiber, root, rootRenderLanes, ); // Retry listener // // If the fallback does commit, we need to attach a different type of // listener. This one schedules an update on the Suspense boundary to // turn the fallback state off. // // Stash the wakeable on the boundary fiber so we can access it in the // commit phase. // // When the wakeable resolves, we'll attempt to render the boundary // again ("retry"). // Check if this is a Suspensey resource. We do not attach retry // listeners to these, because we don't actually need them for // rendering. Only for committing. Instead, if a fallback commits // and the only thing that suspended was a Suspensey resource, we // retry immediately. // TODO: Refactor throwException so that we don't have to do this type // check. The caller already knows what the cause was. const isSuspenseyResource = wakeable === noopSuspenseyCommitThenable; if (isSuspenseyResource) { suspenseBoundary.flags |= ScheduleRetry; } else { const retryQueue: RetryQueue | null = (suspenseBoundary.updateQueue: any); if (retryQueue === null) { suspenseBoundary.updateQueue = new Set([wakeable]); } else { retryQueue.add(wakeable); } // We only attach ping listeners in concurrent mode. Legacy // Suspense always commits fallbacks synchronously, so there are // no pings. if (disableLegacyMode || suspenseBoundary.mode & ConcurrentMode) { attachPingListener(root, wakeable, rootRenderLanes); } } return false; } case OffscreenComponent: { if (disableLegacyMode || suspenseBoundary.mode & ConcurrentMode) { suspenseBoundary.flags |= ShouldCapture; const isSuspenseyResource = wakeable === noopSuspenseyCommitThenable; if (isSuspenseyResource) { suspenseBoundary.flags |= ScheduleRetry; } else { const offscreenQueue: OffscreenQueue | null = (suspenseBoundary.updateQueue: any); if (offscreenQueue === null) { const newOffscreenQueue: OffscreenQueue = { transitions: null, markerInstances: null, retryQueue: new Set([wakeable]), }; suspenseBoundary.updateQueue = newOffscreenQueue; } else { const retryQueue = offscreenQueue.retryQueue; if (retryQueue === null) { offscreenQueue.retryQueue = new Set([wakeable]); } else { retryQueue.add(wakeable); } } attachPingListener(root, wakeable, rootRenderLanes); } return false; } } } throw new Error( `Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` + 'is a bug in React.', ); } else { // No boundary was found. Unless this is a sync update, this is OK. // We can suspend and wait for more data to arrive. if (disableLegacyMode || root.tag === ConcurrentRoot) { // In a concurrent root, suspending without a Suspense boundary is // allowed. It will suspend indefinitely without committing. // // TODO: Should we have different behavior for discrete updates? What // about flushSync? Maybe it should put the tree into an inert state, // and potentially log a warning. Revisit this for a future release. attachPingListener(root, wakeable, rootRenderLanes); renderDidSuspendDelayIfPossible(); return false; } else { // In a legacy root, suspending without a boundary is always an error. const uncaughtSuspenseError = new Error( 'A component suspended while responding to synchronous input. This ' + 'will cause the UI to be replaced with a loading indicator. To ' + 'fix, updates that suspend should be wrapped ' + 'with startTransition.', ); value = uncaughtSuspenseError; } } } } // This is a regular error, not a Suspense wakeable. if ( getIsHydrating() && (disableLegacyMode || sourceFiber.mode & ConcurrentMode) ) { markDidThrowWhileHydratingDEV(); const suspenseBoundary = getSuspenseHandler(); // If the error was thrown during hydration, we may be able to recover by // discarding the dehydrated content and switching to a client render. // Instead of surfacing the error, find the nearest Suspense boundary // and render it again without hydration. if (suspenseBoundary !== null) { if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) { // Set a flag to indicate that we should try rendering the normal // children again, not the fallback. suspenseBoundary.flags |= ForceClientRender; } markSuspenseBoundaryShouldCapture( suspenseBoundary, returnFiber, sourceFiber, root, rootRenderLanes, ); // Even though the user may not be affected by this error, we should // still log it so it can be fixed. if (value !== HydrationMismatchException) { const wrapperError = new Error( 'There was an error while hydrating but React was able to recover by ' + 'instead client rendering from the nearest Suspense boundary.', {cause: value}, ); queueHydrationError( createCapturedValueAtFiber(wrapperError, sourceFiber), ); } return false; } else { if (value !== HydrationMismatchException) { const wrapperError = new Error( 'There was an error while hydrating but React was able to recover by ' + 'instead client rendering the entire root.', {cause: value}, ); queueHydrationError( createCapturedValueAtFiber(wrapperError, sourceFiber), ); } const workInProgress: Fiber = (root.current: any).alternate; // Schedule an update at the root to log the error but this shouldn't // actually happen because we should recover. workInProgress.flags |= ShouldCapture; const lane = pickArbitraryLane(rootRenderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); const rootErrorInfo = createCapturedValueAtFiber(value, sourceFiber); const update = createRootErrorUpdate( workInProgress.stateNode, rootErrorInfo, // This should never actually get logged due to the recovery. lane, ); enqueueCapturedUpdate(workInProgress, update); renderDidError(); return false; } } else { // Otherwise, fall through to the error path. } const wrapperError = new Error( 'There was an error during concurrent rendering but React was able to recover by ' + 'instead synchronously rendering the entire root.', {cause: value}, ); queueConcurrentError(createCapturedValueAtFiber(wrapperError, sourceFiber)); renderDidError(); // We didn't find a boundary that could handle this type of exception. Start // over and traverse parent path again, this time treating the exception // as an error. if (returnFiber === null) { // There's no return fiber, which means the root errored. This should never // happen. Return `true` to trigger a fatal error (panic). return true; } const errorInfo = createCapturedValueAtFiber(value, sourceFiber); let workInProgress: Fiber = returnFiber; do { switch (workInProgress.tag) { case HostRoot: { workInProgress.flags |= ShouldCapture; const lane = pickArbitraryLane(rootRenderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); const update = createRootErrorUpdate( workInProgress.stateNode, errorInfo, lane, ); enqueueCapturedUpdate(workInProgress, update); return false; } case ClassComponent: // Capture and retry const ctor = workInProgress.type; const instance = workInProgress.stateNode; if ( (workInProgress.flags & DidCapture) === NoFlags && (typeof ctor.getDerivedStateFromError === 'function' || (instance !== null && typeof instance.componentDidCatch === 'function' && !isAlreadyFailedLegacyErrorBoundary(instance))) ) { workInProgress.flags |= ShouldCapture; const lane = pickArbitraryLane(rootRenderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); // Schedule the error boundary to re-render using updated state const update = createClassErrorUpdate(lane); initializeClassErrorUpdate(update, root, workInProgress, errorInfo); enqueueCapturedUpdate(workInProgress, update); return false; } break; default: break; } // $FlowFixMe[incompatible-type] we bail out when we get a null workInProgress = workInProgress.return; } while (workInProgress !== null); return false; } export { throwException, createRootErrorUpdate, createClassErrorUpdate, initializeClassErrorUpdate, };