@samitouri / QOS-React-2 / commits / 6c526c5153

Don't shift interleaved updates to separate lane (#20681)

Now that interleaved updates are added to a special queue, we no longer need to shift them into their own lane. We can add to a lane that's already in the middle of rendering without risk of tearing. See #20615 for more background. I've only changed this in the new fork, and only behind the enableTransitionEntanglements flag. Most of this commit involves updating tests. The "shift-to-a-new" lane trick was intentionally used in a handful of tests where two or more updates need to be scheduled in different lanes. Most of these tests were written before `startTransition` existed, and all of them were written before transitions were assigned arbitrary lanes. So I ported these tests to use `startTransition` instead, which is the idiomatic way to mark an update as parallel. I didn't change the old fork at all. Writing these tests in such a way that they also pass in the old fork actually revealed a few flaws in the current implementation regarding interrupting a suspended refresh transition early, which is a good reminder that we should be writing our tests using idiomatic patterns as much as we possibly can.

Andrew Clark committed Feb 9, 2021 at 02:03 UTC 6c526c5153bc350d2a62c7dcbc698be77d3f2439
15 files changed +503 -366
packages/react-reconciler/src/ReactFiberHooks.new.js
+5 -4
@@ -2029,10 +2029,11 @@ function dispatchAction<S, A>(
2029
2030 // Entangle the new transition lane with the other transition lanes.
2031 const newQueueLanes = mergeLanes(queueLanes, lane);
2032 - if (newQueueLanes !== queueLanes) {
2033 - queue.lanes = newQueueLanes;
2034 - markRootEntangled(root, newQueueLanes);
2035 - }
2032 + queue.lanes = newQueueLanes;
2033 + // Even if queue.lanes already include lane, we don't know for certain if
2034 + // the lane finished since the last time we entangled it. So we need to
2035 + // entangle it again, just to be sure.
2036 + markRootEntangled(root, newQueueLanes);
2037 }
2038 }
2039
packages/react-reconciler/src/ReactFiberHooks.old.js
+5 -4
@@ -2029,10 +2029,11 @@ function dispatchAction<S, A>(
2029
2030 // Entangle the new transition lane with the other transition lanes.
2031 const newQueueLanes = mergeLanes(queueLanes, lane);
2032 - if (newQueueLanes !== queueLanes) {
2033 - queue.lanes = newQueueLanes;
2034 - markRootEntangled(root, newQueueLanes);
2035 - }
2032 + queue.lanes = newQueueLanes;
2033 + // Even if queue.lanes already include lane, we don't know for certain if
2034 + // the lane finished since the last time we entangled it. So we need to
2035 + // entangle it again, just to be sure.
2036 + markRootEntangled(root, newQueueLanes);
2037 }
2038 }
2039
packages/react-reconciler/src/ReactFiberLane.new.js
+86 -50
@@ -36,7 +36,10 @@ export type Lane = number;
36 export type LaneMap<T> = Array<T>;
37
38 import invariant from 'shared/invariant';
39 -import {enableCache} from 'shared/ReactFeatureFlags';
39 +import {
40 + enableCache,
41 + enableTransitionEntanglement,
42 +} from 'shared/ReactFeatureFlags';
43
44 import {
45 ImmediatePriority as ImmediateSchedulerPriority,
@@ -92,11 +95,12 @@ export const DefaultLanes: Lanes = /* */ 0b0000000000000000000
95
96 const TransitionHydrationLane: Lane = /* */ 0b0000000000000000001000000000000;
97 const TransitionLanes: Lanes = /* */ 0b0000000001111111110000000000000;
95 -const SomeTransitionLane: Lane = /* */ 0b0000000000000000010000000000000;
98 +const FirstTransitionLane: Lane = /* */ 0b0000000000000000010000000000000;
99
100 const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
101
99 -export const SomeRetryLane: Lanes = /* */ 0b0000010000000000000000000000000;
102 +const FirstRetryLane: Lanes = /* */ 0b0000000010000000000000000000000;
103 +export const SomeRetryLane: Lane = FirstRetryLane;
104
105 export const SelectiveHydrationLane: Lane = /* */ 0b0000100000000000000000000000000;
106
@@ -111,8 +115,8 @@ export const NoTimestamp = -1;
115
116 let currentUpdateLanePriority: LanePriority = NoLanePriority;
117
114 -let nextTransitionLane: Lane = SomeTransitionLane;
115 -let nextRetryLane: Lane = SomeRetryLane;
118 +let nextTransitionLane: Lane = FirstTransitionLane;
119 +let nextRetryLane: Lane = FirstRetryLane;
120
121 export function getCurrentUpdateLanePriority(): LanePriority {
122 return currentUpdateLanePriority;
@@ -498,56 +502,88 @@ export function findUpdateLane(
502 lanePriority: LanePriority,
503 wipLanes: Lanes,
504 ): Lane {
501 - switch (lanePriority) {
502 - case NoLanePriority:
503 - break;
504 - case SyncLanePriority:
505 - return SyncLane;
506 - case SyncBatchedLanePriority:
507 - return SyncBatchedLane;
508 - case InputDiscreteLanePriority: {
509 - const lane = pickArbitraryLane(InputDiscreteLanes & ~wipLanes);
510 - if (lane === NoLane) {
511 - // Shift to the next priority level
512 - return findUpdateLane(InputContinuousLanePriority, wipLanes);
505 + if (enableTransitionEntanglement) {
506 + // Ignore wipLanes. Always assign to the same bit per priority.
507 + switch (lanePriority) {
508 + case NoLanePriority:
509 + break;
510 + case SyncLanePriority:
511 + return SyncLane;
512 + case SyncBatchedLanePriority:
513 + return SyncBatchedLane;
514 + case InputDiscreteLanePriority: {
515 + return pickArbitraryLane(InputDiscreteLanes);
516 }
514 - return lane;
515 - }
516 - case InputContinuousLanePriority: {
517 - const lane = pickArbitraryLane(InputContinuousLanes & ~wipLanes);
518 - if (lane === NoLane) {
519 - // Shift to the next priority level
520 - return findUpdateLane(DefaultLanePriority, wipLanes);
517 + case InputContinuousLanePriority: {
518 + return pickArbitraryLane(InputContinuousLanes);
519 + }
520 + case DefaultLanePriority: {
521 + return pickArbitraryLane(DefaultLanes);
522 }
522 - return lane;
523 + case TransitionPriority: // Should be handled by findTransitionLane instead
524 + case RetryLanePriority: // Should be handled by findRetryLane instead
525 + break;
526 + case IdleLanePriority:
527 + return pickArbitraryLane(IdleLanes);
528 + default:
529 + // The remaining priorities are not valid for updates
530 + break;
531 }
524 - case DefaultLanePriority: {
525 - let lane = pickArbitraryLane(DefaultLanes & ~wipLanes);
526 - if (lane === NoLane) {
527 - // If all the default lanes are already being worked on, look for a
528 - // lane in the transition range.
529 - lane = pickArbitraryLane(TransitionLanes & ~wipLanes);
532 + } else {
533 + // Old behavior that uses wipLanes to shift interleaved updates into a
534 + // separate lane. This is no longer needed because we put interleaved
535 + // updates on a special queue.
536 + switch (lanePriority) {
537 + case NoLanePriority:
538 + break;
539 + case SyncLanePriority:
540 + return SyncLane;
541 + case SyncBatchedLanePriority:
542 + return SyncBatchedLane;
543 + case InputDiscreteLanePriority: {
544 + const lane = pickArbitraryLane(InputDiscreteLanes & ~wipLanes);
545 if (lane === NoLane) {
531 - // All the transition lanes are taken, too. This should be very
532 - // rare, but as a last resort, pick a default lane. This will have
533 - // the effect of interrupting the current work-in-progress render.
534 - lane = pickArbitraryLane(DefaultLanes);
546 + // Shift to the next priority level
547 + return findUpdateLane(InputContinuousLanePriority, wipLanes);
548 }
549 + return lane;
550 }
537 - return lane;
538 - }
539 - case TransitionPriority: // Should be handled by findTransitionLane instead
540 - case RetryLanePriority: // Should be handled by findRetryLane instead
541 - break;
542 - case IdleLanePriority:
543 - let lane = pickArbitraryLane(IdleLanes & ~wipLanes);
544 - if (lane === NoLane) {
545 - lane = pickArbitraryLane(IdleLanes);
551 + case InputContinuousLanePriority: {
552 + const lane = pickArbitraryLane(InputContinuousLanes & ~wipLanes);
553 + if (lane === NoLane) {
554 + // Shift to the next priority level
555 + return findUpdateLane(DefaultLanePriority, wipLanes);
556 + }
557 + return lane;
558 }
547 - return lane;
548 - default:
549 - // The remaining priorities are not valid for updates
550 - break;
559 + case DefaultLanePriority: {
560 + let lane = pickArbitraryLane(DefaultLanes & ~wipLanes);
561 + if (lane === NoLane) {
562 + // If all the default lanes are already being worked on, look for a
563 + // lane in the transition range.
564 + lane = pickArbitraryLane(TransitionLanes & ~wipLanes);
565 + if (lane === NoLane) {
566 + // All the transition lanes are taken, too. This should be very
567 + // rare, but as a last resort, pick a default lane. This will have
568 + // the effect of interrupting the current work-in-progress render.
569 + lane = pickArbitraryLane(DefaultLanes);
570 + }
571 + }
572 + return lane;
573 + }
574 + case TransitionPriority: // Should be handled by findTransitionLane instead
575 + case RetryLanePriority: // Should be handled by findRetryLane instead
576 + break;
577 + case IdleLanePriority:
578 + let lane = pickArbitraryLane(IdleLanes & ~wipLanes);
579 + if (lane === NoLane) {
580 + lane = pickArbitraryLane(IdleLanes);
581 + }
582 + return lane;
583 + default:
584 + // The remaining priorities are not valid for updates
585 + break;
586 + }
587 }
588 invariant(
589 false,
@@ -563,7 +599,7 @@ export function claimNextTransitionLane(): Lane {
599 const lane = nextTransitionLane;
600 nextTransitionLane <<= 1;
601 if ((nextTransitionLane & TransitionLanes) === 0) {
566 - nextTransitionLane = SomeTransitionLane;
602 + nextTransitionLane = FirstTransitionLane;
603 }
604 return lane;
605 }
@@ -572,7 +608,7 @@ export function claimNextRetryLane(): Lane {
608 const lane = nextRetryLane;
609 nextRetryLane <<= 1;
610 if ((nextRetryLane & RetryLanes) === 0) {
575 - nextRetryLane = SomeRetryLane;
611 + nextRetryLane = FirstRetryLane;
612 }
613 return lane;
614 }
packages/react-reconciler/src/ReactFiberLane.old.js
+86 -50
@@ -36,7 +36,10 @@ export type Lane = number;
36 export type LaneMap<T> = Array<T>;
37
38 import invariant from 'shared/invariant';
39 -import {enableCache} from 'shared/ReactFeatureFlags';
39 +import {
40 + enableCache,
41 + enableTransitionEntanglement,
42 +} from 'shared/ReactFeatureFlags';
43
44 import {
45 ImmediatePriority as ImmediateSchedulerPriority,
@@ -92,11 +95,12 @@ export const DefaultLanes: Lanes = /* */ 0b0000000000000000000
95
96 const TransitionHydrationLane: Lane = /* */ 0b0000000000000000001000000000000;
97 const TransitionLanes: Lanes = /* */ 0b0000000001111111110000000000000;
95 -const SomeTransitionLane: Lane = /* */ 0b0000000000000000010000000000000;
98 +const FirstTransitionLane: Lane = /* */ 0b0000000000000000010000000000000;
99
100 const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
101
99 -export const SomeRetryLane: Lanes = /* */ 0b0000010000000000000000000000000;
102 +const FirstRetryLane: Lanes = /* */ 0b0000000010000000000000000000000;
103 +export const SomeRetryLane: Lane = FirstRetryLane;
104
105 export const SelectiveHydrationLane: Lane = /* */ 0b0000100000000000000000000000000;
106
@@ -111,8 +115,8 @@ export const NoTimestamp = -1;
115
116 let currentUpdateLanePriority: LanePriority = NoLanePriority;
117
114 -let nextTransitionLane: Lane = SomeTransitionLane;
115 -let nextRetryLane: Lane = SomeRetryLane;
118 +let nextTransitionLane: Lane = FirstTransitionLane;
119 +let nextRetryLane: Lane = FirstRetryLane;
120
121 export function getCurrentUpdateLanePriority(): LanePriority {
122 return currentUpdateLanePriority;
@@ -498,56 +502,88 @@ export function findUpdateLane(
502 lanePriority: LanePriority,
503 wipLanes: Lanes,
504 ): Lane {
501 - switch (lanePriority) {
502 - case NoLanePriority:
503 - break;
504 - case SyncLanePriority:
505 - return SyncLane;
506 - case SyncBatchedLanePriority:
507 - return SyncBatchedLane;
508 - case InputDiscreteLanePriority: {
509 - const lane = pickArbitraryLane(InputDiscreteLanes & ~wipLanes);
510 - if (lane === NoLane) {
511 - // Shift to the next priority level
512 - return findUpdateLane(InputContinuousLanePriority, wipLanes);
505 + if (enableTransitionEntanglement) {
506 + // Ignore wipLanes. Always assign to the same bit per priority.
507 + switch (lanePriority) {
508 + case NoLanePriority:
509 + break;
510 + case SyncLanePriority:
511 + return SyncLane;
512 + case SyncBatchedLanePriority:
513 + return SyncBatchedLane;
514 + case InputDiscreteLanePriority: {
515 + return pickArbitraryLane(InputDiscreteLanes);
516 }
514 - return lane;
515 - }
516 - case InputContinuousLanePriority: {
517 - const lane = pickArbitraryLane(InputContinuousLanes & ~wipLanes);
518 - if (lane === NoLane) {
519 - // Shift to the next priority level
520 - return findUpdateLane(DefaultLanePriority, wipLanes);
517 + case InputContinuousLanePriority: {
518 + return pickArbitraryLane(InputContinuousLanes);
519 + }
520 + case DefaultLanePriority: {
521 + return pickArbitraryLane(DefaultLanes);
522 }
522 - return lane;
523 + case TransitionPriority: // Should be handled by findTransitionLane instead
524 + case RetryLanePriority: // Should be handled by findRetryLane instead
525 + break;
526 + case IdleLanePriority:
527 + return pickArbitraryLane(IdleLanes);
528 + default:
529 + // The remaining priorities are not valid for updates
530 + break;
531 }
524 - case DefaultLanePriority: {
525 - let lane = pickArbitraryLane(DefaultLanes & ~wipLanes);
526 - if (lane === NoLane) {
527 - // If all the default lanes are already being worked on, look for a
528 - // lane in the transition range.
529 - lane = pickArbitraryLane(TransitionLanes & ~wipLanes);
532 + } else {
533 + // Old behavior that uses wipLanes to shift interleaved updates into a
534 + // separate lane. This is no longer needed because we put interleaved
535 + // updates on a special queue.
536 + switch (lanePriority) {
537 + case NoLanePriority:
538 + break;
539 + case SyncLanePriority:
540 + return SyncLane;
541 + case SyncBatchedLanePriority:
542 + return SyncBatchedLane;
543 + case InputDiscreteLanePriority: {
544 + const lane = pickArbitraryLane(InputDiscreteLanes & ~wipLanes);
545 if (lane === NoLane) {
531 - // All the transition lanes are taken, too. This should be very
532 - // rare, but as a last resort, pick a default lane. This will have
533 - // the effect of interrupting the current work-in-progress render.
534 - lane = pickArbitraryLane(DefaultLanes);
546 + // Shift to the next priority level
547 + return findUpdateLane(InputContinuousLanePriority, wipLanes);
548 }
549 + return lane;
550 }
537 - return lane;
538 - }
539 - case TransitionPriority: // Should be handled by findTransitionLane instead
540 - case RetryLanePriority: // Should be handled by findRetryLane instead
541 - break;
542 - case IdleLanePriority:
543 - let lane = pickArbitraryLane(IdleLanes & ~wipLanes);
544 - if (lane === NoLane) {
545 - lane = pickArbitraryLane(IdleLanes);
551 + case InputContinuousLanePriority: {
552 + const lane = pickArbitraryLane(InputContinuousLanes & ~wipLanes);
553 + if (lane === NoLane) {
554 + // Shift to the next priority level
555 + return findUpdateLane(DefaultLanePriority, wipLanes);
556 + }
557 + return lane;
558 }
547 - return lane;
548 - default:
549 - // The remaining priorities are not valid for updates
550 - break;
559 + case DefaultLanePriority: {
560 + let lane = pickArbitraryLane(DefaultLanes & ~wipLanes);
561 + if (lane === NoLane) {
562 + // If all the default lanes are already being worked on, look for a
563 + // lane in the transition range.
564 + lane = pickArbitraryLane(TransitionLanes & ~wipLanes);
565 + if (lane === NoLane) {
566 + // All the transition lanes are taken, too. This should be very
567 + // rare, but as a last resort, pick a default lane. This will have
568 + // the effect of interrupting the current work-in-progress render.
569 + lane = pickArbitraryLane(DefaultLanes);
570 + }
571 + }
572 + return lane;
573 + }
574 + case TransitionPriority: // Should be handled by findTransitionLane instead
575 + case RetryLanePriority: // Should be handled by findRetryLane instead
576 + break;
577 + case IdleLanePriority:
578 + let lane = pickArbitraryLane(IdleLanes & ~wipLanes);
579 + if (lane === NoLane) {
580 + lane = pickArbitraryLane(IdleLanes);
581 + }
582 + return lane;
583 + default:
584 + // The remaining priorities are not valid for updates
585 + break;
586 + }
587 }
588 invariant(
589 false,
@@ -563,7 +599,7 @@ export function claimNextTransitionLane(): Lane {
599 const lane = nextTransitionLane;
600 nextTransitionLane <<= 1;
601 if ((nextTransitionLane & TransitionLanes) === 0) {
566 - nextTransitionLane = SomeTransitionLane;
602 + nextTransitionLane = FirstTransitionLane;
603 }
604 return lane;
605 }
@@ -572,7 +608,7 @@ export function claimNextRetryLane(): Lane {
608 const lane = nextRetryLane;
609 nextRetryLane <<= 1;
610 if ((nextRetryLane & RetryLanes) === 0) {
575 - nextRetryLane = SomeRetryLane;
611 + nextRetryLane = FirstRetryLane;
612 }
613 return lane;
614 }
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+3
@@ -34,6 +34,7 @@ import {
34 disableSchedulerTimeoutInWorkLoop,
35 enableDoubleInvokingEffects,
36 skipUnmountedBoundaries,
37 + enableTransitionEntanglement,
38 } from 'shared/ReactFeatureFlags';
39 import ReactSharedInternals from 'shared/ReactSharedInternals';
40 import invariant from 'shared/invariant';
@@ -832,6 +833,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
833 let exitStatus = renderRootConcurrent(root, lanes);
834
835 if (
836 + !enableTransitionEntanglement &&
837 includesSomeLane(
838 workInProgressRootIncludedLanes,
839 workInProgressRootUpdatedLanes,
@@ -1037,6 +1039,7 @@ function performSyncWorkOnRoot(root) {
1039 lanes = workInProgressRootRenderLanes;
1040 exitStatus = renderRootSync(root, lanes);
1041 if (
1042 + !enableTransitionEntanglement &&
1043 includesSomeLane(
1044 workInProgressRootIncludedLanes,
1045 workInProgressRootUpdatedLanes,
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+3
@@ -34,6 +34,7 @@ import {
34 disableSchedulerTimeoutInWorkLoop,
35 enableDoubleInvokingEffects,
36 skipUnmountedBoundaries,
37 + enableTransitionEntanglement,
38 } from 'shared/ReactFeatureFlags';
39 import ReactSharedInternals from 'shared/ReactSharedInternals';
40 import invariant from 'shared/invariant';
@@ -832,6 +833,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
833 let exitStatus = renderRootConcurrent(root, lanes);
834
835 if (
836 + !enableTransitionEntanglement &&
837 includesSomeLane(
838 workInProgressRootIncludedLanes,
839 workInProgressRootUpdatedLanes,
@@ -1037,6 +1039,7 @@ function performSyncWorkOnRoot(root) {
1039 lanes = workInProgressRootRenderLanes;
1040 exitStatus = renderRootSync(root, lanes);
1041 if (
1042 + !enableTransitionEntanglement &&
1043 includesSomeLane(
1044 workInProgressRootIncludedLanes,
1045 workInProgressRootUpdatedLanes,
packages/react-reconciler/src/ReactUpdateQueue.new.js
+5 -4
@@ -285,10 +285,11 @@ export function entangleTransitions(root: FiberRoot, fiber: Fiber, lane: Lane) {
285
286 // Entangle the new transition lane with the other transition lanes.
287 const newQueueLanes = mergeLanes(queueLanes, lane);
288 - if (newQueueLanes !== queueLanes) {
289 - sharedQueue.lanes = newQueueLanes;
290 - markRootEntangled(root, newQueueLanes);
291 - }
288 + sharedQueue.lanes = newQueueLanes;
289 + // Even if queue.lanes already include lane, we don't know for certain if
290 + // the lane finished since the last time we entangled it. So we need to
291 + // entangle it again, just to be sure.
292 + markRootEntangled(root, newQueueLanes);
293 }
294 }
295
packages/react-reconciler/src/ReactUpdateQueue.old.js
+5 -4
@@ -285,10 +285,11 @@ export function entangleTransitions(root: FiberRoot, fiber: Fiber, lane: Lane) {
285
286 // Entangle the new transition lane with the other transition lanes.
287 const newQueueLanes = mergeLanes(queueLanes, lane);
288 - if (newQueueLanes !== queueLanes) {
289 - sharedQueue.lanes = newQueueLanes;
290 - markRootEntangled(root, newQueueLanes);
291 - }
288 + sharedQueue.lanes = newQueueLanes;
289 + // Even if queue.lanes already include lane, we don't know for certain if
290 + // the lane finished since the last time we entangled it. So we need to
291 + // entangle it again, just to be sure.
292 + markRootEntangled(root, newQueueLanes);
293 }
294 }
295
packages/react-reconciler/src/__tests__/DebugTracing-test.internal.js
+7 -4
@@ -129,9 +129,9 @@ describe('DebugTracing', () => {
129 expect(Scheduler).toFlushUntilNextPaint([]);
130
131 expect(logs).toEqual([
132 - 'group: ⚛️ render (0b0000010000000000000000000000000)',
132 + 'group: ⚛️ render (0b0000000010000000000000000000000)',
133 'log: <Example/>',
134 - 'groupEnd: ⚛️ render (0b0000010000000000000000000000000)',
134 + 'groupEnd: ⚛️ render (0b0000000010000000000000000000000)',
135 ]);
136 });
137
@@ -209,9 +209,9 @@ describe('DebugTracing', () => {
209 expect(Scheduler).toFlushUntilNextPaint([]);
210
211 expect(logs).toEqual([
212 - 'group: ⚛️ render (0b0000010000000000000000000000000)',
212 + 'group: ⚛️ render (0b0000000010000000000000000000000)',
213 'log: <Example/>',
214 - 'groupEnd: ⚛️ render (0b0000010000000000000000000000000)',
214 + 'groupEnd: ⚛️ render (0b0000000010000000000000000000000)',
215 ]);
216 });
217
@@ -327,6 +327,9 @@ describe('DebugTracing', () => {
327 ]);
328 });
329
330 + // This test is coupled to lane implementation details, so I'm disabling it
331 + // until it stabilizes so we don't have to repeatedly update it.
332 + // @gate !enableTransitionEntanglement
333 // @gate experimental && build === 'development' && enableDebugTracing
334 it('should log cascading passive updates', () => {
335 function Example() {
packages/react-reconciler/src/__tests__/ReactExpiration-test.js
+24 -15
@@ -14,6 +14,7 @@ let ReactNoop;
14 let Scheduler;
15 let readText;
16 let resolveText;
17 +let startTransition;
18
19 describe('ReactExpiration', () => {
20 beforeEach(() => {
@@ -22,6 +23,7 @@ describe('ReactExpiration', () => {
23 React = require('react');
24 ReactNoop = require('react-noop-renderer');
25 Scheduler = require('scheduler');
26 + startTransition = React.unstable_startTransition;
27
28 const textCache = new Map();
29
@@ -610,6 +612,7 @@ describe('ReactExpiration', () => {
612 expect(root).toMatchRenderedOutput('Sync pri: 2, Idle pri: 2');
613 });
614
615 + // @gate experimental
616 it('a single update can expire without forcing all other updates to expire', async () => {
617 const {useState} = React;
618
@@ -648,12 +651,18 @@ describe('ReactExpiration', () => {
651
652 await ReactNoop.act(async () => {
653 // Partially render an update
651 - updateNormalPri();
654 + startTransition(() => {
655 + updateNormalPri();
656 + });
657 expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']);
653 - // Some time goes by. In an interleaved event, schedule another update.
658 +
659 + // Some time goes by. Schedule another update.
660 // This will be placed into a separate batch.
661 Scheduler.unstable_advanceTime(4000);
656 - updateNormalPri();
662 +
663 + startTransition(() => {
664 + updateNormalPri();
665 + });
666 // Keep rendering the first update
667 expect(Scheduler).toFlushAndYieldThrough(['Normal pri: 1']);
668 // More time goes by. Enough to expire the first batch, but not the
@@ -662,20 +671,20 @@ describe('ReactExpiration', () => {
671 // Attempt to interrupt with a high pri update.
672 updateHighPri();
673
665 - // The first update expired, so first will finish it without interrupting.
666 - // But not the second update, which hasn't expired yet.
674 + // The first update expired, so first will finish it without
675 + // interrupting. But not the second update, which hasn't expired yet.
676 expect(Scheduler).toFlushExpired(['Sibling']);
677 + expect(Scheduler).toFlushAndYield([
678 + // Then render the high pri update
679 + 'High pri: 1',
680 + 'Normal pri: 1',
681 + 'Sibling',
682 + // Then the second normal pri update
683 + 'High pri: 1',
684 + 'Normal pri: 2',
685 + 'Sibling',
686 + ]);
687 });
669 - expect(Scheduler).toHaveYielded([
670 - // Then render the high pri update
671 - 'High pri: 1',
672 - 'Normal pri: 1',
673 - 'Sibling',
674 - // Then the second normal pri update
675 - 'High pri: 1',
676 - 'Normal pri: 2',
677 - 'Sibling',
678 - ]);
688 });
689
690 it('detects starvation in multiple batches', async () => {
packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.js
+10 -2
@@ -19,6 +19,7 @@ let NormalPriority;
19 let LowPriority;
20 let IdlePriority;
21 let runWithPriority;
22 +let startTransition;
23
24 describe('ReactSchedulerIntegration', () => {
25 beforeEach(() => {
@@ -33,6 +34,7 @@ describe('ReactSchedulerIntegration', () => {
34 LowPriority = Scheduler.unstable_LowPriority;
35 IdlePriority = Scheduler.unstable_IdlePriority;
36 runWithPriority = Scheduler.unstable_runWithPriority;
37 + startTransition = React.unstable_startTransition;
38 });
39
40 function getCurrentPriorityAsString() {
@@ -446,6 +448,7 @@ describe(
448 React = require('react');
449 ReactNoop = require('react-noop-renderer');
450 Scheduler = require('scheduler');
451 + startTransition = React.unstable_startTransition;
452 });
453
454 afterEach(() => {
@@ -494,6 +497,7 @@ describe(
497 });
498 });
499
500 + // @gate experimental
501 it('mock Scheduler module to check if `shouldYield` is called', async () => {
502 // This test reproduces a bug where React's Scheduler task timed out but
503 // the `shouldYield` method returned true. Usually we try not to mock
@@ -518,7 +522,9 @@ describe(
522
523 await ReactNoop.act(async () => {
524 // Partially render the tree, then yield
521 - ReactNoop.render(<App />);
525 + startTransition(() => {
526 + ReactNoop.render(<App />);
527 + });
528 expect(Scheduler).toFlushAndYieldThrough(['A']);
529
530 // Start logging whenever shouldYield is called
@@ -535,7 +541,9 @@ describe(
541 // We only check before yielding to the main thread (to avoid starvation
542 // by other main thread work) or when receiving an update (to avoid
543 // starvation by incoming updates).
538 - ReactNoop.render(<App />);
544 + startTransition(() => {
545 + ReactNoop.render(<App />);
546 + });
547
548 // Because the render expired, React should finish the tree without
549 // consulting `shouldYield` again
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
-203
@@ -270,58 +270,6 @@ describe('ReactSuspense', () => {
270 expect(root).toMatchRenderedOutput('AsyncAfter SuspenseSibling');
271 });
272
273 - it(
274 - 'interrupts current render if something already suspended with a ' +
275 - "delay, and then subsequently there's a lower priority update",
276 - () => {
277 - const root = ReactTestRenderer.create(
278 - <>
279 - <Suspense fallback={<Text text="Loading..." />} />
280 - <Text text="Initial" />
281 - </>,
282 - {
283 - unstable_isConcurrent: true,
284 - },
285 - );
286 - expect(Scheduler).toFlushAndYield(['Initial']);
287 - expect(root).toMatchRenderedOutput('Initial');
288 -
289 - // The update will suspend.
290 - root.update(
291 - <>
292 - <Suspense fallback={<Text text="Loading..." />}>
293 - <AsyncText text="Async" ms={2000} />
294 - </Suspense>
295 - <Text text="After Suspense" />
296 - <Text text="Sibling" />
297 - </>,
298 - );
299 -
300 - // Yield past the Suspense boundary but don't complete the last sibling.
301 - expect(Scheduler).toFlushAndYieldThrough([
302 - 'Suspend! [Async]',
303 - 'Loading...',
304 - 'After Suspense',
305 - ]);
306 -
307 - // Receives a lower priority update before the current render phase
308 - // has completed.
309 - Scheduler.unstable_advanceTime(1000);
310 - root.update(
311 - <>
312 - <Suspense fallback={<Text text="Loading..." />} />
313 - <Text text="Updated" />
314 - </>,
315 - );
316 - expect(Scheduler).toHaveYielded([]);
317 - expect(root).toMatchRenderedOutput('Initial');
318 -
319 - // Render the update, instead of continuing
320 - expect(Scheduler).toFlushAndYield(['Updated']);
321 - expect(root).toMatchRenderedOutput('Updated');
322 - },
323 - );
324 -
273 // @gate experimental
274 it(
275 'interrupts current render when something suspends with a ' +
@@ -392,157 +340,6 @@ describe('ReactSuspense', () => {
340 },
341 );
342
395 - // @gate experimental
396 - it(
397 - 'interrupts current render when something suspends with a ' +
398 - "delay and we've already bailed out lower priority update in " +
399 - 'a parent',
400 - async () => {
401 - // This is similar to the previous test case, except this covers when
402 - // React completely bails out on the parent component, without processing
403 - // the update queue.
404 -
405 - const {useState} = React;
406 -
407 - function interrupt() {
408 - // React has a heuristic to batch all updates that occur within the same
409 - // event. This is a trick to circumvent that heuristic.
410 - ReactTestRenderer.create('whatever');
411 - }
412 -
413 - let setShouldSuspend;
414 - function Async() {
415 - const [shouldSuspend, _setShouldSuspend] = useState(false);
416 - setShouldSuspend = _setShouldSuspend;
417 - return (
418 - <>
419 - <Text text="A" />
420 - <Suspense fallback={<Text text="Loading..." />}>
421 - {shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
422 - </Suspense>
423 - <Text text="B" />
424 - <Text text="C" />
425 - </>
426 - );
427 - }
428 -
429 - let setShouldHideInParent;
430 - function App() {
431 - const [shouldHideInParent, _setShouldHideInParent] = useState(false);
432 - setShouldHideInParent = _setShouldHideInParent;
433 - Scheduler.unstable_yieldValue(
434 - 'shouldHideInParent: ' + shouldHideInParent,
435 - );
436 - return shouldHideInParent ? <Text text="(empty)" /> : <Async />;
437 - }
438 -
439 - const root = ReactTestRenderer.create(null, {
440 - unstable_isConcurrent: true,
441 - });
442 -
443 - await act(async () => {
444 - root.update(<App />);
445 - expect(Scheduler).toFlushAndYield([
446 - 'shouldHideInParent: false',
447 - 'A',
448 - 'B',
449 - 'C',
450 - ]);
451 - expect(root).toMatchRenderedOutput('ABC');
452 -
453 - // This update will suspend.
454 - setShouldSuspend(true);
455 -
456 - // Need to move into the next async bucket.
457 - // Do a bit of work, then interrupt to trigger a restart.
458 - expect(Scheduler).toFlushAndYieldThrough(['A']);
459 - interrupt();
460 - // Should not have committed loading state
461 - expect(root).toMatchRenderedOutput('ABC');
462 -
463 - // Schedule another update. This will have lower priority because it's
464 - // a transition.
465 - React.unstable_startTransition(() => {
466 - setShouldHideInParent(true);
467 - });
468 -
469 - expect(Scheduler).toFlushAndYieldThrough([
470 - // Should have restarted the first update, because of the interruption
471 - 'A',
472 - 'Suspend! [Async]',
473 - 'Loading...',
474 - 'B',
475 - ]);
476 -
477 - // Should not have committed loading state
478 - expect(root).toMatchRenderedOutput('ABC');
479 -
480 - // After suspending, should abort the first update and switch to the
481 - // second update.
482 - expect(Scheduler).toFlushAndYield([
483 - 'shouldHideInParent: true',
484 - '(empty)',
485 - ]);
486 -
487 - expect(root).toMatchRenderedOutput('(empty)');
488 - });
489 - },
490 - );
491 -
492 - it(
493 - 'interrupts current render when something suspends with a ' +
494 - 'delay, and a parent received an update after it completed',
495 - () => {
496 - function App({shouldSuspend, step}) {
497 - return (
498 - <>
499 - <Text text={`A${step}`} />
500 - <Suspense fallback={<Text text="Loading..." />}>
501 - {shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
502 - </Suspense>
503 - <Text text={`B${step}`} />
504 - <Text text={`C${step}`} />
505 - </>
506 - );
507 - }
508 -
509 - const root = ReactTestRenderer.create(null, {
510 - unstable_isConcurrent: true,
511 - });
512 -
513 - root.update(<App shouldSuspend={false} step={0} />);
514 - expect(Scheduler).toFlushAndYield(['A0', 'B0', 'C0']);
515 - expect(root).toMatchRenderedOutput('A0B0C0');
516 -
517 - // This update will suspend.
518 - root.update(<App shouldSuspend={true} step={1} />);
519 - // Flush past the root, but stop before the async component.
520 - expect(Scheduler).toFlushAndYieldThrough(['A1']);
521 -
522 - // Schedule an update on the root, which already completed.
523 - root.update(<App shouldSuspend={false} step={2} />);
524 - // We'll keep working on the existing update.
525 - expect(Scheduler).toFlushAndYieldThrough([
526 - // Now the async component suspends
527 - 'Suspend! [Async]',
528 - 'Loading...',
529 - 'B1',
530 - ]);
531 -
532 - // Should not have committed loading state
533 - expect(root).toMatchRenderedOutput('A0B0C0');
534 -
535 - // After suspending, should abort the first update and switch to the
536 - // second update. So, C1 should not appear in the log.
537 - // TODO: This should work even if React does not yield to the main
538 - // thread. Should use same mechanism as selective hydration to interrupt
539 - // the render before the end of the current slice of work.
540 - expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
541 -
542 - expect(root).toMatchRenderedOutput('A2B2C2');
543 - },
544 - );
545 -
343 it('mounts a lazy class component in non-concurrent mode', async () => {
344 class Class extends React.Component {
345 componentDidMount() {
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+36 -26
@@ -589,7 +589,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
589
590 // Note: This test was written to test a heuristic used in the expiration
591 // times model. Might not make sense in the new model.
592 - // @gate enableCache
592 + // @gate enableCache || enableTransitionEntanglement
593 it('tries each subsequent level after suspending', async () => {
594 const root = ReactNoop.createRoot();
595
@@ -642,23 +642,26 @@ describe('ReactSuspenseWithNoopRenderer', () => {
642 root.render(<App step={4} shouldSuspend={false} />);
643 });
644
645 - expect(Scheduler).toHaveYielded([
646 - // The new reconciler batches everything together, so it finishes without
647 - // suspending again.
648 - 'Sibling',
649 -
650 - // NOTE: The final of the update got pushed into a lower priority range of
651 - // lanes, leading to the extra intermediate render. This is because when
652 - // we schedule the fourth update, we're already in the middle of rendering
653 - // the three others. Since there are only three lanes in the default
654 - // range, the fourth lane is shifted to slightly lower priority. This
655 - // could easily change when we tweak our batching heuristics. Ideally,
656 - // they'd all have default priority and render in a single batch.
657 - 'Suspend! [Step 3]',
658 - 'Sibling',
659 -
660 - 'Step 4',
661 - ]);
645 + if (gate(flags => flags.enableTransitionEntanglement)) {
646 + expect(Scheduler).toHaveYielded(['Sibling', 'Step 4']);
647 + } else {
648 + // Old implementation
649 + expect(Scheduler).toHaveYielded([
650 + 'Sibling',
651 +
652 + // NOTE: The final of the update got pushed into a lower priority range of
653 + // lanes, leading to the extra intermediate render. This is because when
654 + // we schedule the fourth update, we're already in the middle of rendering
655 + // the three others. Since there are only three lanes in the default
656 + // range, the fourth lane is shifted to slightly lower priority. This
657 + // could easily change when we tweak our batching heuristics. Ideally,
658 + // they'd all have default priority and render in a single batch.
659 + 'Suspend! [Step 3]',
660 + 'Sibling',
661 +
662 + 'Step 4',
663 + ]);
664 + }
665 });
666
667 // @gate enableCache
@@ -2797,14 +2800,21 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2800 foo.setState({suspend: false});
2801 });
2802
2800 - expect(Scheduler).toHaveYielded([
2801 - // First setState
2802 - 'Foo',
2803 - // Second setState. This update was scheduled while we were in the
2804 - // middle of rendering the previous update, so it was pushed to a separate
2805 - // batch to avoid invalidating the work-in-progress tree.
2806 - 'Foo',
2807 - ]);
2803 + if (gate(flags => flags.enableTransitionEntanglement)) {
2804 + expect(Scheduler).toHaveYielded([
2805 + // First setState
2806 + 'Foo',
2807 + ]);
2808 + } else {
2809 + expect(Scheduler).toHaveYielded([
2810 + // First setState
2811 + 'Foo',
2812 + // Second setState. This update was scheduled while we were in the
2813 + // middle of rendering the previous update, so it was pushed to a separate
2814 + // batch to avoid invalidating the work-in-progress tree.
2815 + 'Foo',
2816 + ]);
2817 + }
2818 expect(root).toMatchRenderedOutput(<span prop="Foo" />);
2819 });
2820
packages/react-reconciler/src/__tests__/ReactTransition-test.js
+225
@@ -548,4 +548,229 @@ describe('ReactTransition', () => {
548 expect(root).toMatchRenderedOutput('C');
549 },
550 );
551 +
552 + // @gate experimental
553 + // @gate enableCache
554 + test('interrupt a refresh transition if a new transition is scheduled', async () => {
555 + const root = ReactNoop.createRoot();
556 +
557 + await ReactNoop.act(async () => {
558 + root.render(
559 + <>
560 + <Suspense fallback={<Text text="Loading..." />} />
561 + <Text text="Initial" />
562 + </>,
563 + );
564 + });
565 + expect(Scheduler).toHaveYielded(['Initial']);
566 + expect(root).toMatchRenderedOutput('Initial');
567 +
568 + await ReactNoop.act(async () => {
569 + // Start a refresh transition
570 + startTransition(() => {
571 + root.render(
572 + <>
573 + <Suspense fallback={<Text text="Loading..." />}>
574 + <AsyncText text="Async" />
575 + </Suspense>
576 + <Text text="After Suspense" />
577 + <Text text="Sibling" />
578 + </>,
579 + );
580 + });
581 +
582 + // Partially render it.
583 + expect(Scheduler).toFlushAndYieldThrough([
584 + // Once we the update suspends, we know it's a refresh transition,
585 + // because the Suspense boundary has already mounted.
586 + 'Suspend! [Async]',
587 + 'Loading...',
588 + 'After Suspense',
589 + ]);
590 +
591 + // Schedule a new transition
592 + startTransition(async () => {
593 + root.render(
594 + <>
595 + <Suspense fallback={<Text text="Loading..." />} />
596 + <Text text="Updated" />
597 + </>,
598 + );
599 + });
600 + });
601 +
602 + // Because the first one is going to suspend regardless, we should
603 + // immediately switch to rendering the new transition.
604 + expect(Scheduler).toHaveYielded(['Updated']);
605 + expect(root).toMatchRenderedOutput('Updated');
606 + });
607 +
608 + // @gate experimental
609 + // @gate enableCache
610 + test(
611 + "interrupt a refresh transition when something suspends and we've " +
612 + 'already bailed out on another transition in a parent',
613 + async () => {
614 + let setShouldSuspend;
615 +
616 + function Parent({children}) {
617 + const [shouldHideInParent, _setShouldHideInParent] = useState(false);
618 + setShouldHideInParent = _setShouldHideInParent;
619 + Scheduler.unstable_yieldValue(
620 + 'shouldHideInParent: ' + shouldHideInParent,
621 + );
622 + if (shouldHideInParent) {
623 + return <Text text="(empty)" />;
624 + }
625 + return children;
626 + }
627 +
628 + let setShouldHideInParent;
629 + function App() {
630 + const [shouldSuspend, _setShouldSuspend] = useState(false);
631 + setShouldSuspend = _setShouldSuspend;
632 + return (
633 + <>
634 + <Text text="A" />
635 + <Parent>
636 + <Suspense fallback={<Text text="Loading..." />}>
637 + {shouldSuspend ? <AsyncText text="Async" /> : null}
638 + </Suspense>
639 + </Parent>
640 + <Text text="B" />
641 + <Text text="C" />
642 + </>
643 + );
644 + }
645 +
646 + const root = ReactNoop.createRoot();
647 +
648 + await act(async () => {
649 + root.render(<App />);
650 + expect(Scheduler).toFlushAndYield([
651 + 'A',
652 + 'shouldHideInParent: false',
653 + 'B',
654 + 'C',
655 + ]);
656 + expect(root).toMatchRenderedOutput('ABC');
657 +
658 + // Schedule an update
659 + startTransition(() => {
660 + setShouldSuspend(true);
661 + });
662 +
663 + // Now we need to trigger schedule another transition in a different
664 + // lane from the first one. At the time this was written, all transitions are worked on
665 + // simultaneously, unless a transition was already in progress when a
666 + // new one was scheduled. So, partially render the first transition.
667 + expect(Scheduler).toFlushAndYieldThrough(['A']);
668 +
669 + // Now schedule a second transition. We won't interrupt the first one.
670 + React.unstable_startTransition(() => {
671 + setShouldHideInParent(true);
672 + });
673 + // Continue rendering the first transition.
674 + expect(Scheduler).toFlushAndYieldThrough([
675 + 'shouldHideInParent: false',
676 + 'Suspend! [Async]',
677 + 'Loading...',
678 + 'B',
679 + ]);
680 + // Should not have committed loading state
681 + expect(root).toMatchRenderedOutput('ABC');
682 +
683 + // At this point, we've processed the parent update queue, so we know
684 + // that it has a pending update from the second transition, even though
685 + // we skipped it during this render. And we know this is a refresh
686 + // transition, because we had to render a loading state. So the next
687 + // time we re-enter the work loop (we don't interrupt immediately, we
688 + // just wait for the next time slice), we should throw out the
689 + // suspended first transition and try the second one.
690 + expect(Scheduler).toFlushUntilNextPaint([
691 + 'shouldHideInParent: true',
692 + '(empty)',
693 + ]);
694 + expect(root).toMatchRenderedOutput('A(empty)BC');
695 +
696 + // Since the two transitions are not entangled, we then later go back
697 + // and finish retry the first transition. Not really relevant to this
698 + // test but I'll assert the result anyway.
699 + expect(Scheduler).toFlushAndYield([
700 + 'A',
701 + 'shouldHideInParent: true',
702 + '(empty)',
703 + 'B',
704 + 'C',
705 + ]);
706 + expect(root).toMatchRenderedOutput('A(empty)BC');
707 + });
708 + },
709 + );
710 +
711 + // @gate experimental
712 + // @gate enableCache
713 + test(
714 + 'interrupt a refresh transition when something suspends and a parent ' +
715 + 'component received an interleaved update after its queue was processed',
716 + async () => {
717 + // Title is confusing so I'll try to explain further: This is similar to
718 + // the previous test, except instead of skipped over a transition update
719 + // in a parent, the parent receives an interleaved update *after* its
720 + // begin phase has already finished.
721 +
722 + function App({shouldSuspend, step}) {
723 + return (
724 + <>
725 + <Text text={`A${step}`} />
726 + <Suspense fallback={<Text text="Loading..." />}>
727 + {shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
728 + </Suspense>
729 + <Text text={`B${step}`} />
730 + <Text text={`C${step}`} />
731 + </>
732 + );
733 + }
734 +
735 + const root = ReactNoop.createRoot();
736 +
737 + await ReactNoop.act(async () => {
738 + root.render(<App shouldSuspend={false} step={0} />);
739 + });
740 + expect(Scheduler).toHaveYielded(['A0', 'B0', 'C0']);
741 + expect(root).toMatchRenderedOutput('A0B0C0');
742 +
743 + await ReactNoop.act(async () => {
744 + // This update will suspend.
745 + startTransition(() => {
746 + root.render(<App shouldSuspend={true} step={1} />);
747 + });
748 + // Flush past the root, but stop before the async component.
749 + expect(Scheduler).toFlushAndYieldThrough(['A1']);
750 +
751 + // Schedule another transition on the root, which already completed.
752 + startTransition(() => {
753 + root.render(<App shouldSuspend={false} step={2} />);
754 + });
755 + // We'll keep working on the first update.
756 + expect(Scheduler).toFlushAndYieldThrough([
757 + // Now the async component suspends
758 + 'Suspend! [Async]',
759 + 'Loading...',
760 + 'B1',
761 + ]);
762 + // Should not have committed loading state
763 + expect(root).toMatchRenderedOutput('A0B0C0');
764 +
765 + // After suspending, should abort the first update and switch to the
766 + // second update. So, C1 should not appear in the log.
767 + // TODO: This should work even if React does not yield to the main
768 + // thread. Should use same mechanism as selective hydration to interrupt
769 + // the render before the end of the current slice of work.
770 + expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
771 +
772 + expect(root).toMatchRenderedOutput('A2B2C2');
773 + });
774 + },
775 + );
776 });
packages/react-reconciler/src/__tests__/SchedulingProfiler-test.internal.js
+3
@@ -474,6 +474,9 @@ describe('SchedulingProfiler', () => {
474 ]);
475 });
476
477 + // This test is coupled to lane implementation details, so I'm disabling it in
478 + // the new fork until it stabilizes so we don't have to repeatedly update it.
479 + // @gate !enableTransitionEntanglement
480 // @gate enableSchedulingProfiler
481 it('should mark cascading passive updates', () => {
482 function Example() {