@samitouri / QOS-React-2 / commits / a014c915c7

Parallel transitions: Assign different lanes to consecutive transitions (#20672)

* Land enableTransitionEntanglement changes Leaving the flag though because I plan to reuse it for additional, similar changes. * Assign different lanes to consecutive transitions Currently we always assign the same lane to all transitions. This means if there are two pending transitions at the same time, neither transition can finish until both can finish, even if they affect completely separate parts of the UI. The new approach is to assign a different lane to each consecutive transition, by shifting the bit to the right each time. When we reach the end of the bit range, we cycle back to the first bit. In practice, this should mean that all transitions get their own dedicated lane, unless we have more pending transitions than lanes, which should be rare. We retain our existing behavior of assigning the same lane to all transitions within the same event. This is achieved by caching the first lane assigned to a transition, then re-using that one until the next React task, by which point the event must have finished. This preserves the guarantee that all transition updates that result from a single event will be consistent.

Andrew Clark committed Feb 8, 2021 at 15:26 UTC a014c915c77f908ba3be3de9e6a06d05c71d5b62
6 files changed +625 -250
packages/react-reconciler/src/ReactFiberLane.new.js
+60 -86
@@ -36,10 +36,7 @@ export type Lane = number;
36 export type LaneMap<T> = Array<T>;
37
38 import invariant from 'shared/invariant';
39 -import {
40 - enableCache,
41 - enableTransitionEntanglement,
42 -} from 'shared/ReactFeatureFlags';
39 +import {enableCache} from 'shared/ReactFeatureFlags';
40
41 import {
42 ImmediatePriority as ImmediateSchedulerPriority,
@@ -95,6 +92,7 @@ export const DefaultLanes: Lanes = /* */ 0b0000000000000000000
92
93 const TransitionHydrationLane: Lane = /* */ 0b0000000000000000001000000000000;
94 const TransitionLanes: Lanes = /* */ 0b0000000001111111110000000000000;
95 +const SomeTransitionLane: Lane = /* */ 0b0000000000000000010000000000000;
96
97 const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
98
@@ -113,6 +111,9 @@ export const NoTimestamp = -1;
111
112 let currentUpdateLanePriority: LanePriority = NoLanePriority;
113
114 +let nextTransitionLane: Lane = SomeTransitionLane;
115 +let nextRetryLane: Lane = SomeRetryLane;
116 +
117 export function getCurrentUpdateLanePriority(): LanePriority {
118 return currentUpdateLanePriority;
119 }
@@ -309,15 +310,6 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
310 return NoLanes;
311 }
312
312 - if (enableTransitionEntanglement) {
313 - // We don't need to do anything extra here, because we apply per-lane
314 - // transition entanglement in the entanglement loop below.
315 - } else {
316 - // If there are higher priority lanes, we'll include them even if they
317 - // are suspended.
318 - nextLanes = pendingLanes & getEqualOrHigherPriorityLanes(nextLanes);
319 - }
320 -
313 // If we're already in the middle of a render, switching lanes will interrupt
314 // it and we'll lose our progress. We should only do this if the new lanes are
315 // higher priority.
@@ -350,6 +342,11 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
342 // entanglement is usually "best effort": we'll try our best to render the
343 // lanes in the same batch, but it's not worth throwing out partially
344 // completed work in order to do it.
345 + // TODO: Reconsider this. The counter-argument is that the partial work
346 + // represents an intermediate state, which we don't want to show to the user.
347 + // And by spending extra time finishing it, we're increasing the amount of
348 + // time it takes to show the final state, which is what they are actually
349 + // waiting for.
350 //
351 // For those exceptions where entanglement is semantically important, like
352 // useMutableSource, we should ensure that there is no partial work at the
@@ -559,34 +556,23 @@ export function findUpdateLane(
556 );
557 }
558
562 -// To ensure consistency across multiple updates in the same event, this should
563 -// be pure function, so that it always returns the same lane for given inputs.
564 -export function findTransitionLane(wipLanes: Lanes, pendingLanes: Lanes): Lane {
565 - // First look for lanes that are completely unclaimed, i.e. have no
566 - // pending work.
567 - let lane = pickArbitraryLane(TransitionLanes & ~pendingLanes);
568 - if (lane === NoLane) {
569 - // If all lanes have pending work, look for a lane that isn't currently
570 - // being worked on.
571 - lane = pickArbitraryLane(TransitionLanes & ~wipLanes);
572 - if (lane === NoLane) {
573 - // If everything is being worked on, pick any lane. This has the
574 - // effect of interrupting the current work-in-progress.
575 - lane = pickArbitraryLane(TransitionLanes);
576 - }
559 +export function claimNextTransitionLane(): Lane {
560 + // Cycle through the lanes, assigning each new transition to the next lane.
561 + // In most cases, this means every transition gets its own lane, until we
562 + // run out of lanes and cycle back to the beginning.
563 + const lane = nextTransitionLane;
564 + nextTransitionLane <<= 1;
565 + if ((nextTransitionLane & TransitionLanes) === 0) {
566 + nextTransitionLane = SomeTransitionLane;
567 }
568 return lane;
569 }
570
581 -// To ensure consistency across multiple updates in the same event, this should
582 -// be pure function, so that it always returns the same lane for given inputs.
583 -export function findRetryLane(wipLanes: Lanes): Lane {
584 - // This is a fork of `findUpdateLane` designed specifically for Suspense
585 - // "retries" — a special update that attempts to flip a Suspense boundary
586 - // from its placeholder state to its primary/resolved state.
587 - let lane = pickArbitraryLane(RetryLanes & ~wipLanes);
588 - if (lane === NoLane) {
589 - lane = pickArbitraryLane(RetryLanes);
571 +export function claimNextRetryLane(): Lane {
572 + const lane = nextRetryLane;
573 + nextRetryLane <<= 1;
574 + if ((nextRetryLane & RetryLanes) === 0) {
575 + nextRetryLane = SomeRetryLane;
576 }
577 return lane;
578 }
@@ -595,16 +581,6 @@ function getHighestPriorityLane(lanes: Lanes) {
581 return lanes & -lanes;
582 }
583
598 -function getLowestPriorityLane(lanes: Lanes): Lane {
599 - // This finds the most significant non-zero bit.
600 - const index = 31 - clz32(lanes);
601 - return index < 0 ? NoLanes : 1 << index;
602 -}
603 -
604 -function getEqualOrHigherPriorityLanes(lanes: Lanes | Lane): Lanes {
605 - return (getLowestPriorityLane(lanes) << 1) - 1;
606 -}
607 -
584 export function pickArbitraryLane(lanes: Lanes): Lane {
585 // This wrapper function gets inlined. Only exists so to communicate that it
586 // doesn't matter which bit is selected; you can pick any bit without
@@ -676,39 +652,21 @@ export function markRootUpdated(
652 ) {
653 root.pendingLanes |= updateLane;
654
679 - // TODO: Theoretically, any update to any lane can unblock any other lane. But
680 - // it's not practical to try every single possible combination. We need a
681 - // heuristic to decide which lanes to attempt to render, and in which batches.
682 - // For now, we use the same heuristic as in the old ExpirationTimes model:
683 - // retry any lane at equal or lower priority, but don't try updates at higher
684 - // priority without also including the lower priority updates. This works well
685 - // when considering updates across different priority levels, but isn't
686 - // sufficient for updates within the same priority, since we want to treat
687 - // those updates as parallel.
688 -
689 - // Unsuspend any update at equal or lower priority.
690 - const higherPriorityLanes = updateLane - 1; // Turns 0b1000 into 0b0111
691 -
692 - if (enableTransitionEntanglement) {
693 - // If there are any suspended transitions, it's possible this new update
694 - // could unblock them. Clear the suspended lanes so that we can try rendering
695 - // them again.
696 - //
697 - // TODO: We really only need to unsuspend only lanes that are in the
698 - // `subtreeLanes` of the updated fiber, or the update lanes of the return
699 - // path. This would exclude suspended updates in an unrelated sibling tree,
700 - // since there's no way for this update to unblock it.
701 - //
702 - // We don't do this if the incoming update is idle, because we never process
703 - // idle updates until after all the regular updates have finished; there's no
704 - // way it could unblock a transition.
705 - if ((updateLane & IdleLanes) === NoLanes) {
706 - root.suspendedLanes = NoLanes;
707 - root.pingedLanes = NoLanes;
708 - }
709 - } else {
710 - root.suspendedLanes &= higherPriorityLanes;
711 - root.pingedLanes &= higherPriorityLanes;
655 + // If there are any suspended transitions, it's possible this new update
656 + // could unblock them. Clear the suspended lanes so that we can try rendering
657 + // them again.
658 + //
659 + // TODO: We really only need to unsuspend only lanes that are in the
660 + // `subtreeLanes` of the updated fiber, or the update lanes of the return
661 + // path. This would exclude suspended updates in an unrelated sibling tree,
662 + // since there's no way for this update to unblock it.
663 + //
664 + // We don't do this if the incoming update is idle, because we never process
665 + // idle updates until after all the regular updates have finished; there's no
666 + // way it could unblock a transition.
667 + if ((updateLane & IdleLanes) === NoLanes) {
668 + root.suspendedLanes = NoLanes;
669 + root.pingedLanes = NoLanes;
670 }
671
672 const eventTimes = root.eventTimes;
@@ -801,16 +759,32 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
759 }
760
761 export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
804 - root.entangledLanes |= entangledLanes;
762 + // In addition to entangling each of the given lanes with each other, we also
763 + // have to consider _transitive_ entanglements. For each lane that is already
764 + // entangled with *any* of the given lanes, that lane is now transitively
765 + // entangled with *all* the given lanes.
766 + //
767 + // Translated: If C is entangled with A, then entangling A with B also
768 + // entangles C with B.
769 + //
770 + // If this is hard to grasp, it might help to intentionally break this
771 + // function and look at the tests that fail in ReactTransition-test.js. Try
772 + // commenting out one of the conditions below.
773
774 + const rootEntangledLanes = (root.entangledLanes |= entangledLanes);
775 const entanglements = root.entanglements;
807 - let lanes = entangledLanes;
808 - while (lanes > 0) {
776 + let lanes = rootEntangledLanes;
777 + while (lanes) {
778 const index = pickArbitraryLaneIndex(lanes);
779 const lane = 1 << index;
811 -
812 - entanglements[index] |= entangledLanes;
813 -
780 + if (
781 + // Is this one of the newly entangled lanes?
782 + (lane & entangledLanes) |
783 + // Is this lane transitively entangled with the newly entangled lanes?
784 + (entanglements[index] & entangledLanes)
785 + ) {
786 + entanglements[index] |= entangledLanes;
787 + }
788 lanes &= ~lane;
789 }
790 }
packages/react-reconciler/src/ReactFiberLane.old.js
+60 -86
@@ -36,10 +36,7 @@ export type Lane = number;
36 export type LaneMap<T> = Array<T>;
37
38 import invariant from 'shared/invariant';
39 -import {
40 - enableCache,
41 - enableTransitionEntanglement,
42 -} from 'shared/ReactFeatureFlags';
39 +import {enableCache} from 'shared/ReactFeatureFlags';
40
41 import {
42 ImmediatePriority as ImmediateSchedulerPriority,
@@ -95,6 +92,7 @@ export const DefaultLanes: Lanes = /* */ 0b0000000000000000000
92
93 const TransitionHydrationLane: Lane = /* */ 0b0000000000000000001000000000000;
94 const TransitionLanes: Lanes = /* */ 0b0000000001111111110000000000000;
95 +const SomeTransitionLane: Lane = /* */ 0b0000000000000000010000000000000;
96
97 const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
98
@@ -113,6 +111,9 @@ export const NoTimestamp = -1;
111
112 let currentUpdateLanePriority: LanePriority = NoLanePriority;
113
114 +let nextTransitionLane: Lane = SomeTransitionLane;
115 +let nextRetryLane: Lane = SomeRetryLane;
116 +
117 export function getCurrentUpdateLanePriority(): LanePriority {
118 return currentUpdateLanePriority;
119 }
@@ -309,15 +310,6 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
310 return NoLanes;
311 }
312
312 - if (enableTransitionEntanglement) {
313 - // We don't need to do anything extra here, because we apply per-lane
314 - // transition entanglement in the entanglement loop below.
315 - } else {
316 - // If there are higher priority lanes, we'll include them even if they
317 - // are suspended.
318 - nextLanes = pendingLanes & getEqualOrHigherPriorityLanes(nextLanes);
319 - }
320 -
313 // If we're already in the middle of a render, switching lanes will interrupt
314 // it and we'll lose our progress. We should only do this if the new lanes are
315 // higher priority.
@@ -350,6 +342,11 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
342 // entanglement is usually "best effort": we'll try our best to render the
343 // lanes in the same batch, but it's not worth throwing out partially
344 // completed work in order to do it.
345 + // TODO: Reconsider this. The counter-argument is that the partial work
346 + // represents an intermediate state, which we don't want to show to the user.
347 + // And by spending extra time finishing it, we're increasing the amount of
348 + // time it takes to show the final state, which is what they are actually
349 + // waiting for.
350 //
351 // For those exceptions where entanglement is semantically important, like
352 // useMutableSource, we should ensure that there is no partial work at the
@@ -559,34 +556,23 @@ export function findUpdateLane(
556 );
557 }
558
562 -// To ensure consistency across multiple updates in the same event, this should
563 -// be pure function, so that it always returns the same lane for given inputs.
564 -export function findTransitionLane(wipLanes: Lanes, pendingLanes: Lanes): Lane {
565 - // First look for lanes that are completely unclaimed, i.e. have no
566 - // pending work.
567 - let lane = pickArbitraryLane(TransitionLanes & ~pendingLanes);
568 - if (lane === NoLane) {
569 - // If all lanes have pending work, look for a lane that isn't currently
570 - // being worked on.
571 - lane = pickArbitraryLane(TransitionLanes & ~wipLanes);
572 - if (lane === NoLane) {
573 - // If everything is being worked on, pick any lane. This has the
574 - // effect of interrupting the current work-in-progress.
575 - lane = pickArbitraryLane(TransitionLanes);
576 - }
559 +export function claimNextTransitionLane(): Lane {
560 + // Cycle through the lanes, assigning each new transition to the next lane.
561 + // In most cases, this means every transition gets its own lane, until we
562 + // run out of lanes and cycle back to the beginning.
563 + const lane = nextTransitionLane;
564 + nextTransitionLane <<= 1;
565 + if ((nextTransitionLane & TransitionLanes) === 0) {
566 + nextTransitionLane = SomeTransitionLane;
567 }
568 return lane;
569 }
570
581 -// To ensure consistency across multiple updates in the same event, this should
582 -// be pure function, so that it always returns the same lane for given inputs.
583 -export function findRetryLane(wipLanes: Lanes): Lane {
584 - // This is a fork of `findUpdateLane` designed specifically for Suspense
585 - // "retries" — a special update that attempts to flip a Suspense boundary
586 - // from its placeholder state to its primary/resolved state.
587 - let lane = pickArbitraryLane(RetryLanes & ~wipLanes);
588 - if (lane === NoLane) {
589 - lane = pickArbitraryLane(RetryLanes);
571 +export function claimNextRetryLane(): Lane {
572 + const lane = nextRetryLane;
573 + nextRetryLane <<= 1;
574 + if ((nextRetryLane & RetryLanes) === 0) {
575 + nextRetryLane = SomeRetryLane;
576 }
577 return lane;
578 }
@@ -595,16 +581,6 @@ function getHighestPriorityLane(lanes: Lanes) {
581 return lanes & -lanes;
582 }
583
598 -function getLowestPriorityLane(lanes: Lanes): Lane {
599 - // This finds the most significant non-zero bit.
600 - const index = 31 - clz32(lanes);
601 - return index < 0 ? NoLanes : 1 << index;
602 -}
603 -
604 -function getEqualOrHigherPriorityLanes(lanes: Lanes | Lane): Lanes {
605 - return (getLowestPriorityLane(lanes) << 1) - 1;
606 -}
607 -
584 export function pickArbitraryLane(lanes: Lanes): Lane {
585 // This wrapper function gets inlined. Only exists so to communicate that it
586 // doesn't matter which bit is selected; you can pick any bit without
@@ -676,39 +652,21 @@ export function markRootUpdated(
652 ) {
653 root.pendingLanes |= updateLane;
654
679 - // TODO: Theoretically, any update to any lane can unblock any other lane. But
680 - // it's not practical to try every single possible combination. We need a
681 - // heuristic to decide which lanes to attempt to render, and in which batches.
682 - // For now, we use the same heuristic as in the old ExpirationTimes model:
683 - // retry any lane at equal or lower priority, but don't try updates at higher
684 - // priority without also including the lower priority updates. This works well
685 - // when considering updates across different priority levels, but isn't
686 - // sufficient for updates within the same priority, since we want to treat
687 - // those updates as parallel.
688 -
689 - // Unsuspend any update at equal or lower priority.
690 - const higherPriorityLanes = updateLane - 1; // Turns 0b1000 into 0b0111
691 -
692 - if (enableTransitionEntanglement) {
693 - // If there are any suspended transitions, it's possible this new update
694 - // could unblock them. Clear the suspended lanes so that we can try rendering
695 - // them again.
696 - //
697 - // TODO: We really only need to unsuspend only lanes that are in the
698 - // `subtreeLanes` of the updated fiber, or the update lanes of the return
699 - // path. This would exclude suspended updates in an unrelated sibling tree,
700 - // since there's no way for this update to unblock it.
701 - //
702 - // We don't do this if the incoming update is idle, because we never process
703 - // idle updates until after all the regular updates have finished; there's no
704 - // way it could unblock a transition.
705 - if ((updateLane & IdleLanes) === NoLanes) {
706 - root.suspendedLanes = NoLanes;
707 - root.pingedLanes = NoLanes;
708 - }
709 - } else {
710 - root.suspendedLanes &= higherPriorityLanes;
711 - root.pingedLanes &= higherPriorityLanes;
655 + // If there are any suspended transitions, it's possible this new update
656 + // could unblock them. Clear the suspended lanes so that we can try rendering
657 + // them again.
658 + //
659 + // TODO: We really only need to unsuspend only lanes that are in the
660 + // `subtreeLanes` of the updated fiber, or the update lanes of the return
661 + // path. This would exclude suspended updates in an unrelated sibling tree,
662 + // since there's no way for this update to unblock it.
663 + //
664 + // We don't do this if the incoming update is idle, because we never process
665 + // idle updates until after all the regular updates have finished; there's no
666 + // way it could unblock a transition.
667 + if ((updateLane & IdleLanes) === NoLanes) {
668 + root.suspendedLanes = NoLanes;
669 + root.pingedLanes = NoLanes;
670 }
671
672 const eventTimes = root.eventTimes;
@@ -801,16 +759,32 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
759 }
760
761 export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
804 - root.entangledLanes |= entangledLanes;
762 + // In addition to entangling each of the given lanes with each other, we also
763 + // have to consider _transitive_ entanglements. For each lane that is already
764 + // entangled with *any* of the given lanes, that lane is now transitively
765 + // entangled with *all* the given lanes.
766 + //
767 + // Translated: If C is entangled with A, then entangling A with B also
768 + // entangles C with B.
769 + //
770 + // If this is hard to grasp, it might help to intentionally break this
771 + // function and look at the tests that fail in ReactTransition-test.js. Try
772 + // commenting out one of the conditions below.
773
774 + const rootEntangledLanes = (root.entangledLanes |= entangledLanes);
775 const entanglements = root.entanglements;
807 - let lanes = entangledLanes;
808 - while (lanes > 0) {
776 + let lanes = rootEntangledLanes;
777 + while (lanes) {
778 const index = pickArbitraryLaneIndex(lanes);
779 const lane = 1 << index;
811 -
812 - entanglements[index] |= entangledLanes;
813 -
780 + if (
781 + // Is this one of the newly entangled lanes?
782 + (lane & entangledLanes) |
783 + // Is this lane transitively entangled with the newly entangled lanes?
784 + (entanglements[index] & entangledLanes)
785 + ) {
786 + entanglements[index] |= entangledLanes;
787 + }
788 lanes &= ~lane;
789 }
790 }
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+12 -21
@@ -145,8 +145,8 @@ import {
145 SyncBatchedLane,
146 NoTimestamp,
147 findUpdateLane,
148 - findTransitionLane,
149 - findRetryLane,
148 + claimNextTransitionLane,
149 + claimNextRetryLane,
150 includesSomeLane,
151 isSubsetOfLanes,
152 mergeLanes,
@@ -302,8 +302,6 @@ let workInProgressRootUpdatedLanes: Lanes = NoLanes;
302 // Lanes that were pinged (in an interleaved event) during this render.
303 let workInProgressRootPingedLanes: Lanes = NoLanes;
304
305 -let mostRecentlyUpdatedRoot: FiberRoot | null = null;
306 -
305 // The most recent time we committed a fallback. This lets us ensure a train
306 // model where we don't commit new loading states in too quick succession.
307 let globalMostRecentFallbackTime: number = 0;
@@ -360,7 +358,7 @@ let spawnedWorkDuringRender: null | Array<Lane | Lanes> = null;
358 // between the first and second call.
359 let currentEventTime: number = NoTimestamp;
360 let currentEventWipLanes: Lanes = NoLanes;
363 -let currentEventPendingLanes: Lanes = NoLanes;
361 +let currentEventTransitionLane: Lanes = NoLanes;
362
363 // Dev only flag that tracks if passive effects are currently being flushed.
364 // We warn about state updates for unmounted components differently in this case.
@@ -428,20 +426,17 @@ export function requestUpdateLane(fiber: Fiber): Lane {
426 // event. Then reset the cached values once we can be sure the event is over.
427 // Our heuristic for that is whenever we enter a concurrent work loop.
428 //
431 - // We'll do the same for `currentEventPendingLanes` below.
429 + // We'll do the same for `currentEventTransitionLane` below.
430 if (currentEventWipLanes === NoLanes) {
431 currentEventWipLanes = workInProgressRootIncludedLanes;
432 }
433
434 const isTransition = requestCurrentTransition() !== NoTransition;
435 if (isTransition) {
438 - if (currentEventPendingLanes !== NoLanes) {
439 - currentEventPendingLanes =
440 - mostRecentlyUpdatedRoot !== null
441 - ? mostRecentlyUpdatedRoot.pendingLanes
442 - : NoLanes;
436 + if (currentEventTransitionLane === NoLane) {
437 + currentEventTransitionLane = claimNextTransitionLane();
438 }
444 - return findTransitionLane(currentEventWipLanes, currentEventPendingLanes);
439 + return currentEventTransitionLane;
440 }
441
442 // TODO: Remove this dependency on the Scheduler priority.
@@ -494,7 +489,8 @@ function requestRetryLane(fiber: Fiber) {
489 if (currentEventWipLanes === NoLanes) {
490 currentEventWipLanes = workInProgressRootIncludedLanes;
491 }
497 - return findRetryLane(currentEventWipLanes);
492 +
493 + return claimNextRetryLane();
494 }
495
496 export function scheduleUpdateOnFiber(
@@ -618,13 +614,6 @@ export function scheduleUpdateOnFiber(
614 schedulePendingInteractions(root, lane);
615 }
616
621 - // We use this when assigning a lane for a transition inside
622 - // `requestUpdateLane`. We assume it's the same as the root being updated,
623 - // since in the common case of a single root app it probably is. If it's not
624 - // the same root, then it's not a huge deal, we just might batch more stuff
625 - // together more than necessary.
626 - mostRecentlyUpdatedRoot = root;
627 -
617 return root;
618 }
619
@@ -793,7 +782,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
782 // event time. The next update will compute a new event time.
783 currentEventTime = NoTimestamp;
784 currentEventWipLanes = NoLanes;
796 - currentEventPendingLanes = NoLanes;
785 + currentEventTransitionLane = NoLanes;
786
787 invariant(
788 (executionContext & (RenderContext | CommitContext)) === NoContext,
@@ -2461,6 +2450,8 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) {
2450 // suspended it has resolved, which means at least part of the tree was
2451 // likely unblocked. Try rendering again, at a new expiration time.
2452 if (retryLane === NoLane) {
2453 + // TODO: Assign this to `suspenseState.retryLane`? to avoid
2454 + // unnecessary entanglement?
2455 retryLane = requestRetryLane(boundaryFiber);
2456 }
2457 // TODO: Special case idle priority?
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+12 -21
@@ -145,8 +145,8 @@ import {
145 SyncBatchedLane,
146 NoTimestamp,
147 findUpdateLane,
148 - findTransitionLane,
149 - findRetryLane,
148 + claimNextTransitionLane,
149 + claimNextRetryLane,
150 includesSomeLane,
151 isSubsetOfLanes,
152 mergeLanes,
@@ -302,8 +302,6 @@ let workInProgressRootUpdatedLanes: Lanes = NoLanes;
302 // Lanes that were pinged (in an interleaved event) during this render.
303 let workInProgressRootPingedLanes: Lanes = NoLanes;
304
305 -let mostRecentlyUpdatedRoot: FiberRoot | null = null;
306 -
305 // The most recent time we committed a fallback. This lets us ensure a train
306 // model where we don't commit new loading states in too quick succession.
307 let globalMostRecentFallbackTime: number = 0;
@@ -360,7 +358,7 @@ let spawnedWorkDuringRender: null | Array<Lane | Lanes> = null;
358 // between the first and second call.
359 let currentEventTime: number = NoTimestamp;
360 let currentEventWipLanes: Lanes = NoLanes;
363 -let currentEventPendingLanes: Lanes = NoLanes;
361 +let currentEventTransitionLane: Lanes = NoLanes;
362
363 // Dev only flag that tracks if passive effects are currently being flushed.
364 // We warn about state updates for unmounted components differently in this case.
@@ -428,20 +426,17 @@ export function requestUpdateLane(fiber: Fiber): Lane {
426 // event. Then reset the cached values once we can be sure the event is over.
427 // Our heuristic for that is whenever we enter a concurrent work loop.
428 //
431 - // We'll do the same for `currentEventPendingLanes` below.
429 + // We'll do the same for `currentEventTransitionLane` below.
430 if (currentEventWipLanes === NoLanes) {
431 currentEventWipLanes = workInProgressRootIncludedLanes;
432 }
433
434 const isTransition = requestCurrentTransition() !== NoTransition;
435 if (isTransition) {
438 - if (currentEventPendingLanes !== NoLanes) {
439 - currentEventPendingLanes =
440 - mostRecentlyUpdatedRoot !== null
441 - ? mostRecentlyUpdatedRoot.pendingLanes
442 - : NoLanes;
436 + if (currentEventTransitionLane === NoLane) {
437 + currentEventTransitionLane = claimNextTransitionLane();
438 }
444 - return findTransitionLane(currentEventWipLanes, currentEventPendingLanes);
439 + return currentEventTransitionLane;
440 }
441
442 // TODO: Remove this dependency on the Scheduler priority.
@@ -494,7 +489,8 @@ function requestRetryLane(fiber: Fiber) {
489 if (currentEventWipLanes === NoLanes) {
490 currentEventWipLanes = workInProgressRootIncludedLanes;
491 }
497 - return findRetryLane(currentEventWipLanes);
492 +
493 + return claimNextRetryLane();
494 }
495
496 export function scheduleUpdateOnFiber(
@@ -618,13 +614,6 @@ export function scheduleUpdateOnFiber(
614 schedulePendingInteractions(root, lane);
615 }
616
621 - // We use this when assigning a lane for a transition inside
622 - // `requestUpdateLane`. We assume it's the same as the root being updated,
623 - // since in the common case of a single root app it probably is. If it's not
624 - // the same root, then it's not a huge deal, we just might batch more stuff
625 - // together more than necessary.
626 - mostRecentlyUpdatedRoot = root;
627 -
617 return root;
618 }
619
@@ -793,7 +782,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
782 // event time. The next update will compute a new event time.
783 currentEventTime = NoTimestamp;
784 currentEventWipLanes = NoLanes;
796 - currentEventPendingLanes = NoLanes;
785 + currentEventTransitionLane = NoLanes;
786
787 invariant(
788 (executionContext & (RenderContext | CommitContext)) === NoContext,
@@ -2461,6 +2450,8 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) {
2450 // suspended it has resolved, which means at least part of the tree was
2451 // likely unblocked. Try rendering again, at a new expiration time.
2452 if (retryLane === NoLane) {
2453 + // TODO: Assign this to `suspenseState.retryLane`? to avoid
2454 + // unnecessary entanglement?
2455 retryLane = requestRetryLane(boundaryFiber);
2456 }
2457 // TODO: Special case idle priority?
packages/react-reconciler/src/__tests__/ReactExpiration-test.js
+4 -13
@@ -733,19 +733,10 @@ describe('ReactExpiration', () => {
733 // Both normal pri updates should have expired.
734 expect(Scheduler).toFlushExpired([
735 'Sibling',
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',
736 + // Notice that the high pri update didn't flush yet. Expiring one lane
737 + // doesn't affect other lanes. (Unless they are intentionally entangled,
738 + // like we do for overlapping transitions that affect the same state.)
739 + 'High pri: 0',
740 'Normal pri: 2',
741 'Sibling',
742 ]);
packages/react-reconciler/src/__tests__/ReactTransition-test.js
+477 -23
@@ -16,7 +16,12 @@ let Scheduler;
16 let Suspense;
17 let useState;
18 let useTransition;
19 +let startTransition;
20 let act;
21 +let getCacheForType;
22 +
23 +let caches;
24 +let seededCache;
25
26 describe('ReactTransition', () => {
27 beforeEach(() => {
@@ -27,44 +32,142 @@ describe('ReactTransition', () => {
32 useState = React.useState;
33 useTransition = React.unstable_useTransition;
34 Suspense = React.Suspense;
35 + startTransition = React.unstable_startTransition;
36 + getCacheForType = React.unstable_getCacheForType;
37 act = ReactNoop.act;
38 +
39 + caches = [];
40 + seededCache = null;
41 });
42
33 - function Text(props) {
34 - Scheduler.unstable_yieldValue(props.text);
35 - return props.text;
43 + function createTextCache() {
44 + if (seededCache !== null) {
45 + // Trick to seed a cache before it exists.
46 + // TODO: Need a built-in API to seed data before the initial render (i.e.
47 + // not a refresh because nothing has mounted yet).
48 + const cache = seededCache;
49 + seededCache = null;
50 + return cache;
51 + }
52 +
53 + const data = new Map();
54 + const version = caches.length + 1;
55 + const cache = {
56 + version,
57 + data,
58 + resolve(text) {
59 + const record = data.get(text);
60 + if (record === undefined) {
61 + const newRecord = {
62 + status: 'resolved',
63 + value: text,
64 + };
65 + data.set(text, newRecord);
66 + } else if (record.status === 'pending') {
67 + const thenable = record.value;
68 + record.status = 'resolved';
69 + record.value = text;
70 + thenable.pings.forEach(t => t());
71 + }
72 + },
73 + reject(text, error) {
74 + const record = data.get(text);
75 + if (record === undefined) {
76 + const newRecord = {
77 + status: 'rejected',
78 + value: error,
79 + };
80 + data.set(text, newRecord);
81 + } else if (record.status === 'pending') {
82 + const thenable = record.value;
83 + record.status = 'rejected';
84 + record.value = error;
85 + thenable.pings.forEach(t => t());
86 + }
87 + },
88 + };
89 + caches.push(cache);
90 + return cache;
91 }
92
38 - function createAsyncText(text) {
39 - let resolved = false;
40 - const Component = function() {
41 - if (!resolved) {
42 - Scheduler.unstable_yieldValue('Suspend! [' + text + ']');
43 - throw promise;
93 + function readText(text) {
94 + const textCache = getCacheForType(createTextCache);
95 + const record = textCache.data.get(text);
96 + if (record !== undefined) {
97 + switch (record.status) {
98 + case 'pending':
99 + Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
100 + throw record.value;
101 + case 'rejected':
102 + Scheduler.unstable_yieldValue(`Error! [${text}]`);
103 + throw record.value;
104 + case 'resolved':
105 + return textCache.version;
106 }
45 - return <Text text={text} />;
46 - };
47 - const promise = new Promise(resolve => {
48 - Component.resolve = function() {
49 - resolved = true;
50 - return resolve();
107 + } else {
108 + Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
109 +
110 + const thenable = {
111 + pings: [],
112 + then(resolve) {
113 + if (newRecord.status === 'pending') {
114 + thenable.pings.push(resolve);
115 + } else {
116 + Promise.resolve().then(() => resolve(newRecord.value));
117 + }
118 + },
119 };
52 - });
53 - return Component;
120 +
121 + const newRecord = {
122 + status: 'pending',
123 + value: thenable,
124 + };
125 + textCache.data.set(text, newRecord);
126 +
127 + throw thenable;
128 + }
129 + }
130 +
131 + function Text({text}) {
132 + Scheduler.unstable_yieldValue(text);
133 + return text;
134 + }
135 +
136 + function AsyncText({text}) {
137 + readText(text);
138 + Scheduler.unstable_yieldValue(text);
139 + return text;
140 + }
141 +
142 + function seedNextTextCache(text) {
143 + if (seededCache === null) {
144 + seededCache = createTextCache();
145 + }
146 + seededCache.resolve(text);
147 + }
148 +
149 + function resolveText(text) {
150 + if (caches.length === 0) {
151 + throw Error('Cache does not exist.');
152 + } else {
153 + // Resolve the most recently created cache. An older cache can by
154 + // resolved with `caches[index].resolve(text)`.
155 + caches[caches.length - 1].resolve(text);
156 + }
157 }
158
159 // @gate experimental
57 - it('isPending works even if called from outside an input event', async () => {
58 - const Async = createAsyncText('Async');
160 + // @gate enableCache
161 + test('isPending works even if called from outside an input event', async () => {
162 let start;
163 function App() {
164 const [show, setShow] = useState(false);
62 - const [startTransition, isPending] = useTransition();
63 - start = () => startTransition(() => setShow(true));
165 + const [_start, isPending] = useTransition();
166 + start = () => _start(() => setShow(true));
167 return (
168 <Suspense fallback={<Text text="Loading..." />}>
169 {isPending ? <Text text="Pending..." /> : null}
67 - {show ? <Async /> : <Text text="(empty)" />}
170 + {show ? <AsyncText text="Async" /> : <Text text="(empty)" />}
171 </Suspense>
172 );
173 }
@@ -89,9 +192,360 @@ describe('ReactTransition', () => {
192
193 expect(root).toMatchRenderedOutput('Pending...(empty)');
194
92 - await Async.resolve();
195 + await resolveText('Async');
196 });
197 expect(Scheduler).toHaveYielded(['Async']);
198 expect(root).toMatchRenderedOutput('Async');
199 });
200 +
201 + // @gate experimental
202 + // @gate enableCache
203 + test(
204 + 'when multiple transitions update the same queue, only the most recent ' +
205 + 'one is allowed to finish (no intermediate states)',
206 + async () => {
207 + let update;
208 + function App() {
209 + const [startContentChange, isContentPending] = useTransition();
210 + const [label, setLabel] = useState('A');
211 + const [contents, setContents] = useState('A');
212 + update = value => {
213 + ReactNoop.discreteUpdates(() => {
214 + setLabel(value);
215 + startContentChange(() => {
216 + setContents(value);
217 + });
218 + });
219 + };
220 + return (
221 + <>
222 + <Text
223 + text={
224 + label + ' label' + (isContentPending ? ' (loading...)' : '')
225 + }
226 + />
227 + <div>
228 + <Suspense fallback={<Text text="Loading..." />}>
229 + <AsyncText text={contents + ' content'} />
230 + </Suspense>
231 + </div>
232 + </>
233 + );
234 + }
235 +
236 + // Initial render
237 + const root = ReactNoop.createRoot();
238 + await act(async () => {
239 + seedNextTextCache('A content');
240 + root.render(<App />);
241 + });
242 + expect(Scheduler).toHaveYielded(['A label', 'A content']);
243 + expect(root).toMatchRenderedOutput(
244 + <>
245 + A label<div>A content</div>
246 + </>,
247 + );
248 +
249 + // Switch to B
250 + await act(async () => {
251 + update('B');
252 + });
253 + expect(Scheduler).toHaveYielded([
254 + // Commit pending state
255 + 'B label (loading...)',
256 + 'A content',
257 +
258 + // Attempt to render B, but it suspends
259 + 'B label',
260 + 'Suspend! [B content]',
261 + 'Loading...',
262 + ]);
263 + // This is a refresh transition so it shouldn't show a fallback
264 + expect(root).toMatchRenderedOutput(
265 + <>
266 + B label (loading...)<div>A content</div>
267 + </>,
268 + );
269 +
270 + // Before B finishes loading, switch to C
271 + await act(async () => {
272 + update('C');
273 + });
274 + expect(Scheduler).toHaveYielded([
275 + // Commit pending state
276 + 'C label (loading...)',
277 + 'A content',
278 +
279 + // Attempt to render C, but it suspends
280 + 'C label',
281 + 'Suspend! [C content]',
282 + 'Loading...',
283 + ]);
284 + expect(root).toMatchRenderedOutput(
285 + <>
286 + C label (loading...)<div>A content</div>
287 + </>,
288 + );
289 +
290 + // Finish loading B. But we're not allowed to render B because it's
291 + // entangled with C. So we're still pending.
292 + await act(async () => {
293 + resolveText('B content');
294 + });
295 + expect(Scheduler).toHaveYielded([
296 + // Attempt to render C, but it suspends
297 + 'C label',
298 + 'Suspend! [C content]',
299 + 'Loading...',
300 + ]);
301 + expect(root).toMatchRenderedOutput(
302 + <>
303 + C label (loading...)<div>A content</div>
304 + </>,
305 + );
306 +
307 + // Now finish loading C. This is the terminal update, so it can finish.
308 + await act(async () => {
309 + resolveText('C content');
310 + });
311 + expect(Scheduler).toHaveYielded(['C label', 'C content']);
312 + expect(root).toMatchRenderedOutput(
313 + <>
314 + C label<div>C content</div>
315 + </>,
316 + );
317 + },
318 + );
319 +
320 + // Same as previous test, but for class update queue.
321 + // @gate experimental
322 + // @gate enableCache
323 + test(
324 + 'when multiple transitions update the same queue, only the most recent ' +
325 + 'one is allowed to finish (no intermediate states) (classes)',
326 + async () => {
327 + let update;
328 + class App extends React.Component {
329 + state = {
330 + label: 'A',
331 + contents: 'A',
332 + };
333 + render() {
334 + update = value => {
335 + ReactNoop.discreteUpdates(() => {
336 + this.setState({label: value});
337 + startTransition(() => {
338 + this.setState({contents: value});
339 + });
340 + });
341 + };
342 + const label = this.state.label;
343 + const contents = this.state.contents;
344 + const isContentPending = label !== contents;
345 + return (
346 + <>
347 + <Text
348 + text={
349 + label + ' label' + (isContentPending ? ' (loading...)' : '')
350 + }
351 + />
352 + <div>
353 + <Suspense fallback={<Text text="Loading..." />}>
354 + <AsyncText text={contents + ' content'} />
355 + </Suspense>
356 + </div>
357 + </>
358 + );
359 + }
360 + }
361 +
362 + // Initial render
363 + const root = ReactNoop.createRoot();
364 + await act(async () => {
365 + seedNextTextCache('A content');
366 + root.render(<App />);
367 + });
368 + expect(Scheduler).toHaveYielded(['A label', 'A content']);
369 + expect(root).toMatchRenderedOutput(
370 + <>
371 + A label<div>A content</div>
372 + </>,
373 + );
374 +
375 + // Switch to B
376 + await act(async () => {
377 + update('B');
378 + });
379 + expect(Scheduler).toHaveYielded([
380 + // Commit pending state
381 + 'B label (loading...)',
382 + 'A content',
383 +
384 + // Attempt to render B, but it suspends
385 + 'B label',
386 + 'Suspend! [B content]',
387 + 'Loading...',
388 + ]);
389 + // This is a refresh transition so it shouldn't show a fallback
390 + expect(root).toMatchRenderedOutput(
391 + <>
392 + B label (loading...)<div>A content</div>
393 + </>,
394 + );
395 +
396 + // Before B finishes loading, switch to C
397 + await act(async () => {
398 + update('C');
399 + });
400 + expect(Scheduler).toHaveYielded([
401 + // Commit pending state
402 + 'C label (loading...)',
403 + 'A content',
404 +
405 + // Attempt to render C, but it suspends
406 + 'C label',
407 + 'Suspend! [C content]',
408 + 'Loading...',
409 + ]);
410 + expect(root).toMatchRenderedOutput(
411 + <>
412 + C label (loading...)<div>A content</div>
413 + </>,
414 + );
415 +
416 + // Finish loading B. But we're not allowed to render B because it's
417 + // entangled with C. So we're still pending.
418 + await act(async () => {
419 + resolveText('B content');
420 + });
421 + expect(Scheduler).toHaveYielded([
422 + // Attempt to render C, but it suspends
423 + 'C label',
424 + 'Suspend! [C content]',
425 + 'Loading...',
426 + ]);
427 + expect(root).toMatchRenderedOutput(
428 + <>
429 + C label (loading...)<div>A content</div>
430 + </>,
431 + );
432 +
433 + // Now finish loading C. This is the terminal update, so it can finish.
434 + await act(async () => {
435 + resolveText('C content');
436 + });
437 + expect(Scheduler).toHaveYielded(['C label', 'C content']);
438 + expect(root).toMatchRenderedOutput(
439 + <>
440 + C label<div>C content</div>
441 + </>,
442 + );
443 + },
444 + );
445 +
446 + // @gate experimental
447 + // @gate enableCache
448 + test(
449 + 'when multiple transitions update overlapping queues, all the transitions ' +
450 + 'across all the queues are entangled',
451 + async () => {
452 + let setShowA;
453 + let setShowB;
454 + let setShowC;
455 + function App() {
456 + const [showA, _setShowA] = useState(false);
457 + const [showB, _setShowB] = useState(false);
458 + const [showC, _setShowC] = useState(false);
459 + setShowA = _setShowA;
460 + setShowB = _setShowB;
461 + setShowC = _setShowC;
462 +
463 + // Only one of these children should be visible at a time. Except
464 + // instead of being modeled as a single state, it's three separate
465 + // states that are updated simultaneously. This may seem a bit
466 + // contrived, but it's more common than you might think. Usually via
467 + // a framework or indirection. For example, consider a tooltip manager
468 + // that only shows a single tooltip at a time. Or a router that
469 + // highlights links to the active route.
470 + return (
471 + <>
472 + <Suspense fallback={<Text text="Loading..." />}>
473 + {showA ? <AsyncText text="A" /> : null}
474 + {showB ? <AsyncText text="B" /> : null}
475 + {showC ? <AsyncText text="C" /> : null}
476 + </Suspense>
477 + </>
478 + );
479 + }
480 +
481 + // Initial render. Start with all children hidden.
482 + const root = ReactNoop.createRoot();
483 + await act(async () => {
484 + root.render(<App />);
485 + });
486 + expect(Scheduler).toHaveYielded([]);
487 + expect(root).toMatchRenderedOutput(null);
488 +
489 + // Switch to A.
490 + await act(async () => {
491 + startTransition(() => {
492 + setShowA(true);
493 + });
494 + });
495 + expect(Scheduler).toHaveYielded(['Suspend! [A]', 'Loading...']);
496 + expect(root).toMatchRenderedOutput(null);
497 +
498 + // Before A loads, switch to B. This should entangle A with B.
499 + await act(async () => {
500 + startTransition(() => {
501 + setShowA(false);
502 + setShowB(true);
503 + });
504 + });
505 + expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...']);
506 + expect(root).toMatchRenderedOutput(null);
507 +
508 + // Before A or B loads, switch to C. This should entangle C with B, and
509 + // transitively entangle C with A.
510 + await act(async () => {
511 + startTransition(() => {
512 + setShowB(false);
513 + setShowC(true);
514 + });
515 + });
516 + expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
517 + expect(root).toMatchRenderedOutput(null);
518 +
519 + // Now the data starts resolving out of order.
520 +
521 + // First resolve B. This will attempt to render C, since everything is
522 + // entangled.
523 + await act(async () => {
524 + startTransition(() => {
525 + resolveText('B');
526 + });
527 + });
528 + expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
529 + expect(root).toMatchRenderedOutput(null);
530 +
531 + // Now resolve A. Again, this will attempt to render C, since everything
532 + // is entangled.
533 + await act(async () => {
534 + startTransition(() => {
535 + resolveText('A');
536 + });
537 + });
538 + expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
539 + expect(root).toMatchRenderedOutput(null);
540 +
541 + // Finally, resolve C. This time we can finish.
542 + await act(async () => {
543 + startTransition(() => {
544 + resolveText('C');
545 + });
546 + });
547 + expect(Scheduler).toHaveYielded(['C']);
548 + expect(root).toMatchRenderedOutput('C');
549 + },
550 + );
551 });