Skip to content

Commit 04ccc01

Browse files
authored
Hydration errors should force a client render (#22416)
* 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.
1 parent 029fdce commit 04ccc01

File tree

6 files changed

+906
-470
lines changed

6 files changed

+906
-470
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+259
Original file line numberDiff line numberDiff line change
@@ -1783,4 +1783,263 @@ describe('ReactDOMFizzServer', () => {
17831783
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
17841784
expect(ref.current).toEqual(serverRenderedDiv);
17851785
});
1786+
1787+
// @gate supportsNativeUseSyncExternalStore
1788+
// @gate experimental
1789+
it(
1790+
'errors during hydration force a client render at the nearest Suspense ' +
1791+
'boundary, and during the client render it recovers',
1792+
async () => {
1793+
let isClient = false;
1794+
1795+
function subscribe() {
1796+
return () => {};
1797+
}
1798+
function getClientSnapshot() {
1799+
return 'Yay!';
1800+
}
1801+
1802+
// At the time of writing, the only API that exposes whether it's currently
1803+
// hydrating is the `getServerSnapshot` API, so I'm using that here to
1804+
// simulate an error during hydration.
1805+
function getServerSnapshot() {
1806+
if (isClient) {
1807+
throw new Error('Hydration error');
1808+
}
1809+
return 'Yay!';
1810+
}
1811+
1812+
function Child() {
1813+
const value = useSyncExternalStore(
1814+
subscribe,
1815+
getClientSnapshot,
1816+
getServerSnapshot,
1817+
);
1818+
Scheduler.unstable_yieldValue(value);
1819+
return value;
1820+
}
1821+
1822+
const span1Ref = React.createRef();
1823+
const span2Ref = React.createRef();
1824+
const span3Ref = React.createRef();
1825+
1826+
function App() {
1827+
return (
1828+
<div>
1829+
<span ref={span1Ref} />
1830+
<Suspense fallback="Loading...">
1831+
<span ref={span2Ref}>
1832+
<Child />
1833+
</span>
1834+
</Suspense>
1835+
<span ref={span3Ref} />
1836+
</div>
1837+
);
1838+
}
1839+
1840+
await act(async () => {
1841+
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
1842+
<App />,
1843+
writable,
1844+
);
1845+
startWriting();
1846+
});
1847+
expect(Scheduler).toHaveYielded(['Yay!']);
1848+
1849+
const [span1, span2, span3] = container.getElementsByTagName('span');
1850+
1851+
// Hydrate the tree. Child will throw during hydration, but not when it
1852+
// falls back to client rendering.
1853+
isClient = true;
1854+
ReactDOM.hydrateRoot(container, <App />);
1855+
1856+
expect(Scheduler).toFlushAndYield(['Yay!']);
1857+
expect(getVisibleChildren(container)).toEqual(
1858+
<div>
1859+
<span />
1860+
<span>Yay!</span>
1861+
<span />
1862+
</div>,
1863+
);
1864+
1865+
// The node that's inside the boundary that errored during hydration was
1866+
// not hydrated.
1867+
expect(span2Ref.current).not.toBe(span2);
1868+
1869+
// But the nodes outside the boundary were.
1870+
expect(span1Ref.current).toBe(span1);
1871+
expect(span3Ref.current).toBe(span3);
1872+
},
1873+
);
1874+
1875+
// @gate experimental
1876+
it(
1877+
'errors during hydration force a client render at the nearest Suspense ' +
1878+
'boundary, and during the client render it fails again',
1879+
async () => {
1880+
// Similar to previous test, but the client render errors, too. We should
1881+
// be able to capture it with an error boundary.
1882+
1883+
let isClient = false;
1884+
1885+
class ErrorBoundary extends React.Component {
1886+
state = {error: null};
1887+
static getDerivedStateFromError(error) {
1888+
return {error};
1889+
}
1890+
render() {
1891+
if (this.state.error !== null) {
1892+
return this.state.error.message;
1893+
}
1894+
return this.props.children;
1895+
}
1896+
}
1897+
1898+
function Child() {
1899+
if (isClient) {
1900+
throw new Error('Oops!');
1901+
}
1902+
Scheduler.unstable_yieldValue('Yay!');
1903+
return 'Yay!';
1904+
}
1905+
1906+
const span1Ref = React.createRef();
1907+
const span2Ref = React.createRef();
1908+
const span3Ref = React.createRef();
1909+
1910+
function App() {
1911+
return (
1912+
<ErrorBoundary>
1913+
<span ref={span1Ref} />
1914+
<Suspense fallback="Loading...">
1915+
<span ref={span2Ref}>
1916+
<Child />
1917+
</span>
1918+
</Suspense>
1919+
<span ref={span3Ref} />
1920+
</ErrorBoundary>
1921+
);
1922+
}
1923+
1924+
await act(async () => {
1925+
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
1926+
<App />,
1927+
writable,
1928+
);
1929+
startWriting();
1930+
});
1931+
expect(Scheduler).toHaveYielded(['Yay!']);
1932+
1933+
// Hydrate the tree. Child will throw during render.
1934+
isClient = true;
1935+
ReactDOM.hydrateRoot(container, <App />);
1936+
1937+
expect(Scheduler).toFlushAndYield([]);
1938+
expect(getVisibleChildren(container)).toEqual('Oops!');
1939+
},
1940+
);
1941+
1942+
// @gate supportsNativeUseSyncExternalStore
1943+
// @gate experimental
1944+
it(
1945+
'errors during hydration force a client render at the nearest Suspense ' +
1946+
'boundary, and during the client render it recovers, then a deeper ' +
1947+
'child suspends',
1948+
async () => {
1949+
let isClient = false;
1950+
1951+
function subscribe() {
1952+
return () => {};
1953+
}
1954+
function getClientSnapshot() {
1955+
return 'Yay!';
1956+
}
1957+
1958+
// At the time of writing, the only API that exposes whether it's currently
1959+
// hydrating is the `getServerSnapshot` API, so I'm using that here to
1960+
// simulate an error during hydration.
1961+
function getServerSnapshot() {
1962+
if (isClient) {
1963+
throw new Error('Hydration error');
1964+
}
1965+
return 'Yay!';
1966+
}
1967+
1968+
function Child() {
1969+
const value = useSyncExternalStore(
1970+
subscribe,
1971+
getClientSnapshot,
1972+
getServerSnapshot,
1973+
);
1974+
if (isClient) {
1975+
readText(value);
1976+
}
1977+
Scheduler.unstable_yieldValue(value);
1978+
return value;
1979+
}
1980+
1981+
const span1Ref = React.createRef();
1982+
const span2Ref = React.createRef();
1983+
const span3Ref = React.createRef();
1984+
1985+
function App() {
1986+
return (
1987+
<div>
1988+
<span ref={span1Ref} />
1989+
<Suspense fallback="Loading...">
1990+
<span ref={span2Ref}>
1991+
<Child />
1992+
</span>
1993+
</Suspense>
1994+
<span ref={span3Ref} />
1995+
</div>
1996+
);
1997+
}
1998+
1999+
await act(async () => {
2000+
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
2001+
<App />,
2002+
writable,
2003+
);
2004+
startWriting();
2005+
});
2006+
expect(Scheduler).toHaveYielded(['Yay!']);
2007+
2008+
const [span1, span2, span3] = container.getElementsByTagName('span');
2009+
2010+
// Hydrate the tree. Child will throw during hydration, but not when it
2011+
// falls back to client rendering.
2012+
isClient = true;
2013+
ReactDOM.hydrateRoot(container, <App />);
2014+
2015+
expect(Scheduler).toFlushAndYield([]);
2016+
expect(getVisibleChildren(container)).toEqual(
2017+
<div>
2018+
<span />
2019+
Loading...
2020+
<span />
2021+
</div>,
2022+
);
2023+
2024+
await act(async () => {
2025+
resolveText('Yay!');
2026+
});
2027+
expect(Scheduler).toFlushAndYield(['Yay!']);
2028+
expect(getVisibleChildren(container)).toEqual(
2029+
<div>
2030+
<span />
2031+
<span>Yay!</span>
2032+
<span />
2033+
</div>,
2034+
);
2035+
2036+
// The node that's inside the boundary that errored during hydration was
2037+
// not hydrated.
2038+
expect(span2Ref.current).not.toBe(span2);
2039+
2040+
// But the nodes outside the boundary were.
2041+
expect(span1Ref.current).toBe(span1);
2042+
expect(span3Ref.current).toBe(span3);
2043+
},
2044+
);
17862045
});

