diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index edf5814023142..dd43cbd20d279 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1783,4 +1783,263 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual(
client
); expect(ref.current).toEqual(serverRenderedDiv); }); + + // @gate supportsNativeUseSyncExternalStore + // @gate experimental + it( + 'errors during hydration force a client render at the nearest Suspense ' + + 'boundary, and during the client render it recovers', + async () => { + let isClient = false; + + function subscribe() { + return () => {}; + } + function getClientSnapshot() { + return 'Yay!'; + } + + // At the time of writing, the only API that exposes whether it's currently + // hydrating is the `getServerSnapshot` API, so I'm using that here to + // simulate an error during hydration. + function getServerSnapshot() { + if (isClient) { + throw new Error('Hydration error'); + } + return 'Yay!'; + } + + function Child() { + const value = useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); + Scheduler.unstable_yieldValue(value); + return value; + } + + const span1Ref = React.createRef(); + const span2Ref = React.createRef(); + const span3Ref = React.createRef(); + + function App() { + return ( +
+ + + + + + + +
+ ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + ); + startWriting(); + }); + expect(Scheduler).toHaveYielded(['Yay!']); + + const [span1, span2, span3] = container.getElementsByTagName('span'); + + // Hydrate the tree. Child will throw during hydration, but not when it + // falls back to client rendering. + isClient = true; + ReactDOM.hydrateRoot(container, ); + + expect(Scheduler).toFlushAndYield(['Yay!']); + expect(getVisibleChildren(container)).toEqual( +
+ + Yay! + +
, + ); + + // The node that's inside the boundary that errored during hydration was + // not hydrated. + expect(span2Ref.current).not.toBe(span2); + + // But the nodes outside the boundary were. + expect(span1Ref.current).toBe(span1); + expect(span3Ref.current).toBe(span3); + }, + ); + + // @gate experimental + it( + 'errors during hydration force a client render at the nearest Suspense ' + + 'boundary, and during the client render it fails again', + async () => { + // Similar to previous test, but the client render errors, too. We should + // be able to capture it with an error boundary. + + let isClient = false; + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error !== null) { + return this.state.error.message; + } + return this.props.children; + } + } + + function Child() { + if (isClient) { + throw new Error('Oops!'); + } + Scheduler.unstable_yieldValue('Yay!'); + return 'Yay!'; + } + + const span1Ref = React.createRef(); + const span2Ref = React.createRef(); + const span3Ref = React.createRef(); + + function App() { + return ( + + + + + + + + + + ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + ); + startWriting(); + }); + expect(Scheduler).toHaveYielded(['Yay!']); + + // Hydrate the tree. Child will throw during render. + isClient = true; + ReactDOM.hydrateRoot(container, ); + + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual('Oops!'); + }, + ); + + // @gate supportsNativeUseSyncExternalStore + // @gate experimental + it( + 'errors during hydration force a client render at the nearest Suspense ' + + 'boundary, and during the client render it recovers, then a deeper ' + + 'child suspends', + async () => { + let isClient = false; + + function subscribe() { + return () => {}; + } + function getClientSnapshot() { + return 'Yay!'; + } + + // At the time of writing, the only API that exposes whether it's currently + // hydrating is the `getServerSnapshot` API, so I'm using that here to + // simulate an error during hydration. + function getServerSnapshot() { + if (isClient) { + throw new Error('Hydration error'); + } + return 'Yay!'; + } + + function Child() { + const value = useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); + if (isClient) { + readText(value); + } + Scheduler.unstable_yieldValue(value); + return value; + } + + const span1Ref = React.createRef(); + const span2Ref = React.createRef(); + const span3Ref = React.createRef(); + + function App() { + return ( +
+ + + + + + + +
+ ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + ); + startWriting(); + }); + expect(Scheduler).toHaveYielded(['Yay!']); + + const [span1, span2, span3] = container.getElementsByTagName('span'); + + // Hydrate the tree. Child will throw during hydration, but not when it + // falls back to client rendering. + isClient = true; + ReactDOM.hydrateRoot(container, ); + + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual( +
+ + Loading... + +
, + ); + + await act(async () => { + resolveText('Yay!'); + }); + expect(Scheduler).toFlushAndYield(['Yay!']); + expect(getVisibleChildren(container)).toEqual( +
+ + Yay! + +
, + ); + + // The node that's inside the boundary that errored during hydration was + // not hydrated. + expect(span2Ref.current).not.toBe(span2); + + // But the nodes outside the boundary were. + expect(span1Ref.current).toBe(span1); + expect(span3Ref.current).toBe(span3); + }, + ); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 9ad53ec00f27f..364489f1e01f8 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -74,6 +74,7 @@ import { ForceUpdateForLegacySuspense, StaticMask, ShouldCapture, + ForceClientRender, } from './ReactFiberFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -2081,6 +2082,14 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { prevState, renderLanes, ); + } else if (workInProgress.flags & ForceClientRender) { + // Something errored during hydration. Try again without hydrating. + workInProgress.flags &= ~ForceClientRender; + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + ); } else if ( (workInProgress.memoizedState: null | SuspenseState) !== null ) { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index bef7863638ff0..08e9f38b173d1 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -74,6 +74,7 @@ import { ForceUpdateForLegacySuspense, StaticMask, ShouldCapture, + ForceClientRender, } from './ReactFiberFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -2081,6 +2082,14 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { prevState, renderLanes, ); + } else if (workInProgress.flags & ForceClientRender) { + // Something errored during hydration. Try again without hydrating. + workInProgress.flags &= ~ForceClientRender; + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + ); } else if ( (workInProgress.memoizedState: null | SuspenseState) !== null ) { diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index 13f843ad80607..a82278222bf0a 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,53 +12,54 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these two values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b000000000000000000000000; -export const PerformedWork = /* */ 0b000000000000000000000001; +export const NoFlags = /* */ 0b0000000000000000000000000; +export const PerformedWork = /* */ 0b0000000000000000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b000000000000000000000010; -export const Update = /* */ 0b000000000000000000000100; +export const Placement = /* */ 0b0000000000000000000000010; +export const Update = /* */ 0b0000000000000000000000100; export const PlacementAndUpdate = /* */ Placement | Update; -export const Deletion = /* */ 0b000000000000000000001000; -export const ChildDeletion = /* */ 0b000000000000000000010000; -export const ContentReset = /* */ 0b000000000000000000100000; -export const Callback = /* */ 0b000000000000000001000000; -export const DidCapture = /* */ 0b000000000000000010000000; -export const Ref = /* */ 0b000000000000000100000000; -export const Snapshot = /* */ 0b000000000000001000000000; -export const Passive = /* */ 0b000000000000010000000000; -export const Hydrating = /* */ 0b000000000000100000000000; +export const Deletion = /* */ 0b0000000000000000000001000; +export const ChildDeletion = /* */ 0b0000000000000000000010000; +export const ContentReset = /* */ 0b0000000000000000000100000; +export const Callback = /* */ 0b0000000000000000001000000; +export const DidCapture = /* */ 0b0000000000000000010000000; +export const ForceClientRender = /* */ 0b0000000000000000100000000; +export const Ref = /* */ 0b0000000000000001000000000; +export const Snapshot = /* */ 0b0000000000000010000000000; +export const Passive = /* */ 0b0000000000000100000000000; +export const Hydrating = /* */ 0b0000000000001000000000000; export const HydratingAndUpdate = /* */ Hydrating | Update; -export const Visibility = /* */ 0b000000000001000000000000; -export const StoreConsistency = /* */ 0b000000000010000000000000; +export const Visibility = /* */ 0b0000000000010000000000000; +export const StoreConsistency = /* */ 0b0000000000100000000000000; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot | StoreConsistency; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b000000000011111111111111; +export const HostEffectMask = /* */ 0b0000000000111111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b000000000100000000000000; -export const ShouldCapture = /* */ 0b000000001000000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b000000010000000000000000; -export const DidPropagateContext = /* */ 0b000000100000000000000000; -export const NeedsPropagation = /* */ 0b000001000000000000000000; +export const Incomplete = /* */ 0b0000000001000000000000000; +export const ShouldCapture = /* */ 0b0000000010000000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b0000000100000000000000000; +export const DidPropagateContext = /* */ 0b0000001000000000000000000; +export const NeedsPropagation = /* */ 0b0000010000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const RefStatic = /* */ 0b000010000000000000000000; -export const LayoutStatic = /* */ 0b000100000000000000000000; -export const PassiveStatic = /* */ 0b001000000000000000000000; +export const RefStatic = /* */ 0b0000100000000000000000000; +export const LayoutStatic = /* */ 0b0001000000000000000000000; +export const PassiveStatic = /* */ 0b0010000000000000000000000; // These flags allow us to traverse to fibers that have effects on mount // without traversing the entire tree after every commit for // double invoking -export const MountLayoutDev = /* */ 0b010000000000000000000000; -export const MountPassiveDev = /* */ 0b100000000000000000000000; +export const MountLayoutDev = /* */ 0b0100000000000000000000000; +export const MountPassiveDev = /* */ 0b1000000000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 8833cb132c4cc..5987c3b2861e8 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -32,6 +32,7 @@ import { ShouldCapture, LifecycleEffectMask, ForceUpdateForLegacySuspense, + ForceClientRender, } from './ReactFiberFlags'; import { supportsPersistence, @@ -83,6 +84,7 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.new'; +import {getIsHydrating} from './ReactFiberHydrationContext.new'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -490,6 +492,28 @@ function throwException( 'provide a loading indicator or placeholder to display.', ); } + } else { + // This is a regular error, not a Suspense wakeable. + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + // 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; + return; + } + } else { + // Otherwise, fall through to the error path. + } } // We didn't find a boundary that could handle this type of exception. Start diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 6eb647991fd10..e2323b5ca7c31 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -32,6 +32,7 @@ import { ShouldCapture, LifecycleEffectMask, ForceUpdateForLegacySuspense, + ForceClientRender, } from './ReactFiberFlags'; import { supportsPersistence, @@ -83,6 +84,7 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.old'; +import {getIsHydrating} from './ReactFiberHydrationContext.old'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -490,6 +492,28 @@ function throwException( 'provide a loading indicator or placeholder to display.', ); } + } else { + // This is a regular error, not a Suspense wakeable. + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + // 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; + return; + } + } else { + // Otherwise, fall through to the error path. + } } // We didn't find a boundary that could handle this type of exception. Start