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.
Andrew Clark committed
Sep 24, 2021 at 13:47 UTC
04ccc01d967091f441f08177a5078cc6034c2a9e
6 files changed
+906
-470
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+259
@@ -1783,4 +1783,263 @@ describe('ReactDOMFizzServer', () => {
1783
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
1784
expect(ref.current).toEqual(serverRenderedDiv);
1785
});
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
+ );
2045
});
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+9
@@ -74,6 +74,7 @@ import {
74
ForceUpdateForLegacySuspense,
75
StaticMask,
76
ShouldCapture,
77
+ ForceClientRender,
78
} from './ReactFiberFlags';
79
import ReactSharedInternals from 'shared/ReactSharedInternals';
80
import {
@@ -2081,6 +2082,14 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2082
prevState,
2083
renderLanes,
2084
);
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
+ );
2093
} else if (
2094
(workInProgress.memoizedState: null | SuspenseState) !== null
2095
) {
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+9
@@ -74,6 +74,7 @@ import {
74
ForceUpdateForLegacySuspense,
75
StaticMask,
76
ShouldCapture,
77
+ ForceClientRender,
78
} from './ReactFiberFlags';
79
import ReactSharedInternals from 'shared/ReactSharedInternals';
80
import {
@@ -2081,6 +2082,14 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
2082
prevState,
2083
renderLanes,
2084
);
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
+ );
2093
} else if (
2094
(workInProgress.memoizedState: null | SuspenseState) !== null
2095
) {
packages/react-reconciler/src/ReactFiberFlags.js
+27
-26
@@ -12,53 +12,54 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
12
export type Flags = number;
13
14
// 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;
17
18
// 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;
21
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;
32
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;
35
36
export const LifecycleEffectMask =
37
Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
38
39
// Union of all commit flags (flags with the lifetime of a particular commit)
39
-export const HostEffectMask = /* */ 0b000000000011111111111111;
40
+export const HostEffectMask = /* */ 0b0000000000111111111111111;
41
42
// 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;
48
49
// Static tags describe aspects of a fiber that are not specific to a render,
50
// e.g. a fiber uses a passive effect (even if there are no updates on this particular render).
51
// This enables us to defer more work in the unmount case,
52
// since we can defer traversing the tree during layout to look for Passive effects,
53
// 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;
57
58
// These flags allow us to traverse to fibers that have effects on mount
59
// without traversing the entire tree after every commit for
60
// double invoking
60
-export const MountLayoutDev = /* */ 0b010000000000000000000000;
61
-export const MountPassiveDev = /* */ 0b100000000000000000000000;
61
+export const MountLayoutDev = /* */ 0b0100000000000000000000000;
62
+export const MountPassiveDev = /* */ 0b1000000000000000000000000;
63
64
// Groups of flags that are used in the commit phase to skip over trees that
65
// don't contain effects, by checking subtreeFlags.
packages/react-reconciler/src/ReactFiberThrow.new.js
+301
-222
@@ -32,6 +32,7 @@ import {
32
ShouldCapture,
33
LifecycleEffectMask,
34
ForceUpdateForLegacySuspense,
35
+ ForceClientRender,
36
} from './ReactFiberFlags';
37
import {
38
supportsPersistence,
@@ -78,6 +79,7 @@ import {
79
mergeLanes,
80
pickArbitraryLane,
81
} from './ReactFiberLane.new';
82
+import {getIsHydrating} from './ReactFiberHydrationContext.new';
83
84
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
85
@@ -160,35 +162,264 @@ function createClassErrorUpdate(
162
return update;
163
}
164
163
-function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
164
- // Attach a listener to the promise to "ping" the root and retry. But only if
165
- // one does not already exist for the lanes we're currently rendering (which
166
- // acts like a "thread ID" here).
167
- let pingCache = root.pingCache;
168
- let threadIDs;
169
- if (pingCache === null) {
170
- pingCache = root.pingCache = new PossiblyWeakMap();
171
- threadIDs = new Set();
172
- pingCache.set(wakeable, threadIDs);
173
- } else {
174
- threadIDs = pingCache.get(wakeable);
175
- if (threadIDs === undefined) {
165
+function attachWakeableListeners(
166
+ suspenseBoundary: Fiber,
167
+ root: FiberRoot,
168
+ wakeable: Wakeable,
169
+ lanes: Lanes,
170
+) {
171
+ // Attach a ping listener
172
+ //
173
+ // The data might resolve before we have a chance to commit the fallback. Or,
174
+ // in the case of a refresh, we'll never commit a fallback. So we need to
175
+ // attach a listener now. When it resolves ("pings"), we can decide whether to
176
+ // try rendering the tree again.
177
+ //
178
+ // Only attach a listener if one does not already exist for the lanes
179
+ // we're currently rendering (which acts like a "thread ID" here).
180
+ //
181
+ // We only need to do this in concurrent mode. Legacy Suspense always
182
+ // commits fallbacks synchronously, so there are no pings.
183
+ if (suspenseBoundary.mode & ConcurrentMode) {
184
+ let pingCache = root.pingCache;
185
+ let threadIDs;
186
+ if (pingCache === null) {
187
+ pingCache = root.pingCache = new PossiblyWeakMap();
188
threadIDs = new Set();
189
pingCache.set(wakeable, threadIDs);
190
+ } else {
191
+ threadIDs = pingCache.get(wakeable);
192
+ if (threadIDs === undefined) {
193
+ threadIDs = new Set();
194
+ pingCache.set(wakeable, threadIDs);
195
+ }
196
}
179
- }
180
- if (!threadIDs.has(lanes)) {
181
- // Memoize using the thread ID to prevent redundant listeners.
182
- threadIDs.add(lanes);
183
- const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
184
- if (enableUpdaterTracking) {
185
- if (isDevToolsPresent) {
186
- // If we have pending work still, restore the original updaters
187
- restorePendingUpdaters(root, lanes);
197
+ if (!threadIDs.has(lanes)) {
198
+ // Memoize using the thread ID to prevent redundant listeners.
199
+ threadIDs.add(lanes);
200
+ const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
201
+ if (enableUpdaterTracking) {
202
+ if (isDevToolsPresent) {
203
+ // If we have pending work still, restore the original updaters
204
+ restorePendingUpdaters(root, lanes);
205
+ }
206
}
207
+ wakeable.then(ping, ping);
208
}
190
- wakeable.then(ping, ping);
209
}
210
+
211
+ // Retry listener
212
+ //
213
+ // If the fallback does commit, we need to attach a different type of
214
+ // listener. This one schedules an update on the Suspense boundary to turn
215
+ // the fallback state off.
216
+ //
217
+ // Stash the wakeable on the boundary fiber so we can access it in the
218
+ // commit phase.
219
+ //
220
+ // When the wakeable resolves, we'll attempt to render the boundary
221
+ // again ("retry").
222
+ const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
223
+ if (wakeables === null) {
224
+ const updateQueue = (new Set(): any);
225
+ updateQueue.add(wakeable);
226
+ suspenseBoundary.updateQueue = updateQueue;
227
+ } else {
228
+ wakeables.add(wakeable);
229
+ }
230
+}
231
+
232
+function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
233
+ if (enableLazyContextPropagation) {
234
+ const currentSourceFiber = sourceFiber.alternate;
235
+ if (currentSourceFiber !== null) {
236
+ // Since we never visited the children of the suspended component, we
237
+ // need to propagate the context change now, to ensure that we visit
238
+ // them during the retry.
239
+ //
240
+ // We don't have to do this for errors because we retry errors without
241
+ // committing in between. So this is specific to Suspense.
242
+ propagateParentContextChangesToDeferredTree(
243
+ currentSourceFiber,
244
+ sourceFiber,
245
+ rootRenderLanes,
246
+ );
247
+ }
248
+ }
249
+
250
+ // Reset the memoizedState to what it was before we attempted to render it.
251
+ // A legacy mode Suspense quirk, only relevant to hook components.
252
+ const tag = sourceFiber.tag;
253
+ if (
254
+ (sourceFiber.mode & ConcurrentMode) === NoMode &&
255
+ (tag === FunctionComponent ||
256
+ tag === ForwardRef ||
257
+ tag === SimpleMemoComponent)
258
+ ) {
259
+ const currentSource = sourceFiber.alternate;
260
+ if (currentSource) {
261
+ sourceFiber.updateQueue = currentSource.updateQueue;
262
+ sourceFiber.memoizedState = currentSource.memoizedState;
263
+ sourceFiber.lanes = currentSource.lanes;
264
+ } else {
265
+ sourceFiber.updateQueue = null;
266
+ sourceFiber.memoizedState = null;
267
+ }
268
+ }
269
+}
270
+
271
+function markNearestSuspenseBoundaryShouldCapture(
272
+ returnFiber: Fiber,
273
+ sourceFiber: Fiber,
274
+ root: FiberRoot,
275
+ rootRenderLanes: Lanes,
276
+): Fiber | null {
277
+ const hasInvisibleParentBoundary = hasSuspenseContext(
278
+ suspenseStackCursor.current,
279
+ (InvisibleParentSuspenseContext: SuspenseContext),
280
+ );
281
+ let node = returnFiber;
282
+ do {
283
+ if (
284
+ node.tag === SuspenseComponent &&
285
+ shouldCaptureSuspense(node, hasInvisibleParentBoundary)
286
+ ) {
287
+ // Found the nearest boundary.
288
+ const suspenseBoundary = node;
289
+
290
+ // This marks a Suspense boundary so that when we're unwinding the stack,
291
+ // it captures the suspended "exception" and does a second (fallback) pass.
292
+
293
+ if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
294
+ // Legacy Mode Suspense
295
+ //
296
+ // If the boundary is in legacy mode, we should *not*
297
+ // suspend the commit. Pretend as if the suspended component rendered
298
+ // null and keep rendering. When the Suspense boundary completes,
299
+ // we'll do a second pass to render the fallback.
300
+ if (suspenseBoundary === returnFiber) {
301
+ // Special case where we suspended while reconciling the children of
302
+ // a Suspense boundary's inner Offscreen wrapper fiber. This happens
303
+ // when a React.lazy component is a direct child of a
304
+ // Suspense boundary.
305
+ //
306
+ // Suspense boundaries are implemented as multiple fibers, but they
307
+ // are a single conceptual unit. The legacy mode behavior where we
308
+ // pretend the suspended fiber committed as `null` won't work,
309
+ // because in this case the "suspended" fiber is the inner
310
+ // Offscreen wrapper.
311
+ //
312
+ // Because the contents of the boundary haven't started rendering
313
+ // yet (i.e. nothing in the tree has partially rendered) we can
314
+ // switch to the regular, concurrent mode behavior: mark the
315
+ // boundary with ShouldCapture and enter the unwind phase.
316
+ suspenseBoundary.flags |= ShouldCapture;
317
+ } else {
318
+ suspenseBoundary.flags |= DidCapture;
319
+ sourceFiber.flags |= ForceUpdateForLegacySuspense;
320
+
321
+ // We're going to commit this fiber even though it didn't complete.
322
+ // But we shouldn't call any lifecycle methods or callbacks. Remove
323
+ // all lifecycle effect tags.
324
+ sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
325
+
326
+ if (supportsPersistence && enablePersistentOffscreenHostContainer) {
327
+ // Another legacy Suspense quirk. In persistent mode, if this is the
328
+ // initial mount, override the props of the host container to hide
329
+ // its contents.
330
+ const currentSuspenseBoundary = suspenseBoundary.alternate;
331
+ if (currentSuspenseBoundary === null) {
332
+ const offscreenFiber: Fiber = (suspenseBoundary.child: any);
333
+ const offscreenContainer = offscreenFiber.child;
334
+ if (offscreenContainer !== null) {
335
+ const children = offscreenContainer.memoizedProps.children;
336
+ const containerProps = getOffscreenContainerProps(
337
+ 'hidden',
338
+ children,
339
+ );
340
+ offscreenContainer.pendingProps = containerProps;
341
+ offscreenContainer.memoizedProps = containerProps;
342
+ }
343
+ }
344
+ }
345
+
346
+ if (sourceFiber.tag === ClassComponent) {
347
+ const currentSourceFiber = sourceFiber.alternate;
348
+ if (currentSourceFiber === null) {
349
+ // This is a new mount. Change the tag so it's not mistaken for a
350
+ // completed class component. For example, we should not call
351
+ // componentWillUnmount if it is deleted.
352
+ sourceFiber.tag = IncompleteClassComponent;
353
+ } else {
354
+ // When we try rendering again, we should not reuse the current fiber,
355
+ // since it's known to be in an inconsistent state. Use a force update to
356
+ // prevent a bail out.
357
+ const update = createUpdate(NoTimestamp, SyncLane);
358
+ update.tag = ForceUpdate;
359
+ enqueueUpdate(sourceFiber, update, SyncLane);
360
+ }
361
+ }
362
+
363
+ // The source fiber did not complete. Mark it with Sync priority to
364
+ // indicate that it still has pending work.
365
+ sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
366
+ }
367
+ return suspenseBoundary;
368
+ }
369
+ // Confirmed that the boundary is in a concurrent mode tree. Continue
370
+ // with the normal suspend path.
371
+ //
372
+ // After this we'll use a set of heuristics to determine whether this
373
+ // render pass will run to completion or restart or "suspend" the commit.
374
+ // The actual logic for this is spread out in different places.
375
+ //
376
+ // This first principle is that if we're going to suspend when we complete
377
+ // a root, then we should also restart if we get an update or ping that
378
+ // might unsuspend it, and vice versa. The only reason to suspend is
379
+ // because you think you might want to restart before committing. However,
380
+ // it doesn't make sense to restart only while in the period we're suspended.
381
+ //
382
+ // Restarting too aggressively is also not good because it starves out any
383
+ // intermediate loading state. So we use heuristics to determine when.
384
+
385
+ // Suspense Heuristics
386
+ //
387
+ // If nothing threw a Promise or all the same fallbacks are already showing,
388
+ // then don't suspend/restart.
389
+ //
390
+ // If this is an initial render of a new tree of Suspense boundaries and
391
+ // those trigger a fallback, then don't suspend/restart. We want to ensure
392
+ // that we can show the initial loading state as quickly as possible.
393
+ //
394
+ // If we hit a "Delayed" case, such as when we'd switch from content back into
395
+ // a fallback, then we should always suspend/restart. Transitions apply
396
+ // to this case. If none is defined, JND is used instead.
397
+ //
398
+ // If we're already showing a fallback and it gets "retried", allowing us to show
399
+ // another level, but there's still an inner boundary that would show a fallback,
400
+ // then we suspend/restart for 500ms since the last time we showed a fallback
401
+ // anywhere in the tree. This effectively throttles progressive loading into a
402
+ // consistent train of commits. This also gives us an opportunity to restart to
403
+ // get to the completed state slightly earlier.
404
+ //
405
+ // If there's ambiguity due to batching it's resolved in preference of:
406
+ // 1) "delayed", 2) "initial render", 3) "retry".
407
+ //
408
+ // We want to ensure that a "busy" state doesn't get force committed. We want to
409
+ // ensure that new initial loading states can commit as soon as possible.
410
+ suspenseBoundary.flags |= ShouldCapture;
411
+ // TODO: I think we can remove this, since we now use `DidCapture` in
412
+ // the begin phase to prevent an early bailout.
413
+ suspenseBoundary.lanes = rootRenderLanes;
414
+ return suspenseBoundary;
415
+ }
416
+ // This boundary already captured during this render. Continue to the next
417
+ // boundary.
418
+ node = node.return;
419
+ } while (node !== null);
420
+
421
+ // Could not find a Suspense boundary capable of capturing.
422
+ return null;
423
}
424
425
function throwException(
@@ -213,25 +444,9 @@ function throwException(
444
typeof value === 'object' &&
445
typeof value.then === 'function'
446
) {
216
- if (enableLazyContextPropagation) {
217
- const currentSourceFiber = sourceFiber.alternate;
218
- if (currentSourceFiber !== null) {
219
- // Since we never visited the children of the suspended component, we
220
- // need to propagate the context change now, to ensure that we visit
221
- // them during the retry.
222
- //
223
- // We don't have to do this for errors because we retry errors without
224
- // committing in between. So this is specific to Suspense.
225
- propagateParentContextChangesToDeferredTree(
226
- currentSourceFiber,
227
- sourceFiber,
228
- rootRenderLanes,
229
- );
230
- }
231
- }
232
-
233
- // This is a wakeable.
447
+ // This is a wakeable. The component suspended.
448
const wakeable: Wakeable = (value: any);
449
+ resetSuspendedComponent(sourceFiber, rootRenderLanes);
450
451
if (__DEV__) {
452
if (enableDebugTracing) {
@@ -242,190 +457,54 @@ function throwException(
457
}
458
}
459
245
- // Reset the memoizedState to what it was before we attempted to render it.
246
- // A legacy mode Suspense quirk, only relevant to hook components.
247
- const tag = sourceFiber.tag;
248
- if (
249
- (sourceFiber.mode & ConcurrentMode) === NoMode &&
250
- (tag === FunctionComponent ||
251
- tag === ForwardRef ||
252
- tag === SimpleMemoComponent)
253
- ) {
254
- const currentSource = sourceFiber.alternate;
255
- if (currentSource) {
256
- sourceFiber.updateQueue = currentSource.updateQueue;
257
- sourceFiber.memoizedState = currentSource.memoizedState;
258
- sourceFiber.lanes = currentSource.lanes;
259
- } else {
260
- sourceFiber.updateQueue = null;
261
- sourceFiber.memoizedState = null;
262
- }
263
- }
264
-
265
- const hasInvisibleParentBoundary = hasSuspenseContext(
266
- suspenseStackCursor.current,
267
- (InvisibleParentSuspenseContext: SuspenseContext),
268
- );
269
-
460
// Schedule the nearest Suspense to re-render the timed out view.
271
- let workInProgress = returnFiber;
272
- do {
273
- if (
274
- workInProgress.tag === SuspenseComponent &&
275
- shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary)
276
- ) {
277
- // Found the nearest boundary.
278
-
279
- // Stash the promise on the boundary fiber. If the boundary times out, we'll
280
- // attach another listener to flip the boundary back to its normal state.
281
- const wakeables: Set<Wakeable> = (workInProgress.updateQueue: any);
282
- if (wakeables === null) {
283
- const updateQueue = (new Set(): any);
284
- updateQueue.add(wakeable);
285
- workInProgress.updateQueue = updateQueue;
286
- } else {
287
- wakeables.add(wakeable);
288
- }
289
-
290
- if ((workInProgress.mode & ConcurrentMode) === NoMode) {
291
- // Legacy Mode Suspense
292
- //
293
- // If the boundary is in legacy mode, we should *not*
294
- // suspend the commit. Pretend as if the suspended component rendered
295
- // null and keep rendering. When the Suspense boundary completes,
296
- // we'll do a second pass to render the fallback.
297
- if (workInProgress === returnFiber) {
298
- // Special case where we suspended while reconciling the children of
299
- // a Suspense boundary's inner Offscreen wrapper fiber. This happens
300
- // when a React.lazy component is a direct child of a
301
- // Suspense boundary.
302
- //
303
- // Suspense boundaries are implemented as multiple fibers, but they
304
- // are a single conceptual unit. The legacy mode behavior where we
305
- // pretend the suspended fiber committed as `null` won't work,
306
- // because in this case the "suspended" fiber is the inner
307
- // Offscreen wrapper.
308
- //
309
- // Because the contents of the boundary haven't started rendering
310
- // yet (i.e. nothing in the tree has partially rendered) we can
311
- // switch to the regular, concurrent mode behavior: mark the
312
- // boundary with ShouldCapture and enter the unwind phase.
313
- workInProgress.flags |= ShouldCapture;
314
- } else {
315
- workInProgress.flags |= DidCapture;
316
- sourceFiber.flags |= ForceUpdateForLegacySuspense;
317
-
318
- // We're going to commit this fiber even though it didn't complete.
319
- // But we shouldn't call any lifecycle methods or callbacks. Remove
320
- // all lifecycle effect tags.
321
- sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
322
-
323
- if (supportsPersistence && enablePersistentOffscreenHostContainer) {
324
- // Another legacy Suspense quirk. In persistent mode, if this is the
325
- // initial mount, override the props of the host container to hide
326
- // its contents.
327
- const currentSuspenseBoundary = workInProgress.alternate;
328
- if (currentSuspenseBoundary === null) {
329
- const offscreenFiber: Fiber = (workInProgress.child: any);
330
- const offscreenContainer = offscreenFiber.child;
331
- if (offscreenContainer !== null) {
332
- const children = offscreenContainer.memoizedProps.children;
333
- const containerProps = getOffscreenContainerProps(
334
- 'hidden',
335
- children,
336
- );
337
- offscreenContainer.pendingProps = containerProps;
338
- offscreenContainer.memoizedProps = containerProps;
339
- }
340
- }
341
- }
342
-
343
- if (sourceFiber.tag === ClassComponent) {
344
- const currentSourceFiber = sourceFiber.alternate;
345
- if (currentSourceFiber === null) {
346
- // This is a new mount. Change the tag so it's not mistaken for a
347
- // completed class component. For example, we should not call
348
- // componentWillUnmount if it is deleted.
349
- sourceFiber.tag = IncompleteClassComponent;
350
- } else {
351
- // When we try rendering again, we should not reuse the current fiber,
352
- // since it's known to be in an inconsistent state. Use a force update to
353
- // prevent a bail out.
354
- const update = createUpdate(NoTimestamp, SyncLane);
355
- update.tag = ForceUpdate;
356
- enqueueUpdate(sourceFiber, update, SyncLane);
357
- }
358
- }
359
-
360
- // The source fiber did not complete. Mark it with Sync priority to
361
- // indicate that it still has pending work.
362
- sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
363
- }
364
- return;
365
- }
366
- // Confirmed that the boundary is in a concurrent mode tree. Continue
367
- // with the normal suspend path.
368
- //
369
- // After this we'll use a set of heuristics to determine whether this
370
- // render pass will run to completion or restart or "suspend" the commit.
371
- // The actual logic for this is spread out in different places.
372
- //
373
- // This first principle is that if we're going to suspend when we complete
374
- // a root, then we should also restart if we get an update or ping that
375
- // might unsuspend it, and vice versa. The only reason to suspend is
376
- // because you think you might want to restart before committing. However,
377
- // it doesn't make sense to restart only while in the period we're suspended.
378
- //
379
- // Restarting too aggressively is also not good because it starves out any
380
- // intermediate loading state. So we use heuristics to determine when.
381
-
382
- // Suspense Heuristics
383
- //
384
- // If nothing threw a Promise or all the same fallbacks are already showing,
385
- // then don't suspend/restart.
386
- //
387
- // If this is an initial render of a new tree of Suspense boundaries and
388
- // those trigger a fallback, then don't suspend/restart. We want to ensure
389
- // that we can show the initial loading state as quickly as possible.
390
- //
391
- // If we hit a "Delayed" case, such as when we'd switch from content back into
392
- // a fallback, then we should always suspend/restart. Transitions apply
393
- // to this case. If none is defined, JND is used instead.
394
- //
395
- // If we're already showing a fallback and it gets "retried", allowing us to show
396
- // another level, but there's still an inner boundary that would show a fallback,
397
- // then we suspend/restart for 500ms since the last time we showed a fallback
398
- // anywhere in the tree. This effectively throttles progressive loading into a
399
- // consistent train of commits. This also gives us an opportunity to restart to
400
- // get to the completed state slightly earlier.
401
- //
402
- // If there's ambiguity due to batching it's resolved in preference of:
403
- // 1) "delayed", 2) "initial render", 3) "retry".
404
- //
405
- // We want to ensure that a "busy" state doesn't get force committed. We want to
406
- // ensure that new initial loading states can commit as soon as possible.
407
-
408
- attachPingListener(root, wakeable, rootRenderLanes);
409
-
410
- workInProgress.flags |= ShouldCapture;
411
- // TODO: I think we can remove this, since we now use `DidCapture` in
412
- // the begin phase to prevent an early bailout.
413
- workInProgress.lanes = rootRenderLanes;
461
+ const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
462
+ returnFiber,
463
+ sourceFiber,
464
+ root,
465
+ rootRenderLanes,
466
+ );
467
+ if (suspenseBoundary !== null) {
468
+ attachWakeableListeners(
469
+ suspenseBoundary,
470
+ root,
471
+ wakeable,
472
+ rootRenderLanes,
473
+ );
474
+ return;
475
+ } else {
476
+ // No boundary was found. Fallthrough to error mode.
477
+ // TODO: Use invariant so the message is stripped in prod?
478
+ value = new Error(
479
+ (getComponentNameFromFiber(sourceFiber) || 'A React component') +
480
+ ' suspended while rendering, but no fallback UI was specified.\n' +
481
+ '\n' +
482
+ 'Add a <Suspense fallback=...> component higher in the tree to ' +
483
+ 'provide a loading indicator or placeholder to display.',
484
+ );
485
+ }
486
+ } else {
487
+ // This is a regular error, not a Suspense wakeable.
488
+ if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
489
+ // If the error was thrown during hydration, we may be able to recover by
490
+ // discarding the dehydrated content and switching to a client render.
491
+ // Instead of surfacing the error, find the nearest Suspense boundary
492
+ // and render it again without hydration.
493
+ const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
494
+ returnFiber,
495
+ sourceFiber,
496
+ root,
497
+ rootRenderLanes,
498
+ );
499
+ if (suspenseBoundary !== null) {
500
+ // Set a flag to indicate that we should try rendering the normal
501
+ // children again, not the fallback.
502
+ suspenseBoundary.flags |= ForceClientRender;
503
return;
504
}
416
- // This boundary already captured during this render. Continue to the next
417
- // boundary.
418
- workInProgress = workInProgress.return;
419
- } while (workInProgress !== null);
420
- // No boundary was found. Fallthrough to error mode.
421
- // TODO: Use invariant so the message is stripped in prod?
422
- value = new Error(
423
- (getComponentNameFromFiber(sourceFiber) || 'A React component') +
424
- ' suspended while rendering, but no fallback UI was specified.\n' +
425
- '\n' +
426
- 'Add a <Suspense fallback=...> component higher in the tree to ' +
427
- 'provide a loading indicator or placeholder to display.',
428
- );
505
+ } else {
506
+ // Otherwise, fall through to the error path.
507
+ }
508
}
509
510
// We didn't find a boundary that could handle this type of exception. Start
packages/react-reconciler/src/ReactFiberThrow.old.js
+301
-222
@@ -32,6 +32,7 @@ import {
32
ShouldCapture,
33
LifecycleEffectMask,
34
ForceUpdateForLegacySuspense,
35
+ ForceClientRender,
36
} from './ReactFiberFlags';
37
import {
38
supportsPersistence,
@@ -78,6 +79,7 @@ import {
79
mergeLanes,
80
pickArbitraryLane,
81
} from './ReactFiberLane.old';
82
+import {getIsHydrating} from './ReactFiberHydrationContext.old';
83
84
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
85
@@ -160,35 +162,264 @@ function createClassErrorUpdate(
162
return update;
163
}
164
163
-function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
164
- // Attach a listener to the promise to "ping" the root and retry. But only if
165
- // one does not already exist for the lanes we're currently rendering (which
166
- // acts like a "thread ID" here).
167
- let pingCache = root.pingCache;
168
- let threadIDs;
169
- if (pingCache === null) {
170
- pingCache = root.pingCache = new PossiblyWeakMap();
171
- threadIDs = new Set();
172
- pingCache.set(wakeable, threadIDs);
173
- } else {
174
- threadIDs = pingCache.get(wakeable);
175
- if (threadIDs === undefined) {
165
+function attachWakeableListeners(
166
+ suspenseBoundary: Fiber,
167
+ root: FiberRoot,
168
+ wakeable: Wakeable,
169
+ lanes: Lanes,
170
+) {
171
+ // Attach a ping listener
172
+ //
173
+ // The data might resolve before we have a chance to commit the fallback. Or,
174
+ // in the case of a refresh, we'll never commit a fallback. So we need to
175
+ // attach a listener now. When it resolves ("pings"), we can decide whether to
176
+ // try rendering the tree again.
177
+ //
178
+ // Only attach a listener if one does not already exist for the lanes
179
+ // we're currently rendering (which acts like a "thread ID" here).
180
+ //
181
+ // We only need to do this in concurrent mode. Legacy Suspense always
182
+ // commits fallbacks synchronously, so there are no pings.
183
+ if (suspenseBoundary.mode & ConcurrentMode) {
184
+ let pingCache = root.pingCache;
185
+ let threadIDs;
186
+ if (pingCache === null) {
187
+ pingCache = root.pingCache = new PossiblyWeakMap();
188
threadIDs = new Set();
189
pingCache.set(wakeable, threadIDs);
190
+ } else {
191
+ threadIDs = pingCache.get(wakeable);
192
+ if (threadIDs === undefined) {
193
+ threadIDs = new Set();
194
+ pingCache.set(wakeable, threadIDs);
195
+ }
196
}
179
- }
180
- if (!threadIDs.has(lanes)) {
181
- // Memoize using the thread ID to prevent redundant listeners.
182
- threadIDs.add(lanes);
183
- const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
184
- if (enableUpdaterTracking) {
185
- if (isDevToolsPresent) {
186
- // If we have pending work still, restore the original updaters
187
- restorePendingUpdaters(root, lanes);
197
+ if (!threadIDs.has(lanes)) {
198
+ // Memoize using the thread ID to prevent redundant listeners.
199
+ threadIDs.add(lanes);
200
+ const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
201
+ if (enableUpdaterTracking) {
202
+ if (isDevToolsPresent) {
203
+ // If we have pending work still, restore the original updaters
204
+ restorePendingUpdaters(root, lanes);
205
+ }
206
}
207
+ wakeable.then(ping, ping);
208
}
190
- wakeable.then(ping, ping);
209
}
210
+
211
+ // Retry listener
212
+ //
213
+ // If the fallback does commit, we need to attach a different type of
214
+ // listener. This one schedules an update on the Suspense boundary to turn
215
+ // the fallback state off.
216
+ //
217
+ // Stash the wakeable on the boundary fiber so we can access it in the
218
+ // commit phase.
219
+ //
220
+ // When the wakeable resolves, we'll attempt to render the boundary
221
+ // again ("retry").
222
+ const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
223
+ if (wakeables === null) {
224
+ const updateQueue = (new Set(): any);
225
+ updateQueue.add(wakeable);
226
+ suspenseBoundary.updateQueue = updateQueue;
227
+ } else {
228
+ wakeables.add(wakeable);
229
+ }
230
+}
231
+
232
+function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
233
+ if (enableLazyContextPropagation) {
234
+ const currentSourceFiber = sourceFiber.alternate;
235
+ if (currentSourceFiber !== null) {
236
+ // Since we never visited the children of the suspended component, we
237
+ // need to propagate the context change now, to ensure that we visit
238
+ // them during the retry.
239
+ //
240
+ // We don't have to do this for errors because we retry errors without
241
+ // committing in between. So this is specific to Suspense.
242
+ propagateParentContextChangesToDeferredTree(
243
+ currentSourceFiber,
244
+ sourceFiber,
245
+ rootRenderLanes,
246
+ );
247
+ }
248
+ }
249
+
250
+ // Reset the memoizedState to what it was before we attempted to render it.
251
+ // A legacy mode Suspense quirk, only relevant to hook components.
252
+ const tag = sourceFiber.tag;
253
+ if (
254
+ (sourceFiber.mode & ConcurrentMode) === NoMode &&
255
+ (tag === FunctionComponent ||
256
+ tag === ForwardRef ||
257
+ tag === SimpleMemoComponent)
258
+ ) {
259
+ const currentSource = sourceFiber.alternate;
260
+ if (currentSource) {
261
+ sourceFiber.updateQueue = currentSource.updateQueue;
262
+ sourceFiber.memoizedState = currentSource.memoizedState;
263
+ sourceFiber.lanes = currentSource.lanes;
264
+ } else {
265
+ sourceFiber.updateQueue = null;
266
+ sourceFiber.memoizedState = null;
267
+ }
268
+ }
269
+}
270
+
271
+function markNearestSuspenseBoundaryShouldCapture(
272
+ returnFiber: Fiber,
273
+ sourceFiber: Fiber,
274
+ root: FiberRoot,
275
+ rootRenderLanes: Lanes,
276
+): Fiber | null {
277
+ const hasInvisibleParentBoundary = hasSuspenseContext(
278
+ suspenseStackCursor.current,
279
+ (InvisibleParentSuspenseContext: SuspenseContext),
280
+ );
281
+ let node = returnFiber;
282
+ do {
283
+ if (
284
+ node.tag === SuspenseComponent &&
285
+ shouldCaptureSuspense(node, hasInvisibleParentBoundary)
286
+ ) {
287
+ // Found the nearest boundary.
288
+ const suspenseBoundary = node;
289
+
290
+ // This marks a Suspense boundary so that when we're unwinding the stack,
291
+ // it captures the suspended "exception" and does a second (fallback) pass.
292
+
293
+ if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
294
+ // Legacy Mode Suspense
295
+ //
296
+ // If the boundary is in legacy mode, we should *not*
297
+ // suspend the commit. Pretend as if the suspended component rendered
298
+ // null and keep rendering. When the Suspense boundary completes,
299
+ // we'll do a second pass to render the fallback.
300
+ if (suspenseBoundary === returnFiber) {
301
+ // Special case where we suspended while reconciling the children of
302
+ // a Suspense boundary's inner Offscreen wrapper fiber. This happens
303
+ // when a React.lazy component is a direct child of a
304
+ // Suspense boundary.
305
+ //
306
+ // Suspense boundaries are implemented as multiple fibers, but they
307
+ // are a single conceptual unit. The legacy mode behavior where we
308
+ // pretend the suspended fiber committed as `null` won't work,
309
+ // because in this case the "suspended" fiber is the inner
310
+ // Offscreen wrapper.
311
+ //
312
+ // Because the contents of the boundary haven't started rendering
313
+ // yet (i.e. nothing in the tree has partially rendered) we can
314
+ // switch to the regular, concurrent mode behavior: mark the
315
+ // boundary with ShouldCapture and enter the unwind phase.
316
+ suspenseBoundary.flags |= ShouldCapture;
317
+ } else {
318
+ suspenseBoundary.flags |= DidCapture;
319
+ sourceFiber.flags |= ForceUpdateForLegacySuspense;
320
+
321
+ // We're going to commit this fiber even though it didn't complete.
322
+ // But we shouldn't call any lifecycle methods or callbacks. Remove
323
+ // all lifecycle effect tags.
324
+ sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
325
+
326
+ if (supportsPersistence && enablePersistentOffscreenHostContainer) {
327
+ // Another legacy Suspense quirk. In persistent mode, if this is the
328
+ // initial mount, override the props of the host container to hide
329
+ // its contents.
330
+ const currentSuspenseBoundary = suspenseBoundary.alternate;
331
+ if (currentSuspenseBoundary === null) {
332
+ const offscreenFiber: Fiber = (suspenseBoundary.child: any);
333
+ const offscreenContainer = offscreenFiber.child;
334
+ if (offscreenContainer !== null) {
335
+ const children = offscreenContainer.memoizedProps.children;
336
+ const containerProps = getOffscreenContainerProps(
337
+ 'hidden',
338
+ children,
339
+ );
340
+ offscreenContainer.pendingProps = containerProps;
341
+ offscreenContainer.memoizedProps = containerProps;
342
+ }
343
+ }
344
+ }
345
+
346
+ if (sourceFiber.tag === ClassComponent) {
347
+ const currentSourceFiber = sourceFiber.alternate;
348
+ if (currentSourceFiber === null) {
349
+ // This is a new mount. Change the tag so it's not mistaken for a
350
+ // completed class component. For example, we should not call
351
+ // componentWillUnmount if it is deleted.
352
+ sourceFiber.tag = IncompleteClassComponent;
353
+ } else {
354
+ // When we try rendering again, we should not reuse the current fiber,
355
+ // since it's known to be in an inconsistent state. Use a force update to
356
+ // prevent a bail out.
357
+ const update = createUpdate(NoTimestamp, SyncLane);
358
+ update.tag = ForceUpdate;
359
+ enqueueUpdate(sourceFiber, update, SyncLane);
360
+ }
361
+ }
362
+
363
+ // The source fiber did not complete. Mark it with Sync priority to
364
+ // indicate that it still has pending work.
365
+ sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
366
+ }
367
+ return suspenseBoundary;
368
+ }
369
+ // Confirmed that the boundary is in a concurrent mode tree. Continue
370
+ // with the normal suspend path.
371
+ //
372
+ // After this we'll use a set of heuristics to determine whether this
373
+ // render pass will run to completion or restart or "suspend" the commit.
374
+ // The actual logic for this is spread out in different places.
375
+ //
376
+ // This first principle is that if we're going to suspend when we complete
377
+ // a root, then we should also restart if we get an update or ping that
378
+ // might unsuspend it, and vice versa. The only reason to suspend is
379
+ // because you think you might want to restart before committing. However,
380
+ // it doesn't make sense to restart only while in the period we're suspended.
381
+ //
382
+ // Restarting too aggressively is also not good because it starves out any
383
+ // intermediate loading state. So we use heuristics to determine when.
384
+
385
+ // Suspense Heuristics
386
+ //
387
+ // If nothing threw a Promise or all the same fallbacks are already showing,
388
+ // then don't suspend/restart.
389
+ //
390
+ // If this is an initial render of a new tree of Suspense boundaries and
391
+ // those trigger a fallback, then don't suspend/restart. We want to ensure
392
+ // that we can show the initial loading state as quickly as possible.
393
+ //
394
+ // If we hit a "Delayed" case, such as when we'd switch from content back into
395
+ // a fallback, then we should always suspend/restart. Transitions apply
396
+ // to this case. If none is defined, JND is used instead.
397
+ //
398
+ // If we're already showing a fallback and it gets "retried", allowing us to show
399
+ // another level, but there's still an inner boundary that would show a fallback,
400
+ // then we suspend/restart for 500ms since the last time we showed a fallback
401
+ // anywhere in the tree. This effectively throttles progressive loading into a
402
+ // consistent train of commits. This also gives us an opportunity to restart to
403
+ // get to the completed state slightly earlier.
404
+ //
405
+ // If there's ambiguity due to batching it's resolved in preference of:
406
+ // 1) "delayed", 2) "initial render", 3) "retry".
407
+ //
408
+ // We want to ensure that a "busy" state doesn't get force committed. We want to
409
+ // ensure that new initial loading states can commit as soon as possible.
410
+ suspenseBoundary.flags |= ShouldCapture;
411
+ // TODO: I think we can remove this, since we now use `DidCapture` in
412
+ // the begin phase to prevent an early bailout.
413
+ suspenseBoundary.lanes = rootRenderLanes;
414
+ return suspenseBoundary;
415
+ }
416
+ // This boundary already captured during this render. Continue to the next
417
+ // boundary.
418
+ node = node.return;
419
+ } while (node !== null);
420
+
421
+ // Could not find a Suspense boundary capable of capturing.
422
+ return null;
423
}
424
425
function throwException(
@@ -213,25 +444,9 @@ function throwException(
444
typeof value === 'object' &&
445
typeof value.then === 'function'
446
) {
216
- if (enableLazyContextPropagation) {
217
- const currentSourceFiber = sourceFiber.alternate;
218
- if (currentSourceFiber !== null) {
219
- // Since we never visited the children of the suspended component, we
220
- // need to propagate the context change now, to ensure that we visit
221
- // them during the retry.
222
- //
223
- // We don't have to do this for errors because we retry errors without
224
- // committing in between. So this is specific to Suspense.
225
- propagateParentContextChangesToDeferredTree(
226
- currentSourceFiber,
227
- sourceFiber,
228
- rootRenderLanes,
229
- );
230
- }
231
- }
232
-
233
- // This is a wakeable.
447
+ // This is a wakeable. The component suspended.
448
const wakeable: Wakeable = (value: any);
449
+ resetSuspendedComponent(sourceFiber, rootRenderLanes);
450
451
if (__DEV__) {
452
if (enableDebugTracing) {
@@ -242,190 +457,54 @@ function throwException(
457
}
458
}
459
245
- // Reset the memoizedState to what it was before we attempted to render it.
246
- // A legacy mode Suspense quirk, only relevant to hook components.
247
- const tag = sourceFiber.tag;
248
- if (
249
- (sourceFiber.mode & ConcurrentMode) === NoMode &&
250
- (tag === FunctionComponent ||
251
- tag === ForwardRef ||
252
- tag === SimpleMemoComponent)
253
- ) {
254
- const currentSource = sourceFiber.alternate;
255
- if (currentSource) {
256
- sourceFiber.updateQueue = currentSource.updateQueue;
257
- sourceFiber.memoizedState = currentSource.memoizedState;
258
- sourceFiber.lanes = currentSource.lanes;
259
- } else {
260
- sourceFiber.updateQueue = null;
261
- sourceFiber.memoizedState = null;
262
- }
263
- }
264
-
265
- const hasInvisibleParentBoundary = hasSuspenseContext(
266
- suspenseStackCursor.current,
267
- (InvisibleParentSuspenseContext: SuspenseContext),
268
- );
269
-
460
// Schedule the nearest Suspense to re-render the timed out view.
271
- let workInProgress = returnFiber;
272
- do {
273
- if (
274
- workInProgress.tag === SuspenseComponent &&
275
- shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary)
276
- ) {
277
- // Found the nearest boundary.
278
-
279
- // Stash the promise on the boundary fiber. If the boundary times out, we'll
280
- // attach another listener to flip the boundary back to its normal state.
281
- const wakeables: Set<Wakeable> = (workInProgress.updateQueue: any);
282
- if (wakeables === null) {
283
- const updateQueue = (new Set(): any);
284
- updateQueue.add(wakeable);
285
- workInProgress.updateQueue = updateQueue;
286
- } else {
287
- wakeables.add(wakeable);
288
- }
289
-
290
- if ((workInProgress.mode & ConcurrentMode) === NoMode) {
291
- // Legacy Mode Suspense
292
- //
293
- // If the boundary is in legacy mode, we should *not*
294
- // suspend the commit. Pretend as if the suspended component rendered
295
- // null and keep rendering. When the Suspense boundary completes,
296
- // we'll do a second pass to render the fallback.
297
- if (workInProgress === returnFiber) {
298
- // Special case where we suspended while reconciling the children of
299
- // a Suspense boundary's inner Offscreen wrapper fiber. This happens
300
- // when a React.lazy component is a direct child of a
301
- // Suspense boundary.
302
- //
303
- // Suspense boundaries are implemented as multiple fibers, but they
304
- // are a single conceptual unit. The legacy mode behavior where we
305
- // pretend the suspended fiber committed as `null` won't work,
306
- // because in this case the "suspended" fiber is the inner
307
- // Offscreen wrapper.
308
- //
309
- // Because the contents of the boundary haven't started rendering
310
- // yet (i.e. nothing in the tree has partially rendered) we can
311
- // switch to the regular, concurrent mode behavior: mark the
312
- // boundary with ShouldCapture and enter the unwind phase.
313
- workInProgress.flags |= ShouldCapture;
314
- } else {
315
- workInProgress.flags |= DidCapture;
316
- sourceFiber.flags |= ForceUpdateForLegacySuspense;
317
-
318
- // We're going to commit this fiber even though it didn't complete.
319
- // But we shouldn't call any lifecycle methods or callbacks. Remove
320
- // all lifecycle effect tags.
321
- sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
322
-
323
- if (supportsPersistence && enablePersistentOffscreenHostContainer) {
324
- // Another legacy Suspense quirk. In persistent mode, if this is the
325
- // initial mount, override the props of the host container to hide
326
- // its contents.
327
- const currentSuspenseBoundary = workInProgress.alternate;
328
- if (currentSuspenseBoundary === null) {
329
- const offscreenFiber: Fiber = (workInProgress.child: any);
330
- const offscreenContainer = offscreenFiber.child;
331
- if (offscreenContainer !== null) {
332
- const children = offscreenContainer.memoizedProps.children;
333
- const containerProps = getOffscreenContainerProps(
334
- 'hidden',
335
- children,
336
- );
337
- offscreenContainer.pendingProps = containerProps;
338
- offscreenContainer.memoizedProps = containerProps;
339
- }
340
- }
341
- }
342
-
343
- if (sourceFiber.tag === ClassComponent) {
344
- const currentSourceFiber = sourceFiber.alternate;
345
- if (currentSourceFiber === null) {
346
- // This is a new mount. Change the tag so it's not mistaken for a
347
- // completed class component. For example, we should not call
348
- // componentWillUnmount if it is deleted.
349
- sourceFiber.tag = IncompleteClassComponent;
350
- } else {
351
- // When we try rendering again, we should not reuse the current fiber,
352
- // since it's known to be in an inconsistent state. Use a force update to
353
- // prevent a bail out.
354
- const update = createUpdate(NoTimestamp, SyncLane);
355
- update.tag = ForceUpdate;
356
- enqueueUpdate(sourceFiber, update, SyncLane);
357
- }
358
- }
359
-
360
- // The source fiber did not complete. Mark it with Sync priority to
361
- // indicate that it still has pending work.
362
- sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
363
- }
364
- return;
365
- }
366
- // Confirmed that the boundary is in a concurrent mode tree. Continue
367
- // with the normal suspend path.
368
- //
369
- // After this we'll use a set of heuristics to determine whether this
370
- // render pass will run to completion or restart or "suspend" the commit.
371
- // The actual logic for this is spread out in different places.
372
- //
373
- // This first principle is that if we're going to suspend when we complete
374
- // a root, then we should also restart if we get an update or ping that
375
- // might unsuspend it, and vice versa. The only reason to suspend is
376
- // because you think you might want to restart before committing. However,
377
- // it doesn't make sense to restart only while in the period we're suspended.
378
- //
379
- // Restarting too aggressively is also not good because it starves out any
380
- // intermediate loading state. So we use heuristics to determine when.
381
-
382
- // Suspense Heuristics
383
- //
384
- // If nothing threw a Promise or all the same fallbacks are already showing,
385
- // then don't suspend/restart.
386
- //
387
- // If this is an initial render of a new tree of Suspense boundaries and
388
- // those trigger a fallback, then don't suspend/restart. We want to ensure
389
- // that we can show the initial loading state as quickly as possible.
390
- //
391
- // If we hit a "Delayed" case, such as when we'd switch from content back into
392
- // a fallback, then we should always suspend/restart. Transitions apply
393
- // to this case. If none is defined, JND is used instead.
394
- //
395
- // If we're already showing a fallback and it gets "retried", allowing us to show
396
- // another level, but there's still an inner boundary that would show a fallback,
397
- // then we suspend/restart for 500ms since the last time we showed a fallback
398
- // anywhere in the tree. This effectively throttles progressive loading into a
399
- // consistent train of commits. This also gives us an opportunity to restart to
400
- // get to the completed state slightly earlier.
401
- //
402
- // If there's ambiguity due to batching it's resolved in preference of:
403
- // 1) "delayed", 2) "initial render", 3) "retry".
404
- //
405
- // We want to ensure that a "busy" state doesn't get force committed. We want to
406
- // ensure that new initial loading states can commit as soon as possible.
407
-
408
- attachPingListener(root, wakeable, rootRenderLanes);
409
-
410
- workInProgress.flags |= ShouldCapture;
411
- // TODO: I think we can remove this, since we now use `DidCapture` in
412
- // the begin phase to prevent an early bailout.
413
- workInProgress.lanes = rootRenderLanes;
461
+ const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
462
+ returnFiber,
463
+ sourceFiber,
464
+ root,
465
+ rootRenderLanes,
466
+ );
467
+ if (suspenseBoundary !== null) {
468
+ attachWakeableListeners(
469
+ suspenseBoundary,
470
+ root,
471
+ wakeable,
472
+ rootRenderLanes,
473
+ );
474
+ return;
475
+ } else {
476
+ // No boundary was found. Fallthrough to error mode.
477
+ // TODO: Use invariant so the message is stripped in prod?
478
+ value = new Error(
479
+ (getComponentNameFromFiber(sourceFiber) || 'A React component') +
480
+ ' suspended while rendering, but no fallback UI was specified.\n' +
481
+ '\n' +
482
+ 'Add a <Suspense fallback=...> component higher in the tree to ' +
483
+ 'provide a loading indicator or placeholder to display.',
484
+ );
485
+ }
486
+ } else {
487
+ // This is a regular error, not a Suspense wakeable.
488
+ if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
489
+ // If the error was thrown during hydration, we may be able to recover by
490
+ // discarding the dehydrated content and switching to a client render.
491
+ // Instead of surfacing the error, find the nearest Suspense boundary
492
+ // and render it again without hydration.
493
+ const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
494
+ returnFiber,
495
+ sourceFiber,
496
+ root,
497
+ rootRenderLanes,
498
+ );
499
+ if (suspenseBoundary !== null) {
500
+ // Set a flag to indicate that we should try rendering the normal
501
+ // children again, not the fallback.
502
+ suspenseBoundary.flags |= ForceClientRender;
503
return;
504
}
416
- // This boundary already captured during this render. Continue to the next
417
- // boundary.
418
- workInProgress = workInProgress.return;
419
- } while (workInProgress !== null);
420
- // No boundary was found. Fallthrough to error mode.
421
- // TODO: Use invariant so the message is stripped in prod?
422
- value = new Error(
423
- (getComponentNameFromFiber(sourceFiber) || 'A React component') +
424
- ' suspended while rendering, but no fallback UI was specified.\n' +
425
- '\n' +
426
- 'Add a <Suspense fallback=...> component higher in the tree to ' +
427
- 'provide a loading indicator or placeholder to display.',
428
- );
505
+ } else {
506
+ // Otherwise, fall through to the error path.
507
+ }
508
}
509
510
// We didn't find a boundary that could handle this type of exception. Start