packages/react-reconciler/src/ReactFiberBeginWork.new.js

+9
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
ForceUpdateForLegacySuspense,
7575
StaticMask,
7676
ShouldCapture,
77+
ForceClientRender,
7778
} from './ReactFiberFlags';
7879
import ReactSharedInternals from 'shared/ReactSharedInternals';
7980
import {
@@ -2081,6 +2082,14 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
20812082
prevState,
20822083
renderLanes,
20832084
);
2085+
} else if (workInProgress.flags & ForceClientRender) {
2086+
// Something errored during hydration. Try again without hydrating.
2087+
workInProgress.flags &= ~ForceClientRender;
2088+
return retrySuspenseComponentWithoutHydrating(
2089+
current,
2090+
workInProgress,
2091+
renderLanes,
2092+
);
20842093
} else if (
20852094
(workInProgress.memoizedState: null | SuspenseState) !== null
20862095
) {

packages/react-reconciler/src/ReactFiberBeginWork.old.js

+9
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
ForceUpdateForLegacySuspense,
7575
StaticMask,
7676
ShouldCapture,
77+
ForceClientRender,
7778
} from './ReactFiberFlags';
7879
import ReactSharedInternals from 'shared/ReactSharedInternals';
7980
import {
@@ -2081,6 +2082,14 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
20812082
prevState,
20822083
renderLanes,
20832084
);
2085+
} else if (workInProgress.flags & ForceClientRender) {
2086+
// Something errored during hydration. Try again without hydrating.
2087+
workInProgress.flags &= ~ForceClientRender;
2088+
return retrySuspenseComponentWithoutHydrating(
2089+
current,
2090+
workInProgress,
2091+
renderLanes,
2092+
);
20842093
} else if (
20852094
(workInProgress.memoizedState: null | SuspenseState) !== null
20862095
) {

packages/react-reconciler/src/ReactFiberFlags.js

+27-26
Original file line numberDiff line numberDiff line change
@@ -12,53 +12,54 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
1212
export type Flags = number;
1313

1414
// Don't change these two values. They're used by React Dev Tools.
15-
export const NoFlags = /* */ 0b000000000000000000000000;
16-
export const PerformedWork = /* */ 0b000000000000000000000001;
15+
export const NoFlags = /* */ 0b0000000000000000000000000;
16+
export const PerformedWork = /* */ 0b0000000000000000000000001;
1717

1818
// You can change the rest (and add more).
19-
export const Placement = /* */ 0b000000000000000000000010;
20-
export const Update = /* */ 0b000000000000000000000100;
19+
export const Placement = /* */ 0b0000000000000000000000010;
20+
export const Update = /* */ 0b0000000000000000000000100;
2121
export const PlacementAndUpdate = /* */ Placement | Update;
22-
export const Deletion = /* */ 0b000000000000000000001000;
23-
export const ChildDeletion = /* */ 0b000000000000000000010000;
24-
export const ContentReset = /* */ 0b000000000000000000100000;
25-
export const Callback = /* */ 0b000000000000000001000000;
26-
export const DidCapture = /* */ 0b000000000000000010000000;
27-
export const Ref = /* */ 0b000000000000000100000000;
28-
export const Snapshot = /* */ 0b000000000000001000000000;
29-
export const Passive = /* */ 0b000000000000010000000000;
30-
export const Hydrating = /* */ 0b000000000000100000000000;
22+
export const Deletion = /* */ 0b0000000000000000000001000;
23+
export const ChildDeletion = /* */ 0b0000000000000000000010000;
24+
export const ContentReset = /* */ 0b0000000000000000000100000;
25+
export const Callback = /* */ 0b0000000000000000001000000;
26+
export const DidCapture = /* */ 0b0000000000000000010000000;
27+
export const ForceClientRender = /* */ 0b0000000000000000100000000;
28+
export const Ref = /* */ 0b0000000000000001000000000;
29+
export const Snapshot = /* */ 0b0000000000000010000000000;
30+
export const Passive = /* */ 0b0000000000000100000000000;
31+
export const Hydrating = /* */ 0b0000000000001000000000000;
3132
export const HydratingAndUpdate = /* */ Hydrating | Update;
32-
export const Visibility = /* */ 0b000000000001000000000000;
33-
export const StoreConsistency = /* */ 0b000000000010000000000000;
33+
export const Visibility = /* */ 0b0000000000010000000000000;
34+
export const StoreConsistency = /* */ 0b0000000000100000000000000;
3435

3536
export const LifecycleEffectMask =
3637
Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
3738

3839
// Union of all commit flags (flags with the lifetime of a particular commit)
39-
export const HostEffectMask = /* */ 0b000000000011111111111111;
40+
export const HostEffectMask = /* */ 0b0000000000111111111111111;
4041

4142
// These are not really side effects, but we still reuse this field.
42-
export const Incomplete = /* */ 0b000000000100000000000000;
43-
export const ShouldCapture = /* */ 0b000000001000000000000000;
44-
export const ForceUpdateForLegacySuspense = /* */ 0b000000010000000000000000;
45-
export const DidPropagateContext = /* */ 0b000000100000000000000000;
46-
export const NeedsPropagation = /* */ 0b000001000000000000000000;
43+
export const Incomplete = /* */ 0b0000000001000000000000000;
44+
export const ShouldCapture = /* */ 0b0000000010000000000000000;
45+
export const ForceUpdateForLegacySuspense = /* */ 0b0000000100000000000000000;
46+
export const DidPropagateContext = /* */ 0b0000001000000000000000000;
47+
export const NeedsPropagation = /* */ 0b0000010000000000000000000;
4748

4849
// Static tags describe aspects of a fiber that are not specific to a render,
4950
// e.g. a fiber uses a passive effect (even if there are no updates on this particular render).
5051
// This enables us to defer more work in the unmount case,
5152
// since we can defer traversing the tree during layout to look for Passive effects,
5253
// and instead rely on the static flag as a signal that there may be cleanup work.
53-
export const RefStatic = /* */ 0b000010000000000000000000;
54-
export const LayoutStatic = /* */ 0b000100000000000000000000;
55-
export const PassiveStatic = /* */ 0b001000000000000000000000;
54+
export const RefStatic = /* */ 0b0000100000000000000000000;
55+
export const LayoutStatic = /* */ 0b0001000000000000000000000;
56+
export const PassiveStatic = /* */ 0b0010000000000000000000000;
5657

5758
// These flags allow us to traverse to fibers that have effects on mount
5859
// without traversing the entire tree after every commit for
5960
// double invoking
60-
export const MountLayoutDev = /* */ 0b010000000000000000000000;
61-
export const MountPassiveDev = /* */ 0b100000000000000000000000;
61+
export const MountLayoutDev = /* */ 0b0100000000000000000000000;
62+
export const MountPassiveDev = /* */ 0b1000000000000000000000000;
6263

6364
// Groups of flags that are used in the commit phase to skip over trees that
6465
// don't contain effects, by checking subtreeFlags.

0 commit comments

Comments
 (0)