Skip to content

Commit

Permalink
Hydration errors should force a client render (#22416)
Browse files Browse the repository at this point in the history
* Refactor throwException control flow

I'm about to add more branches to the Suspense-related logic in
`throwException`, so before I do, I split some of the steps into
separate functions so that later I can use them in multiple places.

This commit does not change any program behavior, only the control flow
surrounding existing code.

* Hydration errors should force a client render

If something errors during hydration, we should try rendering again
without hydrating.

We'll find the nearest Suspense boundary and force it to client render,
discarding the server-rendered content.
  • Loading branch information
acdlite authored Sep 24, 2021
1 parent 029fdce commit 04ccc01
Show file tree
Hide file tree
Showing 6 changed files with 906 additions and 470 deletions.
259 changes: 259 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1783,4 +1783,263 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
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 (
<div>
<span ref={span1Ref} />
<Suspense fallback="Loading...">
<span ref={span2Ref}>
<Child />
</span>
</Suspense>
<span ref={span3Ref} />
</div>
);
}

await act(async () => {
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
<App />,
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, <App />);

expect(Scheduler).toFlushAndYield(['Yay!']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span />
<span>Yay!</span>
<span />
</div>,
);

// 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 (
<ErrorBoundary>
<span ref={span1Ref} />
<Suspense fallback="Loading...">
<span ref={span2Ref}>
<Child />
</span>
</Suspense>
<span ref={span3Ref} />
</ErrorBoundary>
);
}

await act(async () => {
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
<App />,
writable,
);
startWriting();
});
expect(Scheduler).toHaveYielded(['Yay!']);

// Hydrate the tree. Child will throw during render.
isClient = true;
ReactDOM.hydrateRoot(container, <App />);

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 (
<div>
<span ref={span1Ref} />
<Suspense fallback="Loading...">
<span ref={span2Ref}>
<Child />
</span>
</Suspense>
<span ref={span3Ref} />
</div>
);
}

await act(async () => {
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
<App />,
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, <App />);

expect(Scheduler).toFlushAndYield([]);
expect(getVisibleChildren(container)).toEqual(
<div>
<span />
Loading...
<span />
</div>,
);

await act(async () => {
resolveText('Yay!');
});
expect(Scheduler).toFlushAndYield(['Yay!']);
expect(getVisibleChildren(container)).toEqual(
<div>
<span />
<span>Yay!</span>
<span />
</div>,
);

// 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);
},
);
});
9 changes: 9 additions & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
ForceUpdateForLegacySuspense,
StaticMask,
ShouldCapture,
ForceClientRender,
} from './ReactFiberFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {
Expand Down Expand Up @@ -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
) {
Expand Down
9 changes: 9 additions & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
ForceUpdateForLegacySuspense,
StaticMask,
ShouldCapture,
ForceClientRender,
} from './ReactFiberFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {
Expand Down Expand Up @@ -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
) {
Expand Down
53 changes: 27 additions & 26 deletions packages/react-reconciler/src/ReactFiberFlags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 04ccc01

Please sign in to comment.