Skip to content

Commit 2d93c7e

Browse files
committed
Restart on suspend if return path has an update
Similar to the previous commit, if we suspend with a delay, and something in the return path has a pending update, we should abort the current render and switch to the update instead.
1 parent b4ff414 commit 2d93c7e

File tree

5 files changed

+158
-3
lines changed

5 files changed

+158
-3
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2064,7 +2064,7 @@ function updateDehydratedSuspenseComponent(
20642064
// render something, if we time out. Even if that requires us to delete everything and
20652065
// skip hydration.
20662066
// Delay having to do this as long as the suspense timeout allows us.
2067-
renderDidSuspendDelayIfPossible();
2067+
renderDidSuspendDelayIfPossible(workInProgress);
20682068
return retrySuspenseComponentWithoutHydrating(
20692069
current,
20702070
workInProgress,

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,7 @@ function completeWork(
922922
} else {
923923
// Otherwise, we're going to have to hide content so we should
924924
// suspend for longer if possible.
925-
renderDidSuspendDelayIfPossible();
925+
renderDidSuspendDelayIfPossible(workInProgress);
926926
}
927927
}
928928
}

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,14 @@ export function resetHooks(): void {
531531
// This is used to reset the state of this module when a component throws.
532532
// It's also called inside mountIndeterminateComponent if we determine the
533533
// component is a module-style component.
534+
535+
if (currentlyRenderingFiber !== null) {
536+
// Even though this component didn't complete, set the remaining time left
537+
// on this fiber. This is sometimes useful when suspending to determine if
538+
// there's a lower priority update that could "unsuspend."
539+
currentlyRenderingFiber.expirationTime = remainingExpirationTime;
540+
}
541+
534542
renderExpirationTime = NoWork;
535543
currentlyRenderingFiber = null;
536544

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,8 @@ function renderRoot(
956956
thrownValue,
957957
renderExpirationTime,
958958
);
959+
// TODO: This is not wrapped in a try-catch, so if the complete phase
960+
// throws, we won't capture it.
959961
workInProgress = completeUnitOfWork(sourceFiber);
960962
}
961963
} while (true);
@@ -1259,13 +1261,37 @@ export function renderDidSuspend(): void {
12591261
}
12601262
}
12611263

