Skip to content

Commit 9ecf84e

Browse files
acdlitesebmarkbage
andauthored
Bugfix: Suspending in shell during discrete update (#25495)
Fixes a bug that happens when you suspend in the shell (the part of the tree that is not wrapped in a Suspense boundary) during a discrete update. There were two underyling issues. One was just a mistake: RootDidNotComplete needs to be handled in both renderRootConcurrent and renderRootSync, but it was only handled in renderRootConcurrent. I did it this way because I thought this path was unreachable during a sync update, but I neglected to consider that renderRootSync is sometimes called for non-concurrent lanes, like when recovering from an error, or patching up a mutation to an external store. After I fixed that oversight, the other issue is that we intentionally error if the shell suspends during a sync update. The idea was that you should either wrap the tree in a Suspense boundary, or you should mark the update as a transition to allow React to suspend. However, this did not take into account selective hydration, which can force a sync render before anything has even committed. There's no way in that case to wrap the update in startTransition. Our solution for now is to remove the error that happens when you suspend in the shell during a sync update — even for discrete updates. We will likely revisit this in the future. One appealing possibility is to commit the whole root in an inert state, as if it were a hidden Offscreen tree. Co-authored-by: Sebastian Markbåge <[email protected]> Co-authored-by: Sebastian Markbåge <[email protected]>
1 parent 54f297a commit 9ecf84e

6 files changed

+138
-59
lines changed

packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js

+77
Original file line numberDiff line numberDiff line change
@@ -1695,4 +1695,81 @@ describe('ReactDOMServerSelectiveHydration', () => {
16951695
expect(triggeredParent).toBe(false);
16961696
expect(triggeredChild).toBe(false);
16971697
});
1698+
1699+
it('can attempt sync hydration if suspended root is still concurrently rendering', async () => {
1700+
let suspend = false;
1701+
let resolve;
1702+
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
1703+
function Child({text}) {
1704+
if (suspend) {
1705+
throw promise;
1706+
}
1707+
Scheduler.unstable_yieldValue(text);
1708+
return (
1709+
<span
1710+
onClick={e => {
1711+
e.preventDefault();
1712+
Scheduler.unstable_yieldValue('Clicked ' + text);
1713+
}}>
1714+
{text}
1715+
</span>
1716+
);
1717+
}
1718+
1719+
function App() {
1720+
Scheduler.unstable_yieldValue('App');
1721+
return (
1722+
<div>
1723+
<Child text="A" />
1724+
</div>
1725+
);
1726+
}
1727+
1728+
const finalHTML = ReactDOMServer.renderToString(<App />);
1729+
1730+
expect(Scheduler).toHaveYielded(['App', 'A']);
1731+
1732+
const container = document.createElement('div');
1733+
// We need this to be in the document since we'll dispatch events on it.
1734+
document.body.appendChild(container);
1735+
1736+
container.innerHTML = finalHTML;
1737+
1738+
const span = container.getElementsByTagName('span')[0];
1739+
1740+
// We suspend on the client.
1741+
suspend = true;
1742+
1743+
React.startTransition(() => {
1744+
ReactDOMClient.hydrateRoot(container, <App />);
1745+
});
1746+
expect(Scheduler).toFlushAndYieldThrough(['App']);
1747+
1748+
// This should attempt to synchronously hydrate the root, then pause
1749+
// because it still suspended
1750+
const result = dispatchClickEvent(span);
1751+
expect(Scheduler).toHaveYielded(['App']);
1752+
// The event should not have been cancelled because we didn't hydrate.
1753+
expect(result).toBe(true);
1754+
1755+
// Finish loading the data
1756+
await act(async () => {
1757+
suspend = false;
1758+
await resolve();
1759+
});
1760+
1761+
// The app should have successfully hydrated and rendered
1762+
if (
1763+
gate(
1764+
flags =>
1765+
flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay,
1766+
)
1767+
) {
1768+
expect(Scheduler).toHaveYielded(['App', 'A']);
1769+
} else {
1770+
expect(Scheduler).toHaveYielded(['App', 'A', 'Clicked A']);
1771+
}
1772+
1773+
document.body.removeChild(container);
1774+
});
16981775
});

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

+16-22
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ import {
6969
includesSomeLane,
7070
mergeLanes,
7171
pickArbitraryLane,
72-
includesSyncLane,
7372
} from './ReactFiberLane.new';
7473
import {
7574
getIsHydrating,
7675
markDidThrowWhileHydratingDEV,
7776
queueHydrationError,
7877
} from './ReactFiberHydrationContext.new';
78+
import {ConcurrentRoot} from './ReactRootTags';
7979

