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