1262-
export function renderDidSuspendDelayIfPossible(): void {
1264+
export function renderDidSuspendDelayIfPossible(suspendedWork: Fiber): void {
12631265
if (
12641266
workInProgressRootExitStatus === RootIncomplete ||
12651267
workInProgressRootExitStatus === RootSuspended
12661268
) {
12671269
workInProgressRootExitStatus = RootSuspendedWithDelay;
12681270
}
1271+
1272+
if (workInProgressRoot !== null) {
1273+
// Check if the component that suspsended, or any components in the return
1274+
// path, have a pending update. If so, those updates might unsuspend us, so
1275+
// interrupt the current render and restart.
1276+
let nextAfterSuspendedTime = NoWork;
1277+
let fiber = suspendedWork;
1278+
while (fiber !== null) {
1279+
const updateExpirationTime = fiber.expirationTime;
1280+
if (updateExpirationTime > nextAfterSuspendedTime) {
1281+
nextAfterSuspendedTime = updateExpirationTime;
1282+
}
1283+
fiber = fiber.return;
1284+
}
1285+
1286+
if (nextAfterSuspendedTime !== NoWork) {
1287+
// Mark the current render as suspended, and then mark that there's a
1288+
// pending update.
1289+
// TODO: This should immediately interrupt the current render, instead
1290+
// of waiting until the next time we yield.
1291+
markRootSuspendedAtTime(workInProgressRoot, renderExpirationTime);
1292+
markRootUpdatedAtTime(workInProgressRoot, nextAfterSuspendedTime);
1293+
}
1294+
}
12691295
}
12701296

12711297
export function renderDidError() {

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

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,127 @@ describe('ReactSuspense', () => {
315315
},
316316
);
317317

318+
it(
319+
'interrupts current render when something suspends with a ' +
320+
"delay and we've already skipped over a lower priority update in " +
321+
'a parent',
322+
() => {
323+
function interrupt() {
324+
// React has a heuristic to batch all updates that occur within the same
325+
// event. This is a trick to circumvent that heuristic.
326+
ReactTestRenderer.create('whatever');
327+
}
328+
329+
function App({shouldSuspend, step}) {
330+
return (
331+
<>
332+
<Text text={`A${step}`} />
333+
<Suspense fallback={<Text text="Loading..." />}>
334+
{shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
335+
</Suspense>
336+
<Text text={`B${step}`} />
337+
<Text text={`C${step}`} />
338+
</>
339+
);
340+
}
341+
342+
const root = ReactTestRenderer.create(null, {
343+
unstable_isConcurrent: true,
344+
});
345+
346+
root.update(<App shouldSuspend={false} step={0} />);
347+
expect(Scheduler).toFlushAndYield(['A0', 'B0', 'C0']);
348+
expect(root).toMatchRenderedOutput('A0B0C0');
349+
350+
// This update will suspend.
351+
root.update(<App shouldSuspend={true} step={1} />);
352+
353+
// Need to move into the next async bucket.
354+
Scheduler.unstable_advanceTime(1000);
355+
// Do a bit of work, then interrupt to trigger a restart.
356+
expect(Scheduler).toFlushAndYieldThrough(['A1']);
357+
interrupt();
358+
359+
// Schedule another update. This will have lower priority because of
360+
// the interrupt trick above.
361+
root.update(<App shouldSuspend={false} step={2} />);
362+
363+
expect(Scheduler).toFlushAndYieldThrough([
364+
// Should have restarted the first update, because of the interruption
365+
'A1',
366+
'Suspend! [Async]',
367+
'Loading...',
368+
'B1',
369+
]);
370+
371+
// Should not have committed loading state
372+
expect(root).toMatchRenderedOutput('A0B0C0');
373+
374+
// After suspending, should abort the first update and switch to the
375+
// second update. So, C1 should not appear in the log.
376+
// TODO: This should work even if React does not yield to the main
377+
// thread. Should use same mechanism as selective hydration to interrupt
378+
// the render before the end of the current slice of work.
379+
expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
380+
381+
expect(root).toMatchRenderedOutput('A2B2C2');
382+
},
383+
);
384+
385+
it(
386+
'interrupts current render when something suspends with a ' +
387+
'delay, and a parent received an update after it completed',
388+
() => {
389+
function App({shouldSuspend, step}) {
390+
return (
391+
<>
392+
<Text text={`A${step}`} />
393+
<Suspense fallback={<Text text="Loading..." />}>
394+
{shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
395+
</Suspense>
396+
<Text text={`B${step}`} />
397+
<Text text={`C${step}`} />
398+
</>
399+
);
400+
}
401+
402+
const root = ReactTestRenderer.create(null, {
403+
unstable_isConcurrent: true,
404+
});
405+
406+
root.update(<App shouldSuspend={false} step={0} />);
407+
expect(Scheduler).toFlushAndYield(['A0', 'B0', 'C0']);
408+
expect(root).toMatchRenderedOutput('A0B0C0');
409+
410+
// This update will suspend.
411+
root.update(<App shouldSuspend={true} step={1} />);
412+
// Flush past the root, but stop before the async component.
413+
expect(Scheduler).toFlushAndYieldThrough(['A1']);
414+
415+
// Schedule an update on the root, which already completed.
416+
root.update(<App shouldSuspend={false} step={2} />);
417+
// We'll keep working on the existing update.
418+
expect(Scheduler).toFlushAndYieldThrough([
419+
// Now the async component suspends
420+
'Suspend! [Async]',
421+
'Loading...',
422+
'B1',
423+
]);
424+
425+
// Should not have committed loading state
426+
expect(root).toMatchRenderedOutput('A0B0C0');
427+
428+
// After suspending, should abort the first update and switch to the
429+
// second update. So, C1 should not appear in the log.
430+
// TODO: This should work even if React does not yield to the main
431+
// thread. Should use same mechanism as selective hydration to interrupt
432+
// the render before the end of the current slice of work.
433+
expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
434+
435+
expect(root).toMatchRenderedOutput('A2B2C2');
436+
},
437+
);
438+
318439
it('mounts a lazy class component in non-concurrent mode', async () => {
319440
class Class extends React.Component {
320441
componentDidMount() {

0 commit comments

Comments
 (0)