8080
function createRootErrorUpdate(
8181
fiber: Fiber,
@@ -421,32 +421,26 @@ function throwException(
421421
// No boundary was found. Unless this is a sync update, this is OK.
422422
// We can suspend and wait for more data to arrive.
423423

424-
if (!includesSyncLane(rootRenderLanes)) {
425-
// This is not a sync update. Suspend. Since we're not activating a
426-
// Suspense boundary, this will unwind all the way to the root without
427-
// performing a second pass to render a fallback. (This is arguably how
428-
// refresh transitions should work, too, since we're not going to commit
429-
// the fallbacks anyway.)
424+
if (root.tag === ConcurrentRoot) {
425+
// In a concurrent root, suspending without a Suspense boundary is
426+
// allowed. It will suspend indefinitely without committing.
430427
//
431-
// This case also applies to initial hydration.
428+
// TODO: Should we have different behavior for discrete updates? What
429+
// about flushSync? Maybe it should put the tree into an inert state,
430+
// and potentially log a warning. Revisit this for a future release.
432431
attachPingListener(root, wakeable, rootRenderLanes);
433432
renderDidSuspendDelayIfPossible();
434433
return;
434+
} else {
435+
// In a legacy root, suspending without a boundary is always an error.
436+
const uncaughtSuspenseError = new Error(
437+
'A component suspended while responding to synchronous input. This ' +
438+
'will cause the UI to be replaced with a loading indicator. To ' +
439+
'fix, updates that suspend should be wrapped ' +
440+
'with startTransition.',
441+
);
442+
value = uncaughtSuspenseError;
435443
}
436-
437-
// This is a sync/discrete update. We treat this case like an error
438-
// because discrete renders are expected to produce a complete tree
439-
// synchronously to maintain consistency with external state.
440-
const uncaughtSuspenseError = new Error(
441-
'A component suspended while responding to synchronous input. This ' +
442-
'will cause the UI to be replaced with a loading indicator. To ' +
443-
'fix, updates that suspend should be wrapped ' +
444-
'with startTransition.',
445-
);
446-
447-
// If we're outside a transition, fall through to the regular error path.
448-
// The error will be caught by the nearest suspense boundary.
449-
value = uncaughtSuspenseError;
450444
}
451445
} else {
452446
// This is a regular error, not a Suspense wakeable.

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

+16-22
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ import {
6969
includesSomeLane,
7070
mergeLanes,
7171
pickArbitraryLane,
72-
includesSyncLane,
7372
} from './ReactFiberLane.old';
7473
import {
7574
getIsHydrating,
7675
markDidThrowWhileHydratingDEV,
7776
queueHydrationError,
7877
} from './ReactFiberHydrationContext.old';
78+
import {ConcurrentRoot} from './ReactRootTags';
7979

8080
function createRootErrorUpdate(
8181
fiber: Fiber,
@@ -421,32 +421,26 @@ function throwException(
421421
// No boundary was found. Unless this is a sync update, this is OK.
422422
// We can suspend and wait for more data to arrive.
423423

424-
if (!includesSyncLane(rootRenderLanes)) {
425-
// This is not a sync update. Suspend. Since we're not activating a
426-
// Suspense boundary, this will unwind all the way to the root without
427-
// performing a second pass to render a fallback. (This is arguably how
428-
// refresh transitions should work, too, since we're not going to commit
429-
// the fallbacks anyway.)
424+
if (root.tag === ConcurrentRoot) {
425+
// In a concurrent root, suspending without a Suspense boundary is
426+
// allowed. It will suspend indefinitely without committing.
430427
//
431-
// This case also applies to initial hydration.
428+
// TODO: Should we have different behavior for discrete updates? What
429+
// about flushSync? Maybe it should put the tree into an inert state,
430+
// and potentially log a warning. Revisit this for a future release.
432431
attachPingListener(root, wakeable, rootRenderLanes);
433432
renderDidSuspendDelayIfPossible();
434433
return;
434+
} else {
435+
// In a legacy root, suspending without a boundary is always an error.
436+
const uncaughtSuspenseError = new Error(
437+
'A component suspended while responding to synchronous input. This ' +
438+
'will cause the UI to be replaced with a loading indicator. To ' +
439+
'fix, updates that suspend should be wrapped ' +
440+
'with startTransition.',
441+
);
442+
value = uncaughtSuspenseError;
435443
}
436-
437-
// This is a sync/discrete update. We treat this case like an error
438-
// because discrete renders are expected to produce a complete tree
439-
// synchronously to maintain consistency with external state.
440-
const uncaughtSuspenseError = new Error(
441-
'A component suspended while responding to synchronous input. This ' +
442-
'will cause the UI to be replaced with a loading indicator. To ' +
443-
'fix, updates that suspend should be wrapped ' +
444-
'with startTransition.',
445-
);
446-
447-
// If we're outside a transition, fall through to the regular error path.
448-
// The error will be caught by the nearest suspense boundary.
449-
value = uncaughtSuspenseError;
450444
}
451445
} else {
452446
// This is a regular error, not a Suspense wakeable.

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

+9-5
Original file line numberDiff line numberDiff line change
@@ -1063,10 +1063,6 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
10631063
// The render unwound without completing the tree. This happens in special
10641064
// cases where need to exit the current render without producing a
10651065
// consistent tree or committing.
1066-
//
1067-
// This should only happen during a concurrent render, not a discrete or
1068-
// synchronous update. We should have already checked for this when we
1069-
// unwound the stack.
10701066
markRootSuspended(root, lanes);
10711067
} else {
10721068
// The render completed.
@@ -1111,6 +1107,9 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
11111107
ensureRootIsScheduled(root, now());
11121108
throw fatalError;
11131109
}
1110+
1111+
// FIXME: Need to check for RootDidNotComplete again. The factoring here
1112+
// isn't ideal.
11141113
}
11151114

11161115
// We now have a consistent tree. The next step is either to commit it,
@@ -1473,7 +1472,12 @@ function performSyncWorkOnRoot(root) {
14731472
}
14741473

14751474
if (exitStatus === RootDidNotComplete) {
1476-
throw new Error('Root did not complete. This is a bug in React.');
1475+
// The render unwound without completing the tree. This happens in special
1476+
// cases where need to exit the current render without producing a
1477+
// consistent tree or committing.
1478+
markRootSuspended(root, lanes);
1479+
ensureRootIsScheduled(root, now());
1480+
return null;
14771481
}
14781482

14791483
// We now have a consistent tree. Because this is a sync render, we

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

+9-5
Original file line numberDiff line numberDiff line change
@@ -1063,10 +1063,6 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
10631063
// The render unwound without completing the tree. This happens in special
10641064
// cases where need to exit the current render without producing a
10651065
// consistent tree or committing.
1066-
//
1067-
// This should only happen during a concurrent render, not a discrete or
1068-
// synchronous update. We should have already checked for this when we
1069-
// unwound the stack.
10701066
markRootSuspended(root, lanes);
10711067
} else {
10721068
// The render completed.
@@ -1111,6 +1107,9 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
11111107
ensureRootIsScheduled(root, now());
11121108
throw fatalError;
11131109
}
1110+
1111+
// FIXME: Need to check for RootDidNotComplete again. The factoring here
1112+
// isn't ideal.
11141113
}
11151114

