Skip to content

Commit d13f5b9

Browse files
authored
Experiment: Unsuspend all lanes on update (#20660)
Adds a feature flag to tweak the internal heuristic used to "unsuspend" lanes when a new update comes in. A lane is "suspended" if we couldn't finish rendering it because it was missing data, and we chose not to commit the fallback. (In this context, "suspended" does not include updates that finished with a fallback.) When we receive new data in the form of an update, we need to retry rendering the suspended lanes, since the new data may have unblocked the previously suspended work. For example, the new update could navigate back to an already loaded route. It's impractical to retry every combination of suspended lanes, so we need some heuristic that decides which lanes to retry and in which order. The existing heuristic roughly approximates the old Expiration Times model. It unsuspends all lower priority lanes, but leaves higher priority lanes suspended. Then when we start rendering, we choose the lanes that have the highest LanePriority and render those -- and then we add to that all the lanes that are highher priority. If this sounds terribly confusing, it's because it barely makes sense. (It made more sense in the Expiration Times world, I promise, but it was still confusing.) I don't think it's worth me trying to explain the old behavior too much because the point here is that we can replace it with something simpler. The new heurstic is to unsuspend all suspended lanes whenever there's an update. This is effectively what we already do except in a few very specific edge cases, ever since we removed the delayed suspense feature from everything that's not a refresh transition. We can optimize this in the future to only unsuspend lanes that are either 1) in the `lanes` or `subtreeLanes` of the node that was updated, or 2) in the `lanes` of the return path of the node that was updated. This would exclude lanes that are only located in unrelated sibling trees. But, this optimization wouldn't be useful currently because we assign the same transition lane to all transitions. It will become relevant again once we start assigning arbitrary lanes to transitions -- but that in turn requires us to implement entanglement of overlapping transitions, one of our planned projects. So to sum up: the goal here is to remove the weird edge cases and switch to a simpler model, on top of which we can make more substantial improvements. I put it behind a flag so I can run an A/B test and confirm it doesn't cause a regression.
1 parent db5945e commit d13f5b9

14 files changed

+106
-43
lines changed

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

+34-6
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ export type Lane = number;
3636
export type LaneMap<T> = Array<T>;
3737

3838
import invariant from 'shared/invariant';
39-
import {enableCache} from 'shared/ReactFeatureFlags';
39+
import {
40+
enableCache,
41+
enableTransitionEntanglement,
42+
} from 'shared/ReactFeatureFlags';
4043

4144
import {
4245
ImmediatePriority as ImmediateSchedulerPriority,
@@ -306,9 +309,15 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
306309
return NoLanes;
307310
}
308311

309-
// If there are higher priority lanes, we'll include them even if they
310-
// are suspended.
311-
nextLanes = pendingLanes & getEqualOrHigherPriorityLanes(nextLanes);
312+
if (enableTransitionEntanglement) {
313+
// We don't need to include higher priority lanes, because in this
314+
// experiment we always unsuspend all transitions whenever we receive
315+
// an update.
316+
} else {
317+
// If there are higher priority lanes, we'll include them even if they
318+
// are suspended.
319+
nextLanes = pendingLanes & getEqualOrHigherPriorityLanes(nextLanes);
320+
}
312321

313322
// If we're already in the middle of a render, switching lanes will interrupt
314323
// it and we'll lose our progress. We should only do this if the new lanes are
@@ -673,8 +682,27 @@ export function markRootUpdated(
673682
// Unsuspend any update at equal or lower priority.
674683
const higherPriorityLanes = updateLane - 1; // Turns 0b1000 into 0b0111
675684

676-
root.suspendedLanes &= higherPriorityLanes;
677-
root.pingedLanes &= higherPriorityLanes;
685+
if (enableTransitionEntanglement) {
686+
// If there are any suspended transitions, it's possible this new update
687+
// could unblock them. Clear the suspended lanes so that we can try rendering
688+
// them again.
689+
//
690+
// TODO: We really only need to unsuspend only lanes that are in the
691+
// `subtreeLanes` of the updated fiber, or the update lanes of the return
692+
// path. This would exclude suspended updates in an unrelated sibling tree,
693+
// since there's no way for this update to unblock it.
694+
//
695+
// We don't do this if the incoming update is idle, because we never process
696+
// idle updates until after all the regular updates have finished; there's no
697+
// way it could unblock a transition.
698+
if ((updateLane & IdleLanes) === NoLanes) {
699+
root.suspendedLanes = NoLanes;
700+
root.pingedLanes = NoLanes;
701+
}
702+
} else {
703+
root.suspendedLanes &= higherPriorityLanes;
704+
root.pingedLanes &= higherPriorityLanes;
705+
}
678706

679707
const eventTimes = root.eventTimes;
680708
const index = laneToIndex(updateLane);

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

+34-6
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ export type Lane = number;
3636
export type LaneMap<T> = Array<T>;
3737

3838
import invariant from 'shared/invariant';
39-
import {enableCache} from 'shared/ReactFeatureFlags';
39+
import {
40+
enableCache,
41+
enableTransitionEntanglement,
42+
} from 'shared/ReactFeatureFlags';
4043

4144
import {
4245
ImmediatePriority as ImmediateSchedulerPriority,
@@ -306,9 +309,15 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
306309
return NoLanes;
307310
}
308311

309-
// If there are higher priority lanes, we'll include them even if they
310-
// are suspended.
311-
nextLanes = pendingLanes & getEqualOrHigherPriorityLanes(nextLanes);
312+
if (enableTransitionEntanglement) {
313+
// We don't need to include higher priority lanes, because in this
314+
// experiment we always unsuspend all transitions whenever we receive
315+
// an update.
316+
} else {
317+
// If there are higher priority lanes, we'll include them even if they
318+
// are suspended.
319+
nextLanes = pendingLanes & getEqualOrHigherPriorityLanes(nextLanes);
320+
}
312321

313322
// If we're already in the middle of a render, switching lanes will interrupt
314323
// it and we'll lose our progress. We should only do this if the new lanes are
@@ -673,8 +682,27 @@ export function markRootUpdated(
673682
// Unsuspend any update at equal or lower priority.
674683
const higherPriorityLanes = updateLane - 1; // Turns 0b1000 into 0b0111
675684

676-
root.suspendedLanes &= higherPriorityLanes;
677-
root.pingedLanes &= higherPriorityLanes;
685+
if (enableTransitionEntanglement) {
686+
// If there are any suspended transitions, it's possible this new update
687+
// could unblock them. Clear the suspended lanes so that we can try rendering
688+
// them again.
689+
//
690+
// TODO: We really only need to unsuspend only lanes that are in the
691+
// `subtreeLanes` of the updated fiber, or the update lanes of the return
692+
// path. This would exclude suspended updates in an unrelated sibling tree,
693+
// since there's no way for this update to unblock it.
694+
//
695+
// We don't do this if the incoming update is idle, because we never process
696+
// idle updates until after all the regular updates have finished; there's no
697+
// way it could unblock a transition.
698+
if ((updateLane & IdleLanes) === NoLanes) {
699+
root.suspendedLanes = NoLanes;
700+
root.pingedLanes = NoLanes;
701+
}
702+
} else {
703+
root.suspendedLanes &= higherPriorityLanes;
704+
root.pingedLanes &= higherPriorityLanes;
705+
}
678706

679707
const eventTimes = root.eventTimes;
680708
const index = laneToIndex(updateLane);

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

+13-7
Original file line numberDiff line numberDiff line change
@@ -733,13 +733,19 @@ describe('ReactExpiration', () => {
733733
// Both normal pri updates should have expired.
734734
expect(Scheduler).toFlushExpired([
735735
'Sibling',
736-
// Note: we also flushed the high pri update here, because in the
737-
// current implementation, once we pick the next lanes to work on, we
738-
// entangle it with all pending at equal or higher priority. We could
739-
// feasibly change this heuristic so that the high pri update doesn't
740-
// render until after the expired updates have finished. But the
741-
// important thing in this test is that the normal updates expired.
742-
'High pri: 1',
736+
gate(flags => flags.enableTransitionEntanglement)
737+
? // Notice that the high pri update didn't flush yet. Expiring one lane
738+
// doesn't affect other lanes. (Unless they are intentionally
739+
// entangled, like we do for overlapping transitions that affect the
740+
// same state.)
741+
'High pri: 0'
742+
: // In the current implementation, once we pick the next lanes to work
743+
// on, we entangle it with all pending at equal or higher priority.
744+
// We could feasibly change this heuristic so that the high pri
745+
// update doesn't render until after the expired updates have
746+
// finished. But the important thing in this test is that the normal
747+
// updates expired.
748+
'High pri: 1',
743749
'Normal pri: 2',
744750
'Sibling',
745751
]);

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

+13-24
Original file line numberDiff line numberDiff line change
@@ -2443,41 +2443,30 @@ describe('ReactSuspenseList', () => {
24432443

24442444
expect(ReactNoop).toMatchRenderedOutput(null);
24452445

2446-
ReactNoop.act(() => {
2446+
await ReactNoop.act(async () => {
24472447
// Add a few items at the end.
24482448
updateLowPri(true);
24492449

24502450
// Flush partially through.
24512451
expect(Scheduler).toFlushAndYieldThrough(['B', 'C']);
24522452

24532453
// Schedule another update at higher priority.
2454-
Scheduler.unstable_runWithPriority(
2455-
Scheduler.unstable_UserBlockingPriority,
2456-
() => updateHighPri(true),
2457-
);
2454+
ReactNoop.flushSync(() => updateHighPri(true));
24582455

24592456
// That will intercept the previous render.
2460-
});
2457+
expect(Scheduler).toHaveYielded([
2458+
'Suspend! [A]',
2459+
'Loading A',
2460+
// Re-render at forced.
2461+
'Suspend! [A]',
2462+
'Loading A',
2463+
]);
2464+
expect(ReactNoop).toMatchRenderedOutput(<span>Loading A</span>);
24612465

2462-
jest.runAllTimers();
2463-
2464-
expect(Scheduler).toHaveYielded([
2465-
// First attempt at high pri.
2466-
'Suspend! [A]',
2467-
'Loading A',
2468-
// Re-render at forced.
2469-
'Suspend! [A]',
2470-
'Loading A',
2471-
// We auto-commit this on DEV.
24722466
// Try again on low-pri.
2473-
'Suspend! [A]',
2474-
'Loading A',
2475-
// Re-render at forced.
2476-
'Suspend! [A]',
2477-
'Loading A',
2478-
]);
2479-
2480-
expect(ReactNoop).toMatchRenderedOutput(<span>Loading A</span>);
2467+
expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading A']);
2468+
expect(ReactNoop).toMatchRenderedOutput(<span>Loading A</span>);
2469+
});
24812470

24822471
await AsyncA.resolve();
24832472

packages/shared/ReactFeatureFlags.js

+3
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,6 @@ export const enableUseRefAccessWarning = false;
149149
export const enableRecursiveCommitTraversal = false;
150150

151151
export const disableSchedulerTimeoutInWorkLoop = false;
152+
153+
// Experiment to simplify/improve how transitions are scheduled
154+
export const enableTransitionEntanglement = false;

packages/shared/forks/ReactFeatureFlags.native-fb.js

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const enableUseRefAccessWarning = false;
5858

5959
export const enableRecursiveCommitTraversal = false;
6060
export const disableSchedulerTimeoutInWorkLoop = false;
61+
export const enableTransitionEntanglement = false;
6162

6263
// Flow magic to verify the exports of this file match the original version.
6364
// eslint-disable-next-line no-unused-vars

packages/shared/forks/ReactFeatureFlags.native-oss.js

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const enableUseRefAccessWarning = false;
5757

5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
60+
export const enableTransitionEntanglement = false;
6061

6162
// Flow magic to verify the exports of this file match the original version.
6263
// eslint-disable-next-line no-unused-vars

packages/shared/forks/ReactFeatureFlags.test-renderer.js

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const enableUseRefAccessWarning = false;
5757

5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
60+
export const enableTransitionEntanglement = false;
6061

6162
// Flow magic to verify the exports of this file match the original version.
6263
// eslint-disable-next-line no-unused-vars

packages/shared/forks/ReactFeatureFlags.test-renderer.native.js

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const enableUseRefAccessWarning = false;
5757

5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
60+
export const enableTransitionEntanglement = false;
6061

6162
// Flow magic to verify the exports of this file match the original version.
6263
// eslint-disable-next-line no-unused-vars

packages/shared/forks/ReactFeatureFlags.test-renderer.www.js

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const enableUseRefAccessWarning = false;
5757

5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
60+
export const enableTransitionEntanglement = false;
6061

6162
// Flow magic to verify the exports of this file match the original version.
6263
// eslint-disable-next-line no-unused-vars

packages/shared/forks/ReactFeatureFlags.testing.js

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const enableUseRefAccessWarning = false;
5757

5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
60+
export const enableTransitionEntanglement = false;
6061

6162
// Flow magic to verify the exports of this file match the original version.
6263
// eslint-disable-next-line no-unused-vars

packages/shared/forks/ReactFeatureFlags.testing.www.js

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const enableUseRefAccessWarning = false;
5757

5858
export const enableRecursiveCommitTraversal = false;
5959
export const disableSchedulerTimeoutInWorkLoop = false;
60+
export const enableTransitionEntanglement = false;
6061

6162
// Flow magic to verify the exports of this file match the original version.
6263
// eslint-disable-next-line no-unused-vars

packages/shared/forks/ReactFeatureFlags.www-dynamic.js

+1
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,4 @@ export const enableUseRefAccessWarning = __VARIANT__;
5555

5656
export const enableProfilerNestedUpdateScheduledHook = __VARIANT__;
5757
export const disableSchedulerTimeoutInWorkLoop = __VARIANT__;
58+
export const enableTransitionEntanglement = __VARIANT__;

packages/shared/forks/ReactFeatureFlags.www.js

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const {
3131
enableUseRefAccessWarning,
3232
disableNativeComponentFrames,
3333
disableSchedulerTimeoutInWorkLoop,
34+
enableTransitionEntanglement,
3435
} = dynamicFeatureFlags;
3536

3637
// On WWW, __EXPERIMENTAL__ is used for a new modern build.

0 commit comments

Comments
 (0)