diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index abbbdc1578753..8c282e1bb730e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1677,25 +1677,19 @@ describe('ReactDOMFizzServer', () => { // @gate experimental it('calls getServerSnapshot instead of getSnapshot', async () => { - const ref = React.createRef(); - function getServerSnapshot() { return 'server'; } - function getClientSnapshot() { return 'client'; } - function subscribe() { return () => {}; } - function Child({text}) { Scheduler.unstable_yieldValue(text); return text; } - function App() { const value = useSyncExternalStore( subscribe, @@ -1703,19 +1697,17 @@ describe('ReactDOMFizzServer', () => { getServerSnapshot, ); return ( -
+
); } - const loggedErrors = []; await act(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream( , - { onError(x) { loggedErrors.push(x); @@ -1726,56 +1718,43 @@ describe('ReactDOMFizzServer', () => { }); expect(Scheduler).toHaveYielded(['server']); - const serverRenderedDiv = container.getElementsByTagName('div')[0]; - ReactDOM.hydrateRoot(container, ); - // The first paint uses the server snapshot - expect(Scheduler).toFlushUntilNextPaint(['server']); - expect(getVisibleChildren(container)).toEqual(
server
); - // Hydration succeeded - expect(ref.current).toEqual(serverRenderedDiv); - - // Asynchronously we detect that the store has changed on the client, - // and patch up the inconsistency - expect(Scheduler).toFlushUntilNextPaint(['client']); + expect(() => { + // The first paint switches to client rendering due to mismatch + expect(Scheduler).toFlushUntilNextPaint(['client']); + }).toErrorDev( + 'Warning: An error occurred during hydration. The server HTML was replaced with client content', + {withoutStack: true}, + ); expect(getVisibleChildren(container)).toEqual(
client
); - expect(ref.current).toEqual(serverRenderedDiv); }); // The selector implementation uses the lazy ref initialization pattern - // @gate !(enableUseRefAccessWarning && __DEV__) // @gate experimental it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => { // Same as previous test, but with a selector that returns a complex object // that is memoized with a custom `isEqual` function. const ref = React.createRef(); - function getServerSnapshot() { return {env: 'server', other: 'unrelated'}; } - function getClientSnapshot() { return {env: 'client', other: 'unrelated'}; } - function selector({env}) { return {env}; } - function isEqual(a, b) { return a.env === b.env; } - function subscribe() { return () => {}; } - function Child({text}) { Scheduler.unstable_yieldValue(text); return text; } - function App() { const {env} = useSyncExternalStoreWithSelector( subscribe, @@ -1790,14 +1769,12 @@ describe('ReactDOMFizzServer', () => {
); } - const loggedErrors = []; await act(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream( , - { onError(x) { loggedErrors.push(x); @@ -1808,21 +1785,17 @@ describe('ReactDOMFizzServer', () => { }); expect(Scheduler).toHaveYielded(['server']); - const serverRenderedDiv = container.getElementsByTagName('div')[0]; - ReactDOM.hydrateRoot(container, ); - // The first paint uses the server snapshot - expect(Scheduler).toFlushUntilNextPaint(['server']); - expect(getVisibleChildren(container)).toEqual(
server
); - // Hydration succeeded - expect(ref.current).toEqual(serverRenderedDiv); - - // Asynchronously we detect that the store has changed on the client, - // and patch up the inconsistency - expect(Scheduler).toFlushUntilNextPaint(['client']); + // The first paint uses the client due to mismatch forcing client render + expect(() => { + // The first paint switches to client rendering due to mismatch + expect(Scheduler).toFlushUntilNextPaint(['client']); + }).toErrorDev( + 'Warning: An error occurred during hydration. The server HTML was replaced with client content', + {withoutStack: true}, + ); expect(getVisibleChildren(container)).toEqual(
client
); - expect(ref.current).toEqual(serverRenderedDiv); }); // @gate experimental diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 505a100df3aa6..9aab3bf94536f 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -197,12 +197,7 @@ describe('ReactDOMServerPartialHydration', () => { // hydrating anyway. suspend = true; ReactDOM.hydrateRoot(container, ); - expect(() => { - Scheduler.unstable_flushAll(); - }).toErrorDev( - // TODO: This error should not be logged in this case. It's a false positive. - 'Did not expect server HTML to contain the text node "Hello" in
.', - ); + Scheduler.unstable_flushAll(); jest.runAllTimers(); // Expect the server-generated HTML to stay intact. @@ -218,6 +213,101 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('HelloHello'); }); + it('falls back to client rendering boundary on mismatch', async () => { + let client = false; + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => { + resolve = () => { + suspend = false; + resolvePromise(); + }; + }); + function Child() { + if (suspend) { + Scheduler.unstable_yieldValue('Suspend'); + throw promise; + } else { + Scheduler.unstable_yieldValue('Hello'); + return 'Hello'; + } + } + function Component({shouldMismatch}) { + Scheduler.unstable_yieldValue('Component'); + if (shouldMismatch && client) { + return
Mismatch
; + } + return
Component
; + } + function App() { + return ( + + + + + + + + ); + } + const finalHTML = ReactDOMServer.renderToString(); + const container = document.createElement('div'); + container.innerHTML = finalHTML; + expect(Scheduler).toHaveYielded([ + 'Hello', + 'Component', + 'Component', + 'Component', + 'Component', + ]); + + expect(container.innerHTML).toBe( + 'Hello
Component
Component
Component
Component
', + ); + + suspend = true; + client = true; + + ReactDOM.hydrateRoot(container, ); + expect(Scheduler).toFlushAndYield([ + 'Suspend', + 'Component', + 'Component', + 'Component', + 'Component', + ]); + jest.runAllTimers(); + + // Unchanged + expect(container.innerHTML).toBe( + 'Hello
Component
Component
Component
Component
', + ); + + suspend = false; + resolve(); + await promise; + + expect(Scheduler).toFlushAndYield([ + // first pass, mismatches at end + 'Hello', + 'Component', + 'Component', + 'Component', + 'Component', + // second pass as client render + 'Hello', + 'Component', + 'Component', + 'Component', + 'Component', + ]); + + // Client rendered - suspense comment nodes removed + expect(container.innerHTML).toBe( + 'Hello
Component
Component
Component
Mismatch
', + ); + }); + it('calls the hydration callbacks after hydration or deletion', async () => { let suspend = false; let resolve; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index eabc5e43116bb..5e915579a7c0d 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -8,6 +8,7 @@ */ import type {Fiber} from './ReactInternalTypes'; +import {NoMode, ConcurrentMode} from './ReactTypeOfMode'; import type { Instance, TextInstance, @@ -323,12 +324,21 @@ function tryHydrate(fiber, nextInstance) { } } +function throwOnHydrationMismatchIfConcurrentMode(fiber) { + if ((fiber.mode & ConcurrentMode) !== NoMode) { + throw new Error( + 'An error occurred during hydration. The server HTML was replaced with client content', + ); + } +} + function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } let nextInstance = nextHydratableInstance; if (!nextInstance) { + throwOnHydrationMismatchIfConcurrentMode(fiber); // Nothing to hydrate. Make it an insertion. insertNonHydratedInstance((hydrationParentFiber: any), fiber); isHydrating = false; @@ -337,6 +347,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { } const firstAttemptedInstance = nextInstance; if (!tryHydrate(fiber, nextInstance)) { + throwOnHydrationMismatchIfConcurrentMode(fiber); // If we can't hydrate this instance let's try the next one. // We use this as a heuristic. It's based on intuition and not data so it // might be flawed or unnecessary. diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 48e60581e0f28..e7e08d5f3b8bb 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -8,6 +8,7 @@ */ import type {Fiber} from './ReactInternalTypes'; +import {NoMode, ConcurrentMode} from './ReactTypeOfMode'; import type { Instance, TextInstance, @@ -323,12 +324,21 @@ function tryHydrate(fiber, nextInstance) { } } +function throwOnHydrationMismatchIfConcurrentMode(fiber) { + if ((fiber.mode & ConcurrentMode) !== NoMode) { + throw new Error( + 'An error occurred during hydration. The server HTML was replaced with client content', + ); + } +} + function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } let nextInstance = nextHydratableInstance; if (!nextInstance) { + throwOnHydrationMismatchIfConcurrentMode(fiber); // Nothing to hydrate. Make it an insertion. insertNonHydratedInstance((hydrationParentFiber: any), fiber); isHydrating = false; @@ -337,6 +347,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { } const firstAttemptedInstance = nextInstance; if (!tryHydrate(fiber, nextInstance)) { + throwOnHydrationMismatchIfConcurrentMode(fiber); // If we can't hydrate this instance let's try the next one. // We use this as a heuristic. It's based on intuition and not data so it // might be flawed or unnecessary. diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 85728d96bb730..cd9931687ba5a 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -268,158 +268,157 @@ function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { } } -function markNearestSuspenseBoundaryShouldCapture( - returnFiber: Fiber, - sourceFiber: Fiber, - root: FiberRoot, - rootRenderLanes: Lanes, -): Fiber | null { +function getNearestSuspenseBoundaryToCapture(returnFiber: Fiber) { + let node = returnFiber; const hasInvisibleParentBoundary = hasSuspenseContext( suspenseStackCursor.current, (InvisibleParentSuspenseContext: SuspenseContext), ); - let node = returnFiber; do { if ( node.tag === SuspenseComponent && shouldCaptureSuspense(node, hasInvisibleParentBoundary) ) { - // Found the nearest boundary. - const suspenseBoundary = node; - - // 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 ((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; + return node; + } + // This boundary already captured during this render. Continue to the next + // boundary. + node = node.return; + } while (node !== null); + return null; +} - // 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); +function markSuspenseBoundaryShouldCapture( + suspenseBoundary: Fiber, + returnFiber: Fiber, + 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 ((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; - if (supportsPersistence && enablePersistentOffscreenHostContainer) { - // Another legacy Suspense quirk. In persistent mode, if this is the - // initial mount, override the props of the host container to hide - // its contents. - const currentSuspenseBoundary = suspenseBoundary.alternate; - if (currentSuspenseBoundary === null) { - const offscreenFiber: Fiber = (suspenseBoundary.child: any); - const offscreenContainer = offscreenFiber.child; - if (offscreenContainer !== null) { - const children = offscreenContainer.memoizedProps.children; - const containerProps = getOffscreenContainerProps( - 'hidden', - children, - ); - offscreenContainer.pendingProps = containerProps; - offscreenContainer.memoizedProps = containerProps; - } - } - } + // 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(NoTimestamp, SyncLane); - update.tag = ForceUpdate; - enqueueUpdate(sourceFiber, update, SyncLane); - } + if (supportsPersistence && enablePersistentOffscreenHostContainer) { + // Another legacy Suspense quirk. In persistent mode, if this is the + // initial mount, override the props of the host container to hide + // its contents. + const currentSuspenseBoundary = suspenseBoundary.alternate; + if (currentSuspenseBoundary === null) { + const offscreenFiber: Fiber = (suspenseBoundary.child: any); + const offscreenContainer = offscreenFiber.child; + if (offscreenContainer !== null) { + const children = offscreenContainer.memoizedProps.children; + const containerProps = getOffscreenContainerProps( + 'hidden', + children, + ); + offscreenContainer.pendingProps = containerProps; + offscreenContainer.memoizedProps = containerProps; } + } + } - // 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); + 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(NoTimestamp, SyncLane); + update.tag = ForceUpdate; + enqueueUpdate(sourceFiber, update, 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; + // 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); } - // This boundary already captured during this render. Continue to the next - // boundary. - node = node.return; - } while (node !== null); + 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. - // Could not find a Suspense boundary capable of capturing. - return null; + // 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( @@ -458,13 +457,16 @@ function throwException( } // Schedule the nearest Suspense to re-render the timed out view. - const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture( - returnFiber, - sourceFiber, - root, - rootRenderLanes, - ); + const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); if (suspenseBoundary !== null) { + suspenseBoundary.flags &= ~ForceClientRender; + markSuspenseBoundaryShouldCapture( + suspenseBoundary, + returnFiber, + sourceFiber, + root, + rootRenderLanes, + ); attachWakeableListeners( suspenseBoundary, root, @@ -487,20 +489,24 @@ function throwException( } else { // This is a regular error, not a Suspense wakeable. if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); // 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. - const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture( - returnFiber, - sourceFiber, - root, - rootRenderLanes, - ); if (suspenseBoundary !== null) { - // Set a flag to indicate that we should try rendering the normal - // children again, not the fallback. - suspenseBoundary.flags |= ForceClientRender; + 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, + ); return; } } else { diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 27b0719ba8532..8f6d18a48dea3 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -268,158 +268,157 @@ function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { } } -function markNearestSuspenseBoundaryShouldCapture( - returnFiber: Fiber, - sourceFiber: Fiber, - root: FiberRoot, - rootRenderLanes: Lanes, -): Fiber | null { +function getNearestSuspenseBoundaryToCapture(returnFiber: Fiber) { + let node = returnFiber; const hasInvisibleParentBoundary = hasSuspenseContext( suspenseStackCursor.current, (InvisibleParentSuspenseContext: SuspenseContext), ); - let node = returnFiber; do { if ( node.tag === SuspenseComponent && shouldCaptureSuspense(node, hasInvisibleParentBoundary) ) { - // Found the nearest boundary. - const suspenseBoundary = node; - - // 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 ((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; + return node; + } + // This boundary already captured during this render. Continue to the next + // boundary. + node = node.return; + } while (node !== null); + return null; +} - // 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); +function markSuspenseBoundaryShouldCapture( + suspenseBoundary: Fiber, + returnFiber: Fiber, + 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 ((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; - if (supportsPersistence && enablePersistentOffscreenHostContainer) { - // Another legacy Suspense quirk. In persistent mode, if this is the - // initial mount, override the props of the host container to hide - // its contents. - const currentSuspenseBoundary = suspenseBoundary.alternate; - if (currentSuspenseBoundary === null) { - const offscreenFiber: Fiber = (suspenseBoundary.child: any); - const offscreenContainer = offscreenFiber.child; - if (offscreenContainer !== null) { - const children = offscreenContainer.memoizedProps.children; - const containerProps = getOffscreenContainerProps( - 'hidden', - children, - ); - offscreenContainer.pendingProps = containerProps; - offscreenContainer.memoizedProps = containerProps; - } - } - } + // 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(NoTimestamp, SyncLane); - update.tag = ForceUpdate; - enqueueUpdate(sourceFiber, update, SyncLane); - } + if (supportsPersistence && enablePersistentOffscreenHostContainer) { + // Another legacy Suspense quirk. In persistent mode, if this is the + // initial mount, override the props of the host container to hide + // its contents. + const currentSuspenseBoundary = suspenseBoundary.alternate; + if (currentSuspenseBoundary === null) { + const offscreenFiber: Fiber = (suspenseBoundary.child: any); + const offscreenContainer = offscreenFiber.child; + if (offscreenContainer !== null) { + const children = offscreenContainer.memoizedProps.children; + const containerProps = getOffscreenContainerProps( + 'hidden', + children, + ); + offscreenContainer.pendingProps = containerProps; + offscreenContainer.memoizedProps = containerProps; } + } + } - // 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); + 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(NoTimestamp, SyncLane); + update.tag = ForceUpdate; + enqueueUpdate(sourceFiber, update, 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; + // 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); } - // This boundary already captured during this render. Continue to the next - // boundary. - node = node.return; - } while (node !== null); + 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. - // Could not find a Suspense boundary capable of capturing. - return null; + // 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( @@ -458,13 +457,16 @@ function throwException( } // Schedule the nearest Suspense to re-render the timed out view. - const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture( - returnFiber, - sourceFiber, - root, - rootRenderLanes, - ); + const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); if (suspenseBoundary !== null) { + suspenseBoundary.flags &= ~ForceClientRender; + markSuspenseBoundaryShouldCapture( + suspenseBoundary, + returnFiber, + sourceFiber, + root, + rootRenderLanes, + ); attachWakeableListeners( suspenseBoundary, root, @@ -487,20 +489,24 @@ function throwException( } else { // This is a regular error, not a Suspense wakeable. if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); // 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. - const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture( - returnFiber, - sourceFiber, - root, - rootRenderLanes, - ); if (suspenseBoundary !== null) { - // Set a flag to indicate that we should try rendering the normal - // children again, not the fallback. - suspenseBoundary.flags |= ForceClientRender; + 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, + ); return; } } else { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 93391eb309d49..6703e2cd36539 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -402,5 +402,6 @@ "414": "Did not expect this call in production. This is a bug in React. Please file an issue.", "415": "Error parsing the data. It's probably an error code or network corruption.", "416": "This environment don't support binary chunks.", - "417": "React currently only supports piping to one writable stream." + "417": "React currently only supports piping to one writable stream.", + "418": "An error occurred during hydration. The server HTML was replaced with client content" }