Skip to content

Commit 34f4ad6

Browse files
committed
Allow suspending in shell for all non-sync updates
Instead of erroring, we can delay the commit. The only time we'll continue to error when there's no parent Suspense boundary is during sync/discrete updates, because those are expected to produce a complete tree synchronously to maintain consistency with external state.
1 parent 863fa64 commit 34f4ad6

File tree

5 files changed

+49
-53
lines changed

5 files changed

+49
-53
lines changed

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ describe('useId', () => {
155155
textCache = new Map();
156156
}
157157

158-
test('suspending in the shell', async () => {
158+
test('suspending in the shell during hydration', async () => {
159159
const div = React.createRef(null);
160160

161161
function App() {
@@ -194,4 +194,23 @@ describe('useId', () => {
194194
expect(div.current).toBe(dehydratedDiv);
195195
expect(container.textContent).toBe('Shell');
196196
});
197+
198+
test('suspending in the shell during a normal client render', async () => {
199+
// Same as previous test but during a normal client render, no hydration
200+
function App() {
201+
return <AsyncText text="Shell" />;
202+
}
203+
204+
const root = ReactDOM.createRoot(container);
205+
await clientAct(async () => {
206+
root.render(<App />);
207+
});
208+
expect(Scheduler).toHaveYielded(['Suspend! [Shell]']);
209+
210+
await clientAct(async () => {
211+
await resolveText('Shell');
212+
});
213+
expect(Scheduler).toHaveYielded(['Shell']);
214+
expect(container.textContent).toBe('Shell');
215+
});
197216
});

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

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ import {
7979
includesSomeLane,
8080
mergeLanes,
8181
pickArbitraryLane,
82-
includesOnlyTransitions,
8382
includesSyncLane,
8483
} from './ReactFiberLane.new';
8584
import {
@@ -481,33 +480,25 @@ function throwException(
481480
attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
482481
return;
483482
} else {
484-
// No boundary was found. If we're inside startTransition, this is OK.
483+
// No boundary was found. Unless this is a sync update, this is OK.
485484
// We can suspend and wait for more data to arrive.
486485

487-
if (
488-
includesOnlyTransitions(rootRenderLanes) ||
489-
(getIsHydrating() && !includesSyncLane(rootRenderLanes))
490-
) {
491-
// This is a transition. Suspend. Since we're not activating a Suspense
492-
// boundary, this will unwind all the way to the root without performing
493-
// a second pass to render a fallback. (This is arguably how refresh
494-
// transitions should work, too, since we're not going to commit the
495-
// fallbacks anyway.)
486+
if (!includesSyncLane(rootRenderLanes)) {
487+
// This is not a sync update. Suspend. Since we're not activating a
488+
// Suspense boundary, this will unwind all the way to the root without
489+
// performing a second pass to render a fallback. (This is arguably how
490+
// refresh transitions should work, too, since we're not going to commit
491+
// the fallbacks anyway.)
496492
//
497493
// This case also applies to initial hydration.
498-
//
499-
// TODO: Maybe we should expand this branch to cover all non-sync
500-
// updates, including default.
501494
attachPingListener(root, wakeable, rootRenderLanes);
502495
renderDidSuspendDelayIfPossible();
503496
return;
504497
}
505498

506-
// We're not in a transition. We treat this case like an error because
507-
// discrete renders are expected to finish synchronously to maintain
508-
// consistency with external state.
509-
// TODO: This will error during non-transition concurrent renders, too.
510-
// But maybe it shouldn't?
499+
// This is a sync/discrete update. We treat this case like an error
500+
// because discrete renders are expected to produce a complete tree
501+
// synchronously to maintain consistency with external state.
511502

512503
// TODO: We should never call getComponentNameFromFiber in production.
513504
// Log a warning or something to prevent us from accidentally bundling it.

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

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ import {
7979
includesSomeLane,
8080
mergeLanes,
8181
pickArbitraryLane,
82-
includesOnlyTransitions,
8382
includesSyncLane,
8483
} from './ReactFiberLane.old';
8584
import {
@@ -481,33 +480,25 @@ function throwException(
481480
attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
482481
return;
483482
} else {
484-
// No boundary was found. If we're inside startTransition, this is OK.
483+
// No boundary was found. Unless this is a sync update, this is OK.
485484
// We can suspend and wait for more data to arrive.
486485

487-
if (
488-
includesOnlyTransitions(rootRenderLanes) ||
489-
(getIsHydrating() && !includesSyncLane(rootRenderLanes))
490-
) {
491-
// This is a transition. Suspend. Since we're not activating a Suspense
492-
// boundary, this will unwind all the way to the root without performing
493-
// a second pass to render a fallback. (This is arguably how refresh
494-
// transitions should work, too, since we're not going to commit the
495-
// fallbacks anyway.)
486+
if (!includesSyncLane(rootRenderLanes)) {
487+
// This is not a sync update. Suspend. Since we're not activating a
488+
// Suspense boundary, this will unwind all the way to the root without
489+
// performing a second pass to render a fallback. (This is arguably how
490+
// refresh transitions should work, too, since we're not going to commit
491+
// the fallbacks anyway.)
496492
//
497493
// This case also applies to initial hydration.
498-
//
499-
// TODO: Maybe we should expand this branch to cover all non-sync
500-
// updates, including default.
501494
attachPingListener(root, wakeable, rootRenderLanes);
502495
renderDidSuspendDelayIfPossible();
503496
return;
504497
}
505498

506-
// We're not in a transition. We treat this case like an error because
507-
// discrete renders are expected to finish synchronously to maintain
508-
// consistency with external state.
509-
// TODO: This will error during non-transition concurrent renders, too.
510-
// But maybe it shouldn't?
499+
// This is a sync/discrete update. We treat this case like an error
500+
// because discrete renders are expected to produce a complete tree
501+
// synchronously to maintain consistency with external state.
511502

512503
// TODO: We should never call getComponentNameFromFiber in production.
513504
// Log a warning or something to prevent us from accidentally bundling it.

packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -389,17 +389,6 @@ describe('ReactSuspense', () => {
389389
expect(root).toMatchRenderedOutput('Hi');
390390
});
391391

392-
it('throws if tree suspends and none of the Suspense ancestors have a boundary', () => {
393-
ReactTestRenderer.create(<AsyncText text="Hi" ms={1000} />, {
394-
unstable_isConcurrent: true,
395-
});
396-
397-
expect(Scheduler).toFlushAndThrow(
398-
'AsyncText suspended while rendering, but no fallback UI was specified.',
399-
);
400-
expect(Scheduler).toHaveYielded(['Suspend! [Hi]', 'Suspend! [Hi]']);
401-
});
402-
403392
it('updates memoized child of suspense component when context updates (simple memo)', () => {
404393
const {useContext, createContext, useState, memo} = React;
405394

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,9 +1003,15 @@ describe('ReactSuspenseWithNoopRenderer', () => {
10031003
});
10041004

10051005
// @gate enableCache
1006-
it('throws a helpful error when an update is suspends without a placeholder', () => {
1007-
ReactNoop.render(<AsyncText text="Async" />);
1008-
expect(Scheduler).toFlushAndThrow(
1006+
it('errors when an update suspends without a placeholder during a sync update', () => {
1007+
// This is an error because sync/discrete updates are expected to produce
1008+
// a complete tree immediately to maintain consistency with external state
1009+
// — we can't delay the commit.
1010+
expect(() => {
1011+
ReactNoop.flushSync(() => {
1012+
ReactNoop.render(<AsyncText text="Async" />);
1013+
});
1014+
}).toThrow(
10091015
'AsyncText suspended while rendering, but no fallback UI was specified.',
10101016
);
10111017
});

0 commit comments

Comments
 (0)