@samitouri / QOS-React-1 / commits / 309c8ad968

Track entangled lanes separately from update lane (#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.

Andrew Clark committed Oct 15, 2023 at 12:29 UTC 309c8ad9688c491e5b17beb07ab01d65594914ce
6 files changed +161 -61
packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js
+72 -26
@@ -16,6 +16,8 @@ let ReactDOMClient;
16 let Scheduler;
17 let act;
18 let waitForAll;
19 +let waitFor;
20 +let waitForMicrotasks;
21 let assertLog;
22
23 const setUntrackedInputValue = Object.getOwnPropertyDescriptor(
@@ -36,6 +38,8 @@ describe('ReactDOMFiberAsync', () => {
38
39 const InternalTestUtils = require('internal-test-utils');
40 waitForAll = InternalTestUtils.waitForAll;
41 + waitFor = InternalTestUtils.waitFor;
42 + waitForMicrotasks = InternalTestUtils.waitForMicrotasks;
43 assertLog = InternalTestUtils.assertLog;
44
45 document.body.appendChild(container);
@@ -653,52 +657,94 @@ describe('ReactDOMFiberAsync', () => {
657 });
658 });
659
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 + }
670
671 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 + }
681 }
682 +
683 React.useEffect(() => {
684 + function onPopstate() {
685 + React.startTransition(() => {
686 + setPathname('/path/b');
687 + });
688 + }
689 window.addEventListener('popstate', onPopstate);
690 return () => window.removeEventListener('popstate', onPopstate);
691 }, []);
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 + );
702 }
703
704 const root = ReactDOMClient.createRoot(container);
705 await act(async () => {
706 root.render(<App />);
707 });
685 - assertLog(['Child:false/0']);
708 + assertLog(['Before', '/path/a', 'After']);
709
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 () => {
715 const popStateEvent = new Event('popstate');
716 +
717 + // Simulate a popstate event
718 window.event = popStateEvent;
719 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');
728 });
695 - assertLog(['Suspend!']);
729 + assertLog(['Suspend! [/path/b]']);
730
731 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([]);
742
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');
749 });
750 });
packages/react-reconciler/src/ReactFiberHiddenContext.js
+13 -8
@@ -13,7 +13,10 @@ import type {Lanes} from './ReactFiberLane';
13
14 import {createCursor, push, pop} from './ReactFiberStack';
15
16 -import {getRenderLanes, setRenderLanes} from './ReactFiberWorkLoop';
16 +import {
17 + getEntangledRenderLanes,
18 + setEntangledRenderLanes,
19 +} from './ReactFiberWorkLoop';
20 import {NoLanes, mergeLanes} from './ReactFiberLane';
21
22 // TODO: Remove `renderLanes` context in favor of hidden context
@@ -29,26 +32,28 @@ type HiddenContext = {
32 // InvisibleParentContext that is currently managed by SuspenseContext.
33 export const currentTreeHiddenStackCursor: StackCursor<HiddenContext | null> =
34 createCursor(null);
32 -export const prevRenderLanesStackCursor: StackCursor<Lanes> =
35 +export const prevEntangledRenderLanesCursor: StackCursor<Lanes> =
36 createCursor(NoLanes);
37
38 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);
41 push(currentTreeHiddenStackCursor, context, fiber);
42
43 // When rendering a subtree that's currently hidden, we must include all
44 // lanes that would have rendered if the hidden subtree hadn't been deferred.
45 // That is, in order to reveal content from hidden -> visible, we must commit
46 // 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 + );
50 }
51
52 export function reuseHiddenContextOnStack(fiber: Fiber): void {
53 // This subtree is not currently hidden, so we don't need to add any lanes
54 // to the render lanes. But we still need to push something to avoid a
55 // context mismatch. Reuse the existing context on the stack.
51 - push(prevRenderLanesStackCursor, getRenderLanes(), fiber);
56 + push(prevEntangledRenderLanesCursor, getEntangledRenderLanes(), fiber);
57 push(
58 currentTreeHiddenStackCursor,
59 currentTreeHiddenStackCursor.current,
@@ -58,10 +63,10 @@ export function reuseHiddenContextOnStack(fiber: Fiber): void {
63
64 export function popHiddenContext(fiber: Fiber): void {
65 // Restore the previous render lanes from the stack
61 - setRenderLanes(prevRenderLanesStackCursor.current);
66 + setEntangledRenderLanes(prevEntangledRenderLanesCursor.current);
67
68 pop(currentTreeHiddenStackCursor, fiber);
64 - pop(prevRenderLanesStackCursor, fiber);
69 + pop(prevEntangledRenderLanesCursor, fiber);
70 }
71
72 export function isCurrentTreeHidden(): boolean {
packages/react-reconciler/src/ReactFiberHooks.js
+2 -1
@@ -1525,7 +1525,8 @@ function mountSyncExternalStore<T>(
1525 );
1526 }
1527
1528 - if (!includesBlockingLane(root, renderLanes)) {
1528 + const rootRenderLanes = getWorkInProgressRootRenderLanes();
1529 + if (!includesBlockingLane(root, rootRenderLanes)) {
1530 pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
1531 }
1532 }
packages/react-reconciler/src/ReactFiberLane.js
+43 -7
@@ -39,6 +39,7 @@ export const NoLane: Lane = /* */ 0b0000000000000000000
39
40 export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001;
41 export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010;
42 +export const SyncLaneIndex: number = 1;
43
44 export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
45 export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000001000;
@@ -274,17 +275,23 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
275 }
276 }
277
278 + return nextLanes;
279 +}
280 +
281 +export function getEntangledLanes(root: FiberRoot, renderLanes: Lanes): Lanes {
282 + let entangledLanes = renderLanes;
283 +
284 if (
285 allowConcurrentByDefault &&
286 (root.current.mode & ConcurrentUpdatesByDefaultMode) !== NoMode
287 ) {
288 // Do nothing, use the lanes as they were assigned.
282 - } else if ((nextLanes & InputContinuousLane) !== NoLanes) {
289 + } else if ((entangledLanes & InputContinuousLane) !== NoLanes) {
290 // When updates are sync by default, we entangle continuous priority updates
291 // and default updates, so they render in the same batch. The only reason
292 // they use separate lanes is because continuous updates should interrupt
293 // transitions, but default updates should not.
287 - nextLanes |= pendingLanes & DefaultLane;
294 + entangledLanes |= entangledLanes & DefaultLane;
295 }
296
297 // Check for entangled lanes and add them to the batch.
@@ -309,21 +316,21 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
316 // For those exceptions where entanglement is semantically important,
317 // we should ensure that there is no partial work at the
318 // time we apply the entanglement.
312 - const entangledLanes = root.entangledLanes;
313 - if (entangledLanes !== NoLanes) {
319 + const allEntangledLanes = root.entangledLanes;
320 + if (allEntangledLanes !== NoLanes) {
321 const entanglements = root.entanglements;
315 - let lanes = nextLanes & entangledLanes;
322 + let lanes = entangledLanes & allEntangledLanes;
323 while (lanes > 0) {
324 const index = pickArbitraryLaneIndex(lanes);
325 const lane = 1 << index;
326
320 - nextLanes |= entanglements[index];
327 + entangledLanes |= entanglements[index];
328
329 lanes &= ~lane;
330 }
331 }
332
326 - return nextLanes;
333 + return entangledLanes;
334 }
335
336 function computeExpirationTime(lane: Lane, currentTime: number) {
@@ -404,6 +411,7 @@ export function markStarvedLanesAsExpired(
411 // Iterate through the pending lanes and check if we've reached their
412 // expiration time. If so, we'll assume the update is being starved and mark
413 // it as expired to force it to finish.
414 + // TODO: We should be able to replace this with upgradePendingLanesToSync
415 //
416 // We exclude retry lanes because those must always be time sliced, in order
417 // to unwrap uncached promises.
@@ -708,6 +716,34 @@ export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
716 }
717 }
718
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 +
747 export function markHiddenUpdate(
748 root: FiberRoot,
749 update: ConcurrentUpdate,
packages/react-reconciler/src/ReactFiberRootScheduler.js
+5 -3
@@ -20,8 +20,7 @@ import {
20 getNextLanes,
21 includesSyncLane,
22 markStarvedLanesAsExpired,
23 - markRootEntangled,
24 - mergeLanes,
23 + upgradePendingLaneToSync,
24 claimNextTransitionLane,
25 } from './ReactFiberLane';
26 import {
@@ -250,7 +249,10 @@ function processRootScheduleInMicrotask() {
249 currentEventTransitionLane !== NoLane &&
250 shouldAttemptEagerTransition()
251 ) {
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);
256 }
257
258 const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime);
packages/react-reconciler/src/ReactFiberWorkLoop.js
+26 -16
@@ -141,11 +141,12 @@ import {
141 includesBlockingLane,
142 includesExpiredLane,
143 getNextLanes,
144 + getEntangledLanes,
145 getLanesToRetrySynchronouslyOnError,
146 markRootUpdated,
147 markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
148 markRootPinged,
148 - markRootEntangled,
149 + upgradePendingLanesToSync,
150 markRootFinished,
151 addFiberToLanesMap,
152 movePendingFibersToMemoized,
@@ -349,8 +350,8 @@ let workInProgressRootDidAttachPingListener: boolean = false;
350 // HiddenContext module.
351 //
352 // Most things in the work loop should deal with workInProgressRootRenderLanes.
352 -// Most things in begin/complete phases should deal with renderLanes.
353 -export let renderLanes: Lanes = NoLanes;
353 +// Most things in begin/complete phases should deal with entangledRenderLanes.
354 +export let entangledRenderLanes: Lanes = NoLanes;
355
356 // Whether to root completed, errored, suspended, etc.
357 let workInProgressRootExitStatus: RootExitStatus = RootInProgress;
@@ -1335,7 +1336,7 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
1336
1337 export function flushRoot(root: FiberRoot, lanes: Lanes) {
1338 if (lanes !== NoLanes) {
1338 - markRootEntangled(root, mergeLanes(lanes, SyncLane));
1339 + upgradePendingLanesToSync(root, lanes);
1340 ensureRootIsScheduled(root);
1341 if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
1342 resetRenderTimer();
@@ -1471,12 +1472,12 @@ export function isInvalidExecutionContextForEventFunction(): boolean {
1472 // hidden subtree. The stack logic is managed there because that's the only
1473 // place that ever modifies it. Which module it lives in doesn't matter for
1474 // performance because this function will get inlined regardless
1474 -export function setRenderLanes(subtreeRenderLanes: Lanes) {
1475 - renderLanes = subtreeRenderLanes;
1475 +export function setEntangledRenderLanes(newEntangledRenderLanes: Lanes) {
1476 + entangledRenderLanes = newEntangledRenderLanes;
1477 }
1478
1478 -export function getRenderLanes(): Lanes {
1479 - return renderLanes;
1479 +export function getEntangledRenderLanes(): Lanes {
1480 + return entangledRenderLanes;
1481 }
1482
1483 function resetWorkInProgressStack() {
@@ -1526,7 +1527,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1527 workInProgressRoot = root;
1528 const rootWorkInProgress = createWorkInProgress(root.current, null);
1529 workInProgress = rootWorkInProgress;
1529 - workInProgressRootRenderLanes = renderLanes = lanes;
1530 + workInProgressRootRenderLanes = lanes;
1531 workInProgressSuspendedReason = NotSuspended;
1532 workInProgressThrownValue = null;
1533 workInProgressRootDidAttachPingListener = false;
@@ -1539,6 +1540,15 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1540 workInProgressRootConcurrentErrors = null;
1541 workInProgressRootRecoverableErrors = null;
1542
1543 + // Get the lanes that are entangled with whatever we're about to render. We
1544 + // track these separately so we can distinguish the priority of the render
1545 + // task from the priority of the lanes it is entangled with. For example, a
1546 + // transition may not be allowed to finish unless it includes the Sync lane,
1547 + // which is currently suspended. We should be able to render the Transition
1548 + // and Sync lane in the same batch, but at Transition priority, because the
1549 + // Sync lane already suspended.
1550 + entangledRenderLanes = getEntangledLanes(root, lanes);
1551 +
1552 finishQueueingConcurrentUpdates();
1553
1554 if (__DEV__) {
@@ -2249,10 +2259,10 @@ function performUnitOfWork(unitOfWork: Fiber): void {
2259 let next;
2260 if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
2261 startProfilerTimer(unitOfWork);
2252 - next = beginWork(current, unitOfWork, renderLanes);
2262 + next = beginWork(current, unitOfWork, entangledRenderLanes);
2263 stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
2264 } else {
2255 - next = beginWork(current, unitOfWork, renderLanes);
2265 + next = beginWork(current, unitOfWork, entangledRenderLanes);
2266 }
2267
2268 resetCurrentDebugFiberInDEV();
@@ -2359,9 +2369,9 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
2369 unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes);
2370 unitOfWork = workInProgress = resetWorkInProgress(
2371 unitOfWork,
2362 - renderLanes,
2372 + entangledRenderLanes,
2373 );
2364 - next = beginWork(current, unitOfWork, renderLanes);
2374 + next = beginWork(current, unitOfWork, entangledRenderLanes);
2375 break;
2376 }
2377 }
@@ -2471,10 +2481,10 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
2481 setCurrentDebugFiberInDEV(completedWork);
2482 let next;
2483 if (!enableProfilerTimer || (completedWork.mode & ProfileMode) === NoMode) {
2474 - next = completeWork(current, completedWork, renderLanes);
2484 + next = completeWork(current, completedWork, entangledRenderLanes);
2485 } else {
2486 startProfilerTimer(completedWork);
2477 - next = completeWork(current, completedWork, renderLanes);
2487 + next = completeWork(current, completedWork, entangledRenderLanes);
2488 // Update render duration assuming we didn't error.
2489 stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
2490 }
@@ -2516,7 +2526,7 @@ function unwindUnitOfWork(unitOfWork: Fiber): void {
2526 // This fiber did not complete because something threw. Pop values off
2527 // the stack without entering the complete phase. If this is a boundary,
2528 // capture values if possible.
2519 - const next = unwindWork(current, incompleteWork, renderLanes);
2529 + const next = unwindWork(current, incompleteWork, entangledRenderLanes);
2530
2531 // Because this fiber did not complete, don't reset its lanes.
2532