Skip to content

Commit 09461dc

Browse files
committed
Track the next unprocessed level globally
Instead of backtracking the return path. The main advantage over the backtracking approach is that we don't have to backtrack from the source fiber. (The main disadvantages are that it requires another module-level variable, and that it could include updates from unrelated sibling paths.)
1 parent 2d93c7e commit 09461dc

File tree

6 files changed

+159
-48
lines changed

6 files changed

+159
-48
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ import {
177177
retryDehydratedSuspenseBoundary,
178178
scheduleWork,
179179
renderDidSuspendDelayIfPossible,
180+
markUnprocessedUpdateTime,
180181
} from './ReactFiberWorkLoop';
181182

182183
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -2064,7 +2065,7 @@ function updateDehydratedSuspenseComponent(
20642065
// render something, if we time out. Even if that requires us to delete everything and
20652066
// skip hydration.
20662067
// Delay having to do this as long as the suspense timeout allows us.
2067-
renderDidSuspendDelayIfPossible(workInProgress);
2068+
renderDidSuspendDelayIfPossible();
20682069
return retrySuspenseComponentWithoutHydrating(
20692070
current,
20702071
workInProgress,
@@ -2708,6 +2709,11 @@ function bailoutOnAlreadyFinishedWork(
27082709
stopProfilerTimerIfRunning(workInProgress);
27092710
}
27102711

2712+
const updateExpirationTime = workInProgress.expirationTime;
2713+
if (updateExpirationTime !== NoWork) {
2714+
markUnprocessedUpdateTime(updateExpirationTime);
2715+
}
2716+
27112717
// Check if the children have any pending work.
27122718
const childExpirationTime = workInProgress.childExpirationTime;
27132719
if (childExpirationTime < renderExpirationTime) {

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(workInProgress);
925+
renderDidSuspendDelayIfPossible();
926926
}
927927
}
928928
}

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
warnIfNotCurrentlyActingUpdatesInDev,
4444
warnIfNotScopedWithMatchingAct,
4545
markRenderEventTimeAndConfig,
46+
markUnprocessedUpdateTime,
4647
} from './ReactFiberWorkLoop';
4748

