Skip to content

Commit 24e847c

Browse files
acdliteAndyPengc12
authored andcommitted
Track entangled lanes separately from update lane (facebook#27505)
A small refactor to how the lane entanglement mechanism works. We can now distinguish between the lane that "spawned" a render task (i.e. a new update) versus the lanes that it's entangled with. Both the update lane and the entangled lanes will be included while rendering, but by keeping them separate, we don't lose the original priority. In practical terms, this means we can now entangle a low priority update with a higher priority lane while rendering at the lower priority. To do this, lanes that are entangled at the root are now tracked using the same variable that we use to track the "base lanes" when revealing a previously hidden tree — conceptually, they are the same thing. I also renamed this variable (from subtreeLanes to entangledRenderLanes) to better reflect how it's used. My primary motivation is related to useDeferredValue, which I'll address in a later PR.
1 parent 967ca13 commit 24e847c

File tree

6 files changed

+161
-61
lines changed

6 files changed

+161
-61
lines changed

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

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ let ReactDOMClient;
1616
let Scheduler;
1717
let act;
1818
let waitForAll;
19+
let waitFor;
20+
let waitForMicrotasks;
1921
let assertLog;
2022

2123
const setUntrackedInputValue = Object.getOwnPropertyDescriptor(
@@ -36,6 +38,8 @@ describe('ReactDOMFiberAsync', () => {
3638

3739
const InternalTestUtils = require('internal-test-utils');
3840
waitForAll = InternalTestUtils.waitForAll;
41+
waitFor = InternalTestUtils.waitFor;
42+
waitForMicrotasks = InternalTestUtils.waitForMicrotasks;
3943
assertLog = InternalTestUtils.assertLog;
4044

4145
document.body.appendChild(container);
@@ -653,52 +657,94 @@ describe('ReactDOMFiberAsync', () => {
653657
});
654658
});
655659

656-
it('transition lane in popState should yield if it suspends', async () => {
657-
const never = {then() {}};
658-
let _setText;
660+
it('transition lane in popState should be allowed to suspend', async () => {
661+
let resolvePromise;
662+
const promise = new Promise(res => {
663+
resolvePromise = res;
664+
});
665+
666+
function Text({text}) {
667+
Scheduler.log(text);
668+
return text;
669+
}
659670

660671
function App() {
661-
const [shouldSuspend, setShouldSuspend] = React.useState(false);
662-
const [text, setText] = React.useState('0');
663-
_setText = setText;
664-
if (shouldSuspend) {
665-
Scheduler.log('Suspend!');
666-
throw never;
667-
}
668-
function onPopstate() {
669-
React.startTransition(() => {
670-
setShouldSuspend(val => !val);
671-
});
672+
const [pathname, setPathname] = React.useState('/path/a');
673+
674+
if (pathname !== '/path/a') {
675+
try {
676+
React.use(promise);
677+
} catch (e) {
678+
Scheduler.log(`Suspend! [${pathname}]`);
679+
throw e;
680+
}
672681
}
682+
673683
React.useEffect(() => {
684+
function onPopstate() {
685+
React.startTransition(() => {
686+
setPathname('/path/b');
687+
});
688+
}
674689
window.addEventListener('popstate', onPopstate);
675690
return () => window.removeEventListener('popstate', onPopstate);
676691
}, []);
677-
Scheduler.log(`Child:${shouldSuspend}/${text}`);
678-
return text;
692+
693+
return (
694+
<>
695+
<Text text="Before" />
696+
<div>
697+
<Text text={pathname} />
698+
</div>
699+
<Text text="After" />
700+
</>
701+
);
679702
}
680703

681704
const root = ReactDOMClient.createRoot(container);
682705
await act(async () => {
683706
root.render(<App />);
684707
});
685-
assertLog(['Child:false/0']);
708+
assertLog(['Before', '/path/a', 'After']);
686709

687-
await act(() => {
710+
const div = container.getElementsByTagName('div')[0];
711+
expect(div.textContent).toBe('/path/a');
712+
713+
// Simulate a popstate event
714+
await act(async () => {
688715
const popStateEvent = new Event('popstate');
716+
717+
// Simulate a popstate event
689718
window.event = popStateEvent;
690719
window.dispatchEvent(popStateEvent);
691-
queueMicrotask(() => {
692-
window.event = undefined;
693-
});
720+
await waitForMicrotasks();
721+
window.event = undefined;
722+
723+
// The transition lane should have been attempted synchronously (in
724+
// a microtask)
725+
assertLog(['Suspend! [/path/b]']);
726+
// Because it suspended, it remains on the current path
727+
expect(div.textContent).toBe('/path/a');
694728
});
695-
assertLog(['Suspend!']);
729+
assertLog(['Suspend! [/path/b]']);
696730

697731
await act(async () => {
698-
_setText('1');
699-
});
700-
assertLog(['Child:false/1', 'Suspend!']);
732+
resolvePromise();
733+
734+
// Since the transition previously suspended, there's no need for this
735+
// transition to be rendered synchronously on susbequent attempts; if we
736+
// fail to commit synchronously the first time, the scroll restoration
737+
// state won't be restored anyway.
738+
//
739+
// Yield in between each child to prove that it's concurrent.
740+
await waitForMicrotasks();
741+
assertLog([]);
701742

702-
root.unmount();
743+
await waitFor(['Before']);
744+
await waitFor(['/path/b']);
745+
await waitFor(['After']);
746+
});
747+
assertLog([]);
748+
expect(div.textContent).toBe('/path/b');
703749
});
704750
});

packages/react-reconciler/src/ReactFiberHiddenContext.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import type {Lanes} from './ReactFiberLane';
1313

1414
import {createCursor, push, pop} from './ReactFiberStack';
1515

16-
import {getRenderLanes, setRenderLanes} from './ReactFiberWorkLoop';
16+
import {
17+
getEntangledRenderLanes,
18+
setEntangledRenderLanes,
19+
} from './ReactFiberWorkLoop';
1720
import {NoLanes, mergeLanes} from './ReactFiberLane';
1821

1922
// TODO: Remove `renderLanes` context in favor of hidden context
@@ -29,26 +32,28 @@ type HiddenContext = {
2932
// InvisibleParentContext that is currently managed by SuspenseContext.
3033
export const currentTreeHiddenStackCursor: StackCursor<HiddenContext | null> =
3134
createCursor(null);
32-
export const prevRenderLanesStackCursor: StackCursor<Lanes> =
35+
export const prevEntangledRenderLanesCursor: StackCursor<Lanes> =
3336
createCursor(NoLanes);
3437

3538
export function pushHiddenContext(fiber: Fiber, context: HiddenContext): void {
36-
const prevRenderLanes = getRenderLanes();
37-
push(prevRenderLanesStackCursor, prevRenderLanes, fiber);
39+
const prevEntangledRenderLanes = getEntangledRenderLanes();
40+
push(prevEntangledRenderLanesCursor, prevEntangledRenderLanes, fiber);
3841
push(currentTreeHiddenStackCursor, context, fiber);
3942

4043
// When rendering a subtree that's currently hidden, we must include all
4144
// lanes that would have rendered if the hidden subtree hadn't been deferred.
4245
// That is, in order to reveal content from hidden -> visible, we must commit
4346
// all the updates that we skipped when we originally hid the tree.
44-
setRenderLanes(mergeLanes(prevRenderLanes, context.baseLanes));
47+
setEntangledRenderLanes(
48+
mergeLanes(prevEntangledRenderLanes, context.baseLanes),
49+
);
4550
}
4651

4752
export function reuseHiddenContextOnStack(fiber: Fiber): void {
4853
// This subtree is not currently hidden, so we don't need to add any lanes
4954
// to the render lanes. But we still need to push something to avoid a
5055
// context mismatch. Reuse the existing context on the stack.
51-
push(prevRenderLanesStackCursor, getRenderLanes(), fiber);
56+
push(prevEntangledRenderLanesCursor, getEntangledRenderLanes(), fiber);
5257
push(
5358
currentTreeHiddenStackCursor,
5459
currentTreeHiddenStackCursor.current,
@@ -58,10 +63,10 @@ export function reuseHiddenContextOnStack(fiber: Fiber): void {
5863

5964
export function popHiddenContext(fiber: Fiber): void {
6065
// Restore the previous render lanes from the stack
61-
setRenderLanes(prevRenderLanesStackCursor.current);
66+
setEntangledRenderLanes(prevEntangledRenderLanesCursor.current);
6267

6368
pop(currentTreeHiddenStackCursor, fiber);
64-
pop(prevRenderLanesStackCursor, fiber);
69+
pop(prevEntangledRenderLanesCursor, fiber);
6570
}
6671

6772
export function isCurrentTreeHidden(): boolean {

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1525,7 +1525,8 @@ function mountSyncExternalStore<T>(
15251525
);
15261526
}
15271527

1528-
if (!includesBlockingLane(root, renderLanes)) {
1528+
const rootRenderLanes = getWorkInProgressRootRenderLanes();
1529+
if (!includesBlockingLane(root, rootRenderLanes)) {
15291530
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
15301531
}
15311532
}

packages/react-reconciler/src/ReactFiberLane.js

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const NoLane: Lane = /* */ 0b0000000000000000000
3939

4040
export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001;
4141
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010;
42+
export const SyncLaneIndex: number = 1;
4243

4344
export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
4445
export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000001000;
@@ -274,17 +275,23 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
274275
}
275276
}
276277

278+
return nextLanes;
279+
}
280+
281+
export function getEntangledLanes(root: FiberRoot, renderLanes: Lanes): Lanes {
282+
let entangledLanes = renderLanes;
283+
277284
if (
278285
allowConcurrentByDefault &&
279286
(root.current.mode & ConcurrentUpdatesByDefaultMode) !== NoMode
280287
) {
281288
// Do nothing, use the lanes as they were assigned.
282-
} else if ((nextLanes & InputContinuousLane) !== NoLanes) {
289+
} else if ((entangledLanes & InputContinuousLane) !== NoLanes) {
283290
// When updates are sync by default, we entangle continuous priority updates
284291
// and default updates, so they render in the same batch. The only reason
285292
// they use separate lanes is because continuous updates should interrupt
286293
// transitions, but default updates should not.
287-
nextLanes |= pendingLanes & DefaultLane;
294+
entangledLanes |= entangledLanes & DefaultLane;
288295
}
289296

290297
// Check for entangled lanes and add them to the batch.
@@ -309,21 +316,21 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
309316
// For those exceptions where entanglement is semantically important,
310317
// we should ensure that there is no partial work at the
311318
// time we apply the entanglement.
312-
const entangledLanes = root.entangledLanes;
313-
if (entangledLanes !== NoLanes) {
319+
const allEntangledLanes = root.entangledLanes;
320+
if (allEntangledLanes !== NoLanes) {
314321
const entanglements = root.entanglements;
315-
let lanes = nextLanes & entangledLanes;
322+
let lanes = entangledLanes & allEntangledLanes;
316323
while (lanes > 0) {
317324
const index = pickArbitraryLaneIndex(lanes);
318325
const lane = 1 << index;
319326

320-
nextLanes |= entanglements[index];
327+
entangledLanes |= entanglements[index];
321328

322329
lanes &= ~lane;
323330
}
324331
}
325332

326-
return nextLanes;
333+
return entangledLanes;
327334
}
328335

329336
function computeExpirationTime(lane: Lane, currentTime: number) {
@@ -404,6 +411,7 @@ export function markStarvedLanesAsExpired(
404411
// Iterate through the pending lanes and check if we've reached their
405412
// expiration time. If so, we'll assume the update is being starved and mark
406413
// it as expired to force it to finish.
414+
// TODO: We should be able to replace this with upgradePendingLanesToSync
407415
//
408416
// We exclude retry lanes because those must always be time sliced, in order
409417
// to unwrap uncached promises.
@@ -708,6 +716,34 @@ export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
708716
}
709717
}
710718

719+
export function upgradePendingLaneToSync(root: FiberRoot, lane: Lane) {
720+
// Since we're upgrading the priority of the given lane, there is now pending
721+
// sync work.
722+
root.pendingLanes |= SyncLane;
723+
724+
// Entangle the sync lane with the lane we're upgrading. This means SyncLane
725+
// will not be allowed to finish without also finishing the given lane.
726+
root.entangledLanes |= SyncLane;
727+
root.entanglements[SyncLaneIndex] |= lane;
728+
}
729+
730+
export function upgradePendingLanesToSync(
731+
root: FiberRoot,
732+
lanesToUpgrade: Lanes,
733+
) {
734+
// Same as upgradePendingLaneToSync but accepts multiple lanes, so it's a
735+
// bit slower.
736+
root.pendingLanes |= SyncLane;
737+
root.entangledLanes |= SyncLane;
738+
let lanes = lanesToUpgrade;
739+
while (lanes) {
740+
const index = pickArbitraryLaneIndex(lanes);
741+
const lane = 1 << index;
742+
root.entanglements[SyncLaneIndex] |= lane;
743+
lanes &= ~lane;
744+
}
745+
}
746+
711747
export function markHiddenUpdate(
712748
root: FiberRoot,
713749
update: ConcurrentUpdate,

packages/react-reconciler/src/ReactFiberRootScheduler.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ import {
2020
getNextLanes,
2121
includesSyncLane,
2222
markStarvedLanesAsExpired,
23-
markRootEntangled,
24-
mergeLanes,
23+
upgradePendingLaneToSync,
2524
claimNextTransitionLane,
2625
} from './ReactFiberLane';
2726
import {
@@ -250,7 +249,10 @@ function processRootScheduleInMicrotask() {
250249
currentEventTransitionLane !== NoLane &&
251250
shouldAttemptEagerTransition()
252251
) {
253-
markRootEntangled(root, mergeLanes(currentEventTransitionLane, SyncLane));
252+
// A transition was scheduled during an event, but we're going to try to
253+
// render it synchronously anyway. We do this during a popstate event to
254+
// preserve the scroll position of the previous page.
255+
upgradePendingLaneToSync(root, currentEventTransitionLane);
254256
}
255257

256258
const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime);

0 commit comments

Comments
 (0)