11161115
// We now have a consistent tree. The next step is either to commit it,
@@ -1473,7 +1472,12 @@ function performSyncWorkOnRoot(root) {
14731472
}
14741473

14751474
if (exitStatus === RootDidNotComplete) {
1476-
throw new Error('Root did not complete. This is a bug in React.');
1475+
// The render unwound without completing the tree. This happens in special
1476+
// cases where need to exit the current render without producing a
1477+
// consistent tree or committing.
1478+
markRootSuspended(root, lanes);
1479+
ensureRootIsScheduled(root, now());
1480+
return null;
14771481
}
14781482

14791483
// We now have a consistent tree. Because this is a sync render, we

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js

+11-5
Original file line numberDiff line numberDiff line change
@@ -1012,15 +1012,21 @@ describe('ReactSuspenseWithNoopRenderer', () => {
10121012
});
10131013

10141014
// @gate enableCache
1015-
it('errors when an update suspends without a placeholder during a sync update', () => {
1016-
// This is an error because sync/discrete updates are expected to produce
1017-
// a complete tree immediately to maintain consistency with external state
1018-
// — we can't delay the commit.
1015+
it('in concurrent mode, does not error when an update suspends without a Suspense boundary during a sync update', () => {
1016+
// NOTE: We may change this to be a warning in the future.
10191017
expect(() => {
10201018
ReactNoop.flushSync(() => {
10211019
ReactNoop.render(<AsyncText text="Async" />);
10221020
});
1023-
}).toThrow('A component suspended while responding to synchronous input.');
1021+
}).not.toThrow();
1022+
});
1023+
1024+
// @gate enableCache
1025+
it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', () => {
1026+
const root = ReactNoop.createLegacyRoot();
1027+
expect(() => root.render(<AsyncText text="Async" />)).toThrow(
1028+
'A component suspended while responding to synchronous input.',
1029+
);
10241030
});
10251031

10261032
// @gate enableCache

0 commit comments

Comments
 (0)