4849
import invariant from 'shared/invariant';
@@ -532,13 +533,6 @@ export function resetHooks(): void {
532533
// It's also called inside mountIndeterminateComponent if we determine the
533534
// component is a module-style component.
534535

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-
542536
renderExpirationTime = NoWork;
543537
currentlyRenderingFiber = null;
544538

@@ -763,6 +757,7 @@ function updateReducer<S, I, A>(
763757
// Update the remaining priority in the queue.
764758
if (updateExpirationTime > remainingExpirationTime) {
765759
remainingExpirationTime = updateExpirationTime;
760+
markUnprocessedUpdateTime(remainingExpirationTime);
766761
}
767762
} else {
768763
// This update does have sufficient priority.

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
226226
let workInProgressRootLatestProcessedExpirationTime: ExpirationTime = Sync;
227227
let workInProgressRootLatestSuspenseTimeout: ExpirationTime = Sync;
228228
let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
229+
// The work left over by components that were visited during this render. Only
230+
// includes unprocessed updates, not work in bailed out children.
231+
let workInProgressRootNextUnprocessedUpdateTime: ExpirationTime = NoWork;
232+
229233
// If we're pinged while rendering we don't always restart immediately.
230234
// This flag determines if it might be worthwhile to restart if an opportunity
231235
// happens latere.
@@ -484,21 +488,27 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) {
484488
}
485489

486490
if (root !== null) {
487-
if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
488-
// The root already suspended with a delay, which means this render
489-
// definitely won't finish. Since we have a new update, let's mark it as
490-
// suspended now, right before marking the incoming update. This has the
491-
// effect of interrupting the current render and switching to the update.
492-
// TODO: This happens to work when receiving an update during the render
493-
// phase, because of the trick inside computeExpirationForFiber to
494-
// subtract 1 from `renderExpirationTime` to move it into a
495-
// separate bucket. But we should probably model it with an exception,
496-
// using the same mechanism we use to force hydration of a subtree.
497-
// TODO: This does not account for low pri updates that were already
498-
// scheduled before the root started rendering. Need to track the next
499-
// pending expiration time (perhaps by backtracking the return path) and
500-
// then trigger a restart in the `renderDidSuspendDelayIfPossible` path.
501-
markRootSuspendedAtTime(root, renderExpirationTime);
491+
if (workInProgressRoot === root) {
492+
// Received an update to a tree that's in the middle of rendering. Mark
493+
// that's unprocessed work on this root.
494+
markUnprocessedUpdateTime(expirationTime);
495+
496+
if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
497+
// The root already suspended with a delay, which means this render
498+
// definitely won't finish. Since we have a new update, let's mark it as
499+
// suspended now, right before marking the incoming update. This has the
500+
// effect of interrupting the current render and switching to the update.
501+
// TODO: This happens to work when receiving an update during the render
502+
// phase, because of the trick inside computeExpirationForFiber to
503+
// subtract 1 from `renderExpirationTime` to move it into a
504+
// separate bucket. But we should probably model it with an exception,
505+
// using the same mechanism we use to force hydration of a subtree.
506+
// TODO: This does not account for low pri updates that were already
507+
// scheduled before the root started rendering. Need to track the next
508+
// pending expiration time (perhaps by backtracking the return path) and
509+
// then trigger a restart in the `renderDidSuspendDelayIfPossible` path.
510+
markRootSuspendedAtTime(root, renderExpirationTime);
511+
}
502512
}
503513
// Mark that the root has a pending update.
504514
markRootUpdatedAtTime(root, expirationTime);
@@ -856,6 +866,7 @@ function prepareFreshStack(root, expirationTime) {
856866
workInProgressRootLatestProcessedExpirationTime = Sync;
857867
workInProgressRootLatestSuspenseTimeout = Sync;
858868
workInProgressRootCanSuspendUsingConfig = null;
869+
workInProgressRootNextUnprocessedUpdateTime = NoWork;
859870
workInProgressRootHasPendingPing = false;
860871

861872
if (enableSchedulerTracing) {
@@ -1255,42 +1266,42 @@ export function markRenderEventTimeAndConfig(
12551266
}
12561267
}
12571268

1269+
export function markUnprocessedUpdateTime(
1270+
expirationTime: ExpirationTime,
1271+
): void {
1272+
if (expirationTime > workInProgressRootNextUnprocessedUpdateTime) {
1273+
workInProgressRootNextUnprocessedUpdateTime = expirationTime;
1274+
}
1275+
}
1276+
12581277
export function renderDidSuspend(): void {
12591278
if (workInProgressRootExitStatus === RootIncomplete) {
12601279
workInProgressRootExitStatus = RootSuspended;
12611280
}
12621281
}
12631282

1264-
export function renderDidSuspendDelayIfPossible(suspendedWork: Fiber): void {
1283+
export function renderDidSuspendDelayIfPossible(): void {
12651284
if (
12661285
workInProgressRootExitStatus === RootIncomplete ||
12671286
workInProgressRootExitStatus === RootSuspended
12681287
) {
12691288
workInProgressRootExitStatus = RootSuspendedWithDelay;
12701289
}
12711290

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-
}
1291+
// Check if there's a lower priority update somewhere else in the tree.
1292+
if (
1293+
workInProgressRootNextUnprocessedUpdateTime !== NoWork &&
1294+
workInProgressRoot !== null
1295+
) {
1296+
// Mark the current render as suspended, and then mark that there's a
1297+
// pending update.
1298+
// TODO: This should immediately interrupt the current render, instead
1299+
// of waiting until the next time we yield.
1300+
markRootSuspendedAtTime(workInProgressRoot, renderExpirationTime);
1301+
markRootUpdatedAtTime(
1302+
workInProgressRoot,
1303+
workInProgressRootNextUnprocessedUpdateTime,
1304+
);
12941305
}
12951306
}
12961307

packages/react-reconciler/src/ReactUpdateQueue.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ import {
103103
} from 'shared/ReactFeatureFlags';
104104

105105
import {StrictMode} from './ReactTypeOfMode';
106-
import {markRenderEventTimeAndConfig} from './ReactFiberWorkLoop';
106+
import {
107+
markRenderEventTimeAndConfig,
108+
markUnprocessedUpdateTime,
109+
} from './ReactFiberWorkLoop';
107110

108111
import invariant from 'shared/invariant';
109112
import warningWithoutStack from 'shared/warningWithoutStack';
@@ -580,6 +583,7 @@ export function processUpdateQueue<State>(
580583
// dealt with the props. Context in components that specify
581584
// shouldComponentUpdate is tricky; but we'll have to account for
582585
// that regardless.
586+
markUnprocessedUpdateTime(newExpirationTime);
583587
workInProgress.expirationTime = newExpirationTime;
584588
workInProgress.memoizedState = resultState;
585589

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,101 @@ describe('ReactSuspense', () => {
382382
},
383383
);
384384

385+
it(
386+
'interrupts current render when something suspends with a ' +
387+
"delay and we've already bailed out lower priority update in " +
388+
'a parent',
389+
async () => {
390+
// This is similar to the previous test case, except this covers when
391+
// React completely bails out on the parent component, without processing
392+
// the update queue.
393+
394+
const {useState} = React;
395+
396+
function interrupt() {
397+
// React has a heuristic to batch all updates that occur within the same
398+
// event. This is a trick to circumvent that heuristic.
399+
ReactTestRenderer.create('whatever');
400+
}
401+
402+
let setShouldSuspend;
403+
function Async() {
404+
const [shouldSuspend, _setShouldSuspend] = useState(false);
405+
setShouldSuspend = _setShouldSuspend;
406+
return (
407+
<>
408+
<Text text="A" />
409+
<Suspense fallback={<Text text="Loading..." />}>
410+
{shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
411+
</Suspense>
412+
<Text text="B" />
413+
<Text text="C" />
414+
</>
415+
);
416+
}
417+
418+
let setShouldHideInParent;
419+
function App() {
420+
const [shouldHideInParent, _setShouldHideInParent] = useState(false);
421+
setShouldHideInParent = _setShouldHideInParent;
422+
Scheduler.unstable_yieldValue(
423+
'shouldHideInParent: ' + shouldHideInParent,
424+
);
425+
return shouldHideInParent ? <Text text="(empty)" /> : <Async />;
426+
}
427+
428+
const root = ReactTestRenderer.create(null, {
429+
unstable_isConcurrent: true,
430+
});
431+
432+
await ReactTestRenderer.act(async () => {
433+
root.update(<App />);
434+
expect(Scheduler).toFlushAndYield([
435+
'shouldHideInParent: false',
436+
'A',
437+
'B',
438+
'C',
439+
]);
440+
expect(root).toMatchRenderedOutput('ABC');
441+
442+
// This update will suspend.
443+
setShouldSuspend(true);
444+
445+
// Need to move into the next async bucket.
446+
Scheduler.unstable_advanceTime(1000);
447+
// Do a bit of work, then interrupt to trigger a restart.
448+
expect(Scheduler).toFlushAndYieldThrough(['A']);
449+
interrupt();
450+
// Should not have committed loading state
451+
expect(root).toMatchRenderedOutput('ABC');
452+
453+
// Schedule another update. This will have lower priority because of
454+
// the interrupt trick above.
455+
setShouldHideInParent(true);
456+
457+
expect(Scheduler).toFlushAndYieldThrough([
458+
// Should have restarted the first update, because of the interruption
459+
'A',
460+
'Suspend! [Async]',
461+
'Loading...',
462+
'B',
463+
]);
464+
465+
// Should not have committed loading state
466+
expect(root).toMatchRenderedOutput('ABC');
467+
468+
// After suspending, should abort the first update and switch to the
469+
// second update.
470+
expect(Scheduler).toFlushAndYield([
471+
'shouldHideInParent: true',
472+
'(empty)',
473+
]);
474+
475+
expect(root).toMatchRenderedOutput('(empty)');
476+
});
477+
},
478+
);
479+
385480
it(
386481
'interrupts current render when something suspends with a ' +
387482
'delay, and a parent received an update after it completed',

0 commit comments

Comments
 (0)