@samitouri / QOS-React-2 / commits / 0ddd69d122

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 }