Throw on hydration mismatch and force client rendering if boundary hasn't suspended within concurrent root (#22629)
* Throw on hydration mismatch * remove debugger * update error message * update error message part2... * fix test? * test? :( * tests 4real * remove useRefAccessWarning gating * split markSuspenseBoundary and getNearestBoundary * also assert html is correct * replace-fork * also remove client render flag on suspend * replace-fork * fix mismerge????
salazarm committed
Nov 9, 2021 at 13:40 UTC
0ddd69d1226c660ee4930b0dce7860b706e548bc
7 files changed
+444
-346
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+16
-43
@@ -1677,25 +1677,19 @@ describe('ReactDOMFizzServer', () => {
1677
1678
// @gate experimental
1679
it('calls getServerSnapshot instead of getSnapshot', async () => {
1680
- const ref = React.createRef();
1681
-
1680
function getServerSnapshot() {
1681
return 'server';
1682
}
1685
-
1683
function getClientSnapshot() {
1684
return 'client';
1685
}
1689
-
1686
function subscribe() {
1687
return () => {};
1688
}
1693
-
1689
function Child({text}) {
1690
Scheduler.unstable_yieldValue(text);
1691
return text;
1692
}
1698
-
1693
function App() {
1694
const value = useSyncExternalStore(
1695
subscribe,
@@ -1703,19 +1697,17 @@ describe('ReactDOMFizzServer', () => {
1697
getServerSnapshot,
1698
);
1699
return (
1706
- <div ref={ref}>
1700
+ <div>
1701
<Child text={value} />
1702
</div>
1703
);
1704
}
1711
-
1705
const loggedErrors = [];
1706
await act(async () => {
1707
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1708
<Suspense fallback="Loading...">
1709
<App />
1710
</Suspense>,
1718
-
1711
{
1712
onError(x) {
1713
loggedErrors.push(x);
@@ -1726,56 +1718,43 @@ describe('ReactDOMFizzServer', () => {
1718
});
1719
expect(Scheduler).toHaveYielded(['server']);
1720
1729
- const serverRenderedDiv = container.getElementsByTagName('div')[0];
1730
-
1721
ReactDOM.hydrateRoot(container, <App />);
1722
1733
- // The first paint uses the server snapshot
1734
- expect(Scheduler).toFlushUntilNextPaint(['server']);
1735
- expect(getVisibleChildren(container)).toEqual(<div>server</div>);
1736
- // Hydration succeeded
1737
- expect(ref.current).toEqual(serverRenderedDiv);
1738
-
1739
- // Asynchronously we detect that the store has changed on the client,
1740
- // and patch up the inconsistency
1741
- expect(Scheduler).toFlushUntilNextPaint(['client']);
1723
+ expect(() => {
1724
+ // The first paint switches to client rendering due to mismatch
1725
+ expect(Scheduler).toFlushUntilNextPaint(['client']);
1726
+ }).toErrorDev(
1727
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content',
1728
+ {withoutStack: true},
1729
+ );
1730
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
1743
- expect(ref.current).toEqual(serverRenderedDiv);
1731
});
1732
1733
// The selector implementation uses the lazy ref initialization pattern
1747
- // @gate !(enableUseRefAccessWarning && __DEV__)
1734
// @gate experimental
1735
it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => {
1736
// Same as previous test, but with a selector that returns a complex object
1737
// that is memoized with a custom `isEqual` function.
1738
const ref = React.createRef();
1753
-
1739
function getServerSnapshot() {
1740
return {env: 'server', other: 'unrelated'};
1741
}
1757
-
1742
function getClientSnapshot() {
1743
return {env: 'client', other: 'unrelated'};
1744
}
1761
-
1745
function selector({env}) {
1746
return {env};
1747
}
1765
-
1748
function isEqual(a, b) {
1749
return a.env === b.env;
1750
}
1769
-
1751
function subscribe() {
1752
return () => {};
1753
}
1773
-
1754
function Child({text}) {
1755
Scheduler.unstable_yieldValue(text);
1756
return text;
1757
}
1778
-
1758
function App() {
1759
const {env} = useSyncExternalStoreWithSelector(
1760
subscribe,
@@ -1790,14 +1769,12 @@ describe('ReactDOMFizzServer', () => {
1769
</div>
1770
);
1771
}
1793
-
1772
const loggedErrors = [];
1773
await act(async () => {
1774
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1775
<Suspense fallback="Loading...">
1776
<App />
1777
</Suspense>,
1800
-
1778
{
1779
onError(x) {
1780
loggedErrors.push(x);
@@ -1808,21 +1785,17 @@ describe('ReactDOMFizzServer', () => {
1785
});
1786
expect(Scheduler).toHaveYielded(['server']);
1787
1811
- const serverRenderedDiv = container.getElementsByTagName('div')[0];
1812
-
1788
ReactDOM.hydrateRoot(container, <App />);
1789
1815
- // The first paint uses the server snapshot
1816
- expect(Scheduler).toFlushUntilNextPaint(['server']);
1817
- expect(getVisibleChildren(container)).toEqual(<div>server</div>);
1818
- // Hydration succeeded
1819
- expect(ref.current).toEqual(serverRenderedDiv);
1820
-
1821
- // Asynchronously we detect that the store has changed on the client,
1822
- // and patch up the inconsistency
1823
- expect(Scheduler).toFlushUntilNextPaint(['client']);
1790
+ // The first paint uses the client due to mismatch forcing client render
1791
+ expect(() => {
1792
+ // The first paint switches to client rendering due to mismatch
1793
+ expect(Scheduler).toFlushUntilNextPaint(['client']);
1794
+ }).toErrorDev(
1795
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content',
1796
+ {withoutStack: true},
1797
+ );
1798
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
1825
- expect(ref.current).toEqual(serverRenderedDiv);
1799
});
1800
1801
// @gate experimental
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+96
-6
@@ -197,12 +197,7 @@ describe('ReactDOMServerPartialHydration', () => {
197
// hydrating anyway.
198
suspend = true;
199
ReactDOM.hydrateRoot(container, <App />);
200
- expect(() => {
201
- Scheduler.unstable_flushAll();
202
- }).toErrorDev(
203
- // TODO: This error should not be logged in this case. It's a false positive.
204
- 'Did not expect server HTML to contain the text node "Hello" in <div>.',
205
- );
200
+ Scheduler.unstable_flushAll();
201
jest.runAllTimers();
202
203
// Expect the server-generated HTML to stay intact.
@@ -218,6 +213,101 @@ describe('ReactDOMServerPartialHydration', () => {
213
expect(container.textContent).toBe('HelloHello');
214
});
215
216
+ it('falls back to client rendering boundary on mismatch', async () => {
217
+ let client = false;
218
+ let suspend = false;
219
+ let resolve;
220
+ const promise = new Promise(resolvePromise => {
221
+ resolve = () => {
222
+ suspend = false;
223
+ resolvePromise();
224
+ };
225
+ });
226
+ function Child() {
227
+ if (suspend) {
228
+ Scheduler.unstable_yieldValue('Suspend');
229
+ throw promise;
230
+ } else {
231
+ Scheduler.unstable_yieldValue('Hello');
232
+ return 'Hello';
233
+ }
234
+ }
235
+ function Component({shouldMismatch}) {
236
+ Scheduler.unstable_yieldValue('Component');
237
+ if (shouldMismatch && client) {
238
+ return <article>Mismatch</article>;
239
+ }
240
+ return <div>Component</div>;
241
+ }
242
+ function App() {
243
+ return (
244
+ <Suspense fallback="Loading...">
245
+ <Child />
246
+ <Component />
247
+ <Component />
248
+ <Component />
249
+ <Component shouldMismatch={true} />
250
+ </Suspense>
251
+ );
252
+ }
253
+ const finalHTML = ReactDOMServer.renderToString(<App />);
254
+ const container = document.createElement('div');
255
+ container.innerHTML = finalHTML;
256
+ expect(Scheduler).toHaveYielded([
257
+ 'Hello',
258
+ 'Component',
259
+ 'Component',
260
+ 'Component',
261
+ 'Component',
262
+ ]);
263
+
264
+ expect(container.innerHTML).toBe(
265
+ '<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
266
+ );
267
+
268
+ suspend = true;
269
+ client = true;
270
+
271
+ ReactDOM.hydrateRoot(container, <App />);
272
+ expect(Scheduler).toFlushAndYield([
273
+ 'Suspend',
274
+ 'Component',
275
+ 'Component',
276
+ 'Component',
277
+ 'Component',
278
+ ]);
279
+ jest.runAllTimers();
280
+
281
+ // Unchanged
282
+ expect(container.innerHTML).toBe(
283
+ '<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
284
+ );
285
+
286
+ suspend = false;
287
+ resolve();
288
+ await promise;
289
+
290
+ expect(Scheduler).toFlushAndYield([
291
+ // first pass, mismatches at end
292
+ 'Hello',
293
+ 'Component',
294
+ 'Component',
295
+ 'Component',
296
+ 'Component',
297
+ // second pass as client render
298
+ 'Hello',
299
+ 'Component',
300
+ 'Component',
301
+ 'Component',
302
+ 'Component',
303
+ ]);
304
+
305
+ // Client rendered - suspense comment nodes removed
306
+ expect(container.innerHTML).toBe(
307
+ 'Hello<div>Component</div><div>Component</div><div>Component</div><article>Mismatch</article>',
308
+ );
309
+ });
310
+
311
it('calls the hydration callbacks after hydration or deletion', async () => {
312
let suspend = false;
313
let resolve;
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+11
@@ -8,6 +8,7 @@
8
*/
9
10
import type {Fiber} from './ReactInternalTypes';
11
+import {NoMode, ConcurrentMode} from './ReactTypeOfMode';
12
import type {
13
Instance,
14
TextInstance,
@@ -323,12 +324,21 @@ function tryHydrate(fiber, nextInstance) {
324
}
325
}
326
327
+function throwOnHydrationMismatchIfConcurrentMode(fiber) {
328
+ if ((fiber.mode & ConcurrentMode) !== NoMode) {
329
+ throw new Error(
330
+ 'An error occurred during hydration. The server HTML was replaced with client content',
331
+ );
332
+ }
333
+}
334
+
335
function tryToClaimNextHydratableInstance(fiber: Fiber): void {
336
if (!isHydrating) {
337
return;
338
}
339
let nextInstance = nextHydratableInstance;
340
if (!nextInstance) {
341
+ throwOnHydrationMismatchIfConcurrentMode(fiber);
342
// Nothing to hydrate. Make it an insertion.
343
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
344
isHydrating = false;
@@ -337,6 +347,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
347
}
348
const firstAttemptedInstance = nextInstance;
349
if (!tryHydrate(fiber, nextInstance)) {
350
+ throwOnHydrationMismatchIfConcurrentMode(fiber);
351
// If we can't hydrate this instance let's try the next one.
352
// We use this as a heuristic. It's based on intuition and not data so it
353
// might be flawed or unnecessary.
packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+11
@@ -8,6 +8,7 @@
8
*/
9
10
import type {Fiber} from './ReactInternalTypes';
11
+import {NoMode, ConcurrentMode} from './ReactTypeOfMode';
12
import type {
13
Instance,
14
TextInstance,
@@ -323,12 +324,21 @@ function tryHydrate(fiber, nextInstance) {
324
}
325
}
326
327
+function throwOnHydrationMismatchIfConcurrentMode(fiber) {
328
+ if ((fiber.mode & ConcurrentMode) !== NoMode) {
329
+ throw new Error(
330
+ 'An error occurred during hydration. The server HTML was replaced with client content',
331
+ );
332
+ }
333
+}
334
+
335
function tryToClaimNextHydratableInstance(fiber: Fiber): void {
336
if (!isHydrating) {
337
return;
338
}
339
let nextInstance = nextHydratableInstance;
340
if (!nextInstance) {
341
+ throwOnHydrationMismatchIfConcurrentMode(fiber);
342
// Nothing to hydrate. Make it an insertion.
343
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
344
isHydrating = false;
@@ -337,6 +347,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
347
}
348
const firstAttemptedInstance = nextInstance;
349
if (!tryHydrate(fiber, nextInstance)) {
350
+ throwOnHydrationMismatchIfConcurrentMode(fiber);
351
// If we can't hydrate this instance let's try the next one.
352
// We use this as a heuristic. It's based on intuition and not data so it
353
// might be flawed or unnecessary.
packages/react-reconciler/src/ReactFiberThrow.new.js
+154
-148
@@ -268,158 +268,157 @@ function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
268
}
269
}
270
271
-function markNearestSuspenseBoundaryShouldCapture(
272
- returnFiber: Fiber,
273
- sourceFiber: Fiber,
274
- root: FiberRoot,
275
- rootRenderLanes: Lanes,
276
-): Fiber | null {
271
+function getNearestSuspenseBoundaryToCapture(returnFiber: Fiber) {
272
+ let node = returnFiber;
273
const hasInvisibleParentBoundary = hasSuspenseContext(
274
suspenseStackCursor.current,
275
(InvisibleParentSuspenseContext: SuspenseContext),
276
);
281
- let node = returnFiber;
277
do {
278
if (
279
node.tag === SuspenseComponent &&
280
shouldCaptureSuspense(node, hasInvisibleParentBoundary)
281
) {
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;
282
+ return node;
283
+ }
284
+ // This boundary already captured during this render. Continue to the next
285
+ // boundary.
286
+ node = node.return;
287
+ } while (node !== null);
288
+ return null;
289
+}
290
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);
291
+function markSuspenseBoundaryShouldCapture(
292
+ suspenseBoundary: Fiber,
293
+ returnFiber: Fiber,
294
+ sourceFiber: Fiber,
295
+ root: FiberRoot,
296
+ rootRenderLanes: Lanes,
297
+): Fiber | null {
298
+ // This marks a Suspense boundary so that when we're unwinding the stack,
299
+ // it captures the suspended "exception" and does a second (fallback) pass.
300
+ if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
301
+ // Legacy Mode Suspense
302
+ //
303
+ // If the boundary is in legacy mode, we should *not*
304
+ // suspend the commit. Pretend as if the suspended component rendered
305
+ // null and keep rendering. When the Suspense boundary completes,
306
+ // we'll do a second pass to render the fallback.
307
+ if (suspenseBoundary === returnFiber) {
308
+ // Special case where we suspended while reconciling the children of
309
+ // a Suspense boundary's inner Offscreen wrapper fiber. This happens
310
+ // when a React.lazy component is a direct child of a
311
+ // Suspense boundary.
312
+ //
313
+ // Suspense boundaries are implemented as multiple fibers, but they
314
+ // are a single conceptual unit. The legacy mode behavior where we
315
+ // pretend the suspended fiber committed as `null` won't work,
316
+ // because in this case the "suspended" fiber is the inner
317
+ // Offscreen wrapper.
318
+ //
319
+ // Because the contents of the boundary haven't started rendering
320
+ // yet (i.e. nothing in the tree has partially rendered) we can
321
+ // switch to the regular, concurrent mode behavior: mark the
322
+ // boundary with ShouldCapture and enter the unwind phase.
323
+ suspenseBoundary.flags |= ShouldCapture;
324
+ } else {
325
+ suspenseBoundary.flags |= DidCapture;
326
+ sourceFiber.flags |= ForceUpdateForLegacySuspense;
327
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
- }
328
+ // We're going to commit this fiber even though it didn't complete.
329
+ // But we shouldn't call any lifecycle methods or callbacks. Remove
330
+ // all lifecycle effect tags.
331
+ sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
332
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
- }
333
+ if (supportsPersistence && enablePersistentOffscreenHostContainer) {
334
+ // Another legacy Suspense quirk. In persistent mode, if this is the
335
+ // initial mount, override the props of the host container to hide
336
+ // its contents.
337
+ const currentSuspenseBoundary = suspenseBoundary.alternate;
338
+ if (currentSuspenseBoundary === null) {
339
+ const offscreenFiber: Fiber = (suspenseBoundary.child: any);
340
+ const offscreenContainer = offscreenFiber.child;
341
+ if (offscreenContainer !== null) {
342
+ const children = offscreenContainer.memoizedProps.children;
343
+ const containerProps = getOffscreenContainerProps(
344
+ 'hidden',
345
+ children,
346
+ );
347
+ offscreenContainer.pendingProps = containerProps;
348
+ offscreenContainer.memoizedProps = containerProps;
349
}
350
+ }
351
+ }
352
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);
353
+ if (sourceFiber.tag === ClassComponent) {
354
+ const currentSourceFiber = sourceFiber.alternate;
355
+ if (currentSourceFiber === null) {
356
+ // This is a new mount. Change the tag so it's not mistaken for a
357
+ // completed class component. For example, we should not call
358
+ // componentWillUnmount if it is deleted.
359
+ sourceFiber.tag = IncompleteClassComponent;
360
+ } else {
361
+ // When we try rendering again, we should not reuse the current fiber,
362
+ // since it's known to be in an inconsistent state. Use a force update to
363
+ // prevent a bail out.
364
+ const update = createUpdate(NoTimestamp, SyncLane);
365
+ update.tag = ForceUpdate;
366
+ enqueueUpdate(sourceFiber, update, SyncLane);
367
}
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.
369
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;
370
+ // The source fiber did not complete. Mark it with Sync priority to
371
+ // indicate that it still has pending work.
372
+ sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
373
}
416
- // This boundary already captured during this render. Continue to the next
417
- // boundary.
418
- node = node.return;
419
- } while (node !== null);
374
+ return suspenseBoundary;
375
+ }
376
+ // Confirmed that the boundary is in a concurrent mode tree. Continue
377
+ // with the normal suspend path.
378
+ //
379
+ // After this we'll use a set of heuristics to determine whether this
380
+ // render pass will run to completion or restart or "suspend" the commit.
381
+ // The actual logic for this is spread out in different places.
382
+ //
383
+ // This first principle is that if we're going to suspend when we complete
384
+ // a root, then we should also restart if we get an update or ping that
385
+ // might unsuspend it, and vice versa. The only reason to suspend is
386
+ // because you think you might want to restart before committing. However,
387
+ // it doesn't make sense to restart only while in the period we're suspended.
388
+ //
389
+ // Restarting too aggressively is also not good because it starves out any
390
+ // intermediate loading state. So we use heuristics to determine when.
391
421
- // Could not find a Suspense boundary capable of capturing.
422
- return null;
392
+ // Suspense Heuristics
393
+ //
394
+ // If nothing threw a Promise or all the same fallbacks are already showing,
395
+ // then don't suspend/restart.
396
+ //
397
+ // If this is an initial render of a new tree of Suspense boundaries and
398
+ // those trigger a fallback, then don't suspend/restart. We want to ensure
399
+ // that we can show the initial loading state as quickly as possible.
400
+ //
401
+ // If we hit a "Delayed" case, such as when we'd switch from content back into
402
+ // a fallback, then we should always suspend/restart. Transitions apply
403
+ // to this case. If none is defined, JND is used instead.
404
+ //
405
+ // If we're already showing a fallback and it gets "retried", allowing us to show
406
+ // another level, but there's still an inner boundary that would show a fallback,
407
+ // then we suspend/restart for 500ms since the last time we showed a fallback
408
+ // anywhere in the tree. This effectively throttles progressive loading into a
409
+ // consistent train of commits. This also gives us an opportunity to restart to
410
+ // get to the completed state slightly earlier.
411
+ //
412
+ // If there's ambiguity due to batching it's resolved in preference of:
413
+ // 1) "delayed", 2) "initial render", 3) "retry".
414
+ //
415
+ // We want to ensure that a "busy" state doesn't get force committed. We want to
416
+ // ensure that new initial loading states can commit as soon as possible.
417
+ suspenseBoundary.flags |= ShouldCapture;
418
+ // TODO: I think we can remove this, since we now use `DidCapture` in
419
+ // the begin phase to prevent an early bailout.
420
+ suspenseBoundary.lanes = rootRenderLanes;
421
+ return suspenseBoundary;
422
}
423
424
function throwException(
@@ -458,13 +457,16 @@ function throwException(
457
}
458
459
// Schedule the nearest Suspense to re-render the timed out view.
461
- const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
462
- returnFiber,
463
- sourceFiber,
464
- root,
465
- rootRenderLanes,
466
- );
460
+ const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
461
if (suspenseBoundary !== null) {
462
+ suspenseBoundary.flags &= ~ForceClientRender;
463
+ markSuspenseBoundaryShouldCapture(
464
+ suspenseBoundary,
465
+ returnFiber,
466
+ sourceFiber,
467
+ root,
468
+ rootRenderLanes,
469
+ );
470
attachWakeableListeners(
471
suspenseBoundary,
472
root,
@@ -487,20 +489,24 @@ function throwException(
489
} else {
490
// This is a regular error, not a Suspense wakeable.
491
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
492
+ const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
493
// If the error was thrown during hydration, we may be able to recover by
494
// discarding the dehydrated content and switching to a client render.
495
// Instead of surfacing the error, find the nearest Suspense boundary
496
// and render it again without hydration.
494
- const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
495
- returnFiber,
496
- sourceFiber,
497
- root,
498
- rootRenderLanes,
499
- );
497
if (suspenseBoundary !== null) {
501
- // Set a flag to indicate that we should try rendering the normal
502
- // children again, not the fallback.
503
- suspenseBoundary.flags |= ForceClientRender;
498
+ if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
499
+ // Set a flag to indicate that we should try rendering the normal
500
+ // children again, not the fallback.
501
+ suspenseBoundary.flags |= ForceClientRender;
502
+ }
503
+ markSuspenseBoundaryShouldCapture(
504
+ suspenseBoundary,
505
+ returnFiber,
506
+ sourceFiber,
507
+ root,
508
+ rootRenderLanes,
509
+ );
510
return;
511
}
512
} else {
packages/react-reconciler/src/ReactFiberThrow.old.js
+154
-148
@@ -268,158 +268,157 @@ function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
268
}
269
}
270
271
-function markNearestSuspenseBoundaryShouldCapture(
272
- returnFiber: Fiber,
273
- sourceFiber: Fiber,
274
- root: FiberRoot,
275
- rootRenderLanes: Lanes,
276
-): Fiber | null {
271
+function getNearestSuspenseBoundaryToCapture(returnFiber: Fiber) {
272
+ let node = returnFiber;
273
const hasInvisibleParentBoundary = hasSuspenseContext(
274
suspenseStackCursor.current,
275
(InvisibleParentSuspenseContext: SuspenseContext),
276
);
281
- let node = returnFiber;
277
do {
278
if (
279
node.tag === SuspenseComponent &&
280
shouldCaptureSuspense(node, hasInvisibleParentBoundary)
281
) {
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;
282
+ return node;
283
+ }
284
+ // This boundary already captured during this render. Continue to the next
285
+ // boundary.
286
+ node = node.return;
287
+ } while (node !== null);
288
+ return null;
289
+}
290
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);
291
+function markSuspenseBoundaryShouldCapture(
292
+ suspenseBoundary: Fiber,
293
+ returnFiber: Fiber,
294
+ sourceFiber: Fiber,
295
+ root: FiberRoot,
296
+ rootRenderLanes: Lanes,
297
+): Fiber | null {
298
+ // This marks a Suspense boundary so that when we're unwinding the stack,
299
+ // it captures the suspended "exception" and does a second (fallback) pass.
300
+ if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
301
+ // Legacy Mode Suspense
302
+ //
303
+ // If the boundary is in legacy mode, we should *not*
304
+ // suspend the commit. Pretend as if the suspended component rendered
305
+ // null and keep rendering. When the Suspense boundary completes,
306
+ // we'll do a second pass to render the fallback.
307
+ if (suspenseBoundary === returnFiber) {
308
+ // Special case where we suspended while reconciling the children of
309
+ // a Suspense boundary's inner Offscreen wrapper fiber. This happens
310
+ // when a React.lazy component is a direct child of a
311
+ // Suspense boundary.
312
+ //
313
+ // Suspense boundaries are implemented as multiple fibers, but they
314
+ // are a single conceptual unit. The legacy mode behavior where we
315
+ // pretend the suspended fiber committed as `null` won't work,
316
+ // because in this case the "suspended" fiber is the inner
317
+ // Offscreen wrapper.
318
+ //
319
+ // Because the contents of the boundary haven't started rendering
320
+ // yet (i.e. nothing in the tree has partially rendered) we can
321
+ // switch to the regular, concurrent mode behavior: mark the
322
+ // boundary with ShouldCapture and enter the unwind phase.
323
+ suspenseBoundary.flags |= ShouldCapture;
324
+ } else {
325
+ suspenseBoundary.flags |= DidCapture;
326
+ sourceFiber.flags |= ForceUpdateForLegacySuspense;
327
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
- }
328
+ // We're going to commit this fiber even though it didn't complete.
329
+ // But we shouldn't call any lifecycle methods or callbacks. Remove
330
+ // all lifecycle effect tags.
331
+ sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
332
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
- }
333
+ if (supportsPersistence && enablePersistentOffscreenHostContainer) {
334
+ // Another legacy Suspense quirk. In persistent mode, if this is the
335
+ // initial mount, override the props of the host container to hide
336
+ // its contents.
337
+ const currentSuspenseBoundary = suspenseBoundary.alternate;
338
+ if (currentSuspenseBoundary === null) {
339
+ const offscreenFiber: Fiber = (suspenseBoundary.child: any);
340
+ const offscreenContainer = offscreenFiber.child;
341
+ if (offscreenContainer !== null) {
342
+ const children = offscreenContainer.memoizedProps.children;
343
+ const containerProps = getOffscreenContainerProps(
344
+ 'hidden',
345
+ children,
346
+ );
347
+ offscreenContainer.pendingProps = containerProps;
348
+ offscreenContainer.memoizedProps = containerProps;
349
}
350
+ }
351
+ }
352
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);
353
+ if (sourceFiber.tag === ClassComponent) {
354
+ const currentSourceFiber = sourceFiber.alternate;
355
+ if (currentSourceFiber === null) {
356
+ // This is a new mount. Change the tag so it's not mistaken for a
357
+ // completed class component. For example, we should not call
358
+ // componentWillUnmount if it is deleted.
359
+ sourceFiber.tag = IncompleteClassComponent;
360
+ } else {
361
+ // When we try rendering again, we should not reuse the current fiber,
362
+ // since it's known to be in an inconsistent state. Use a force update to
363
+ // prevent a bail out.
364
+ const update = createUpdate(NoTimestamp, SyncLane);
365
+ update.tag = ForceUpdate;
366
+ enqueueUpdate(sourceFiber, update, SyncLane);
367
}
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.
369
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;
370
+ // The source fiber did not complete. Mark it with Sync priority to
371
+ // indicate that it still has pending work.
372
+ sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
373
}
416
- // This boundary already captured during this render. Continue to the next
417
- // boundary.
418
- node = node.return;
419
- } while (node !== null);
374
+ return suspenseBoundary;
375
+ }
376
+ // Confirmed that the boundary is in a concurrent mode tree. Continue
377
+ // with the normal suspend path.
378
+ //
379
+ // After this we'll use a set of heuristics to determine whether this
380
+ // render pass will run to completion or restart or "suspend" the commit.
381
+ // The actual logic for this is spread out in different places.
382
+ //
383
+ // This first principle is that if we're going to suspend when we complete
384
+ // a root, then we should also restart if we get an update or ping that
385
+ // might unsuspend it, and vice versa. The only reason to suspend is
386
+ // because you think you might want to restart before committing. However,
387
+ // it doesn't make sense to restart only while in the period we're suspended.
388
+ //
389
+ // Restarting too aggressively is also not good because it starves out any
390
+ // intermediate loading state. So we use heuristics to determine when.
391
421
- // Could not find a Suspense boundary capable of capturing.
422
- return null;
392
+ // Suspense Heuristics
393
+ //
394
+ // If nothing threw a Promise or all the same fallbacks are already showing,
395
+ // then don't suspend/restart.
396
+ //
397
+ // If this is an initial render of a new tree of Suspense boundaries and
398
+ // those trigger a fallback, then don't suspend/restart. We want to ensure
399
+ // that we can show the initial loading state as quickly as possible.
400
+ //
401
+ // If we hit a "Delayed" case, such as when we'd switch from content back into
402
+ // a fallback, then we should always suspend/restart. Transitions apply
403
+ // to this case. If none is defined, JND is used instead.
404
+ //
405
+ // If we're already showing a fallback and it gets "retried", allowing us to show
406
+ // another level, but there's still an inner boundary that would show a fallback,
407
+ // then we suspend/restart for 500ms since the last time we showed a fallback
408
+ // anywhere in the tree. This effectively throttles progressive loading into a
409
+ // consistent train of commits. This also gives us an opportunity to restart to
410
+ // get to the completed state slightly earlier.
411
+ //
412
+ // If there's ambiguity due to batching it's resolved in preference of:
413
+ // 1) "delayed", 2) "initial render", 3) "retry".
414
+ //
415
+ // We want to ensure that a "busy" state doesn't get force committed. We want to
416
+ // ensure that new initial loading states can commit as soon as possible.
417
+ suspenseBoundary.flags |= ShouldCapture;
418
+ // TODO: I think we can remove this, since we now use `DidCapture` in
419
+ // the begin phase to prevent an early bailout.
420
+ suspenseBoundary.lanes = rootRenderLanes;
421
+ return suspenseBoundary;
422
}
423
424
function throwException(
@@ -458,13 +457,16 @@ function throwException(
457
}
458
459
// Schedule the nearest Suspense to re-render the timed out view.
461
- const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
462
- returnFiber,
463
- sourceFiber,
464
- root,
465
- rootRenderLanes,
466
- );
460
+ const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
461
if (suspenseBoundary !== null) {
462
+ suspenseBoundary.flags &= ~ForceClientRender;
463
+ markSuspenseBoundaryShouldCapture(
464
+ suspenseBoundary,
465
+ returnFiber,
466
+ sourceFiber,
467
+ root,
468
+ rootRenderLanes,
469
+ );
470
attachWakeableListeners(
471
suspenseBoundary,
472
root,
@@ -487,20 +489,24 @@ function throwException(
489
} else {
490
// This is a regular error, not a Suspense wakeable.
491
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
492
+ const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
493
// If the error was thrown during hydration, we may be able to recover by
494
// discarding the dehydrated content and switching to a client render.
495
// Instead of surfacing the error, find the nearest Suspense boundary
496
// and render it again without hydration.
494
- const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
495
- returnFiber,
496
- sourceFiber,
497
- root,
498
- rootRenderLanes,
499
- );
497
if (suspenseBoundary !== null) {
501
- // Set a flag to indicate that we should try rendering the normal
502
- // children again, not the fallback.
503
- suspenseBoundary.flags |= ForceClientRender;
498
+ if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
499
+ // Set a flag to indicate that we should try rendering the normal
500
+ // children again, not the fallback.
501
+ suspenseBoundary.flags |= ForceClientRender;
502
+ }
503
+ markSuspenseBoundaryShouldCapture(
504
+ suspenseBoundary,
505
+ returnFiber,
506
+ sourceFiber,
507
+ root,
508
+ rootRenderLanes,
509
+ );
510
return;
511
}
512
} else {
scripts/error-codes/codes.json
+2
-1
@@ -402,5 +402,6 @@
402
"414": "Did not expect this call in production. This is a bug in React. Please file an issue.",
403
"415": "Error parsing the data. It's probably an error code or network corruption.",
404
"416": "This environment don't support binary chunks.",
405
- "417": "React currently only supports piping to one writable stream."
405
+ "417": "React currently only supports piping to one writable stream.",
406
+ "418": "An error occurred during hydration. The server HTML was replaced with client content"
407
}