@samitouri / QOS-React / commits / 89847bf6e6

Continuous updates should interrupt transitions (#21323)

Even when updates are sync by default. Discovered this quirk while working on #21322. Previously, when sync default updates are enabled, continuous updates are treated like default updates. We implemented this by assigning DefaultLane to continous updates. However, an unintended consequence of that approach is that continuous updates would no longer interrupt transitions, because default updates are not supposed to interrupt transitions. To fix this, I changed the implementation to always assign separate lanes for default and continuous updates. Then I entangle the lanes together.

Andrew Clark committed Apr 21, 2021 at 10:51 UTC 89847bf6e6c0b77ced5dfc7b794c730bb3deac36
7 files changed +121 -83
packages/react-reconciler/src/ReactFiberLane.new.js
+26 -3
@@ -39,6 +39,7 @@ import {
39 enableCache,
40 enableSchedulingProfiler,
41 enableUpdaterTracking,
42 + enableSyncDefaultUpdates,
43 } from 'shared/ReactFeatureFlags';
44 import {isDevToolsPresent} from './ReactFiberDevToolsHook.new';
45
@@ -263,9 +264,6 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
264 // Default priority updates should not interrupt transition updates. The
265 // only difference between default updates and transition updates is that
266 // default updates do not support refresh transitions.
266 - // TODO: This applies to sync default updates, too. Which is probably what
267 - // we want for default priority events, but not for continuous priority
268 - // events like hover.
267 (nextLane === DefaultLane && (wipLane & TransitionLanes) !== NoLanes)
268 ) {
269 // Keep working on the existing in-progress tree. Do not interrupt.
@@ -273,6 +271,18 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
271 }
272 }
273
274 + if (
275 + // TODO: Check for root override, once that lands
276 + enableSyncDefaultUpdates &&
277 + (nextLanes & InputContinuousLane) !== NoLanes
278 + ) {
279 + // When updates are sync by default, we entangle continous priority updates
280 + // and default updates, so they render in the same batch. The only reason
281 + // they use separate lanes is because continuous updates should interrupt
282 + // transitions, but default updates should not.
283 + nextLanes |= pendingLanes & DefaultLane;
284 + }
285 +
286 // Check for entangled lanes and add them to the batch.
287 //
288 // A lane is said to be entangled with another when it's not allowed to render
@@ -467,6 +477,19 @@ export function includesOnlyTransitions(lanes: Lanes) {
477 return (lanes & TransitionLanes) === lanes;
478 }
479
480 +export function shouldTimeSlice(root: FiberRoot, lanes: Lanes) {
481 + if (!enableSyncDefaultUpdates) {
482 + return true;
483 + }
484 + const SyncDefaultLanes =
485 + InputContinuousHydrationLane |
486 + InputContinuousLane |
487 + DefaultHydrationLane |
488 + DefaultLane;
489 + // TODO: Check for root override, once that lands
490 + return (lanes & SyncDefaultLanes) === NoLanes;
491 +}
492 +
493 export function isTransitionLane(lane: Lane) {
494 return (lane & TransitionLanes) !== 0;
495 }
packages/react-reconciler/src/ReactFiberLane.old.js
+26 -3
@@ -39,6 +39,7 @@ import {
39 enableCache,
40 enableSchedulingProfiler,
41 enableUpdaterTracking,
42 + enableSyncDefaultUpdates,
43 } from 'shared/ReactFeatureFlags';
44 import {isDevToolsPresent} from './ReactFiberDevToolsHook.old';
45
@@ -263,9 +264,6 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
264 // Default priority updates should not interrupt transition updates. The
265 // only difference between default updates and transition updates is that
266 // default updates do not support refresh transitions.
266 - // TODO: This applies to sync default updates, too. Which is probably what
267 - // we want for default priority events, but not for continuous priority
268 - // events like hover.
267 (nextLane === DefaultLane && (wipLane & TransitionLanes) !== NoLanes)
268 ) {
269 // Keep working on the existing in-progress tree. Do not interrupt.
@@ -273,6 +271,18 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
271 }
272 }
273
274 + if (
275 + // TODO: Check for root override, once that lands
276 + enableSyncDefaultUpdates &&
277 + (nextLanes & InputContinuousLane) !== NoLanes
278 + ) {
279 + // When updates are sync by default, we entangle continous priority updates
280 + // and default updates, so they render in the same batch. The only reason
281 + // they use separate lanes is because continuous updates should interrupt
282 + // transitions, but default updates should not.
283 + nextLanes |= pendingLanes & DefaultLane;
284 + }
285 +
286 // Check for entangled lanes and add them to the batch.
287 //
288 // A lane is said to be entangled with another when it's not allowed to render
@@ -467,6 +477,19 @@ export function includesOnlyTransitions(lanes: Lanes) {
477 return (lanes & TransitionLanes) === lanes;
478 }
479
480 +export function shouldTimeSlice(root: FiberRoot, lanes: Lanes) {
481 + if (!enableSyncDefaultUpdates) {
482 + return true;
483 + }
484 + const SyncDefaultLanes =
485 + InputContinuousHydrationLane |
486 + InputContinuousLane |
487 + DefaultHydrationLane |
488 + DefaultLane;
489 + // TODO: Check for root override, once that lands
490 + return (lanes & SyncDefaultLanes) === NoLanes;
491 +}
492 +
493 export function isTransitionLane(lane: Lane) {
494 return (lane & TransitionLanes) !== 0;
495 }
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+5 -26
@@ -32,7 +32,6 @@ import {
32 disableSchedulerTimeoutInWorkLoop,
33 enableStrictEffects,
34 skipUnmountedBoundaries,
35 - enableSyncDefaultUpdates,
35 enableUpdaterTracking,
36 } from 'shared/ReactFeatureFlags';
37 import ReactSharedInternals from 'shared/ReactSharedInternals';
@@ -139,10 +138,6 @@ import {
138 NoLanes,
139 NoLane,
140 SyncLane,
142 - DefaultLane,
143 - DefaultHydrationLane,
144 - InputContinuousLane,
145 - InputContinuousHydrationLane,
141 NoTimestamp,
142 claimNextTransitionLane,
143 claimNextRetryLane,
@@ -154,6 +149,7 @@ import {
149 includesNonIdleWork,
150 includesOnlyRetries,
151 includesOnlyTransitions,
152 + shouldTimeSlice,
153 getNextLanes,
154 markStarvedLanesAsExpired,
155 getLanesToRetrySynchronouslyOnError,
@@ -437,13 +433,6 @@ export function requestUpdateLane(fiber: Fiber): Lane {
433 // TODO: Move this type conversion to the event priority module.
434 const updateLane: Lane = (getCurrentUpdatePriority(): any);
435 if (updateLane !== NoLane) {
440 - if (
441 - enableSyncDefaultUpdates &&
442 - (updateLane === InputContinuousLane ||
443 - updateLane === InputContinuousHydrationLane)
444 - ) {
445 - return DefaultLane;
446 - }
436 return updateLane;
437 }
438
@@ -454,13 +443,6 @@ export function requestUpdateLane(fiber: Fiber): Lane {
443 // use that directly.
444 // TODO: Move this type conversion to the event priority module.
445 const eventLane: Lane = (getCurrentEventPriority(): any);
457 - if (
458 - enableSyncDefaultUpdates &&
459 - (eventLane === InputContinuousLane ||
460 - eventLane === InputContinuousHydrationLane)
461 - ) {
462 - return DefaultLane;
463 - }
446 return eventLane;
447 }
448
@@ -811,13 +793,10 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
793 return null;
794 }
795
814 - let exitStatus =
815 - enableSyncDefaultUpdates &&
816 - (includesSomeLane(lanes, DefaultLane) ||
817 - includesSomeLane(lanes, DefaultHydrationLane))
818 - ? // Time slicing is disabled for default updates in this root.
819 - renderRootSync(root, lanes)
820 - : renderRootConcurrent(root, lanes);
796 + let exitStatus = shouldTimeSlice(root, lanes)
797 + ? renderRootConcurrent(root, lanes)
798 + : // Time slicing is disabled for default updates in this root.
799 + renderRootSync(root, lanes);
800 if (exitStatus !== RootIncomplete) {
801 if (exitStatus === RootErrored) {
802 executionContext |= RetryAfterError;
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+5 -26
@@ -32,7 +32,6 @@ import {
32 disableSchedulerTimeoutInWorkLoop,
33 enableStrictEffects,
34 skipUnmountedBoundaries,
35 - enableSyncDefaultUpdates,
35 enableUpdaterTracking,
36 } from 'shared/ReactFeatureFlags';
37 import ReactSharedInternals from 'shared/ReactSharedInternals';
@@ -139,10 +138,6 @@ import {
138 NoLanes,
139 NoLane,
140 SyncLane,
142 - DefaultLane,
143 - DefaultHydrationLane,
144 - InputContinuousLane,
145 - InputContinuousHydrationLane,
141 NoTimestamp,
142 claimNextTransitionLane,
143 claimNextRetryLane,
@@ -154,6 +149,7 @@ import {
149 includesNonIdleWork,
150 includesOnlyRetries,
151 includesOnlyTransitions,
152 + shouldTimeSlice,
153 getNextLanes,
154 markStarvedLanesAsExpired,
155 getLanesToRetrySynchronouslyOnError,
@@ -437,13 +433,6 @@ export function requestUpdateLane(fiber: Fiber): Lane {
433 // TODO: Move this type conversion to the event priority module.
434 const updateLane: Lane = (getCurrentUpdatePriority(): any);
435 if (updateLane !== NoLane) {
440 - if (
441 - enableSyncDefaultUpdates &&
442 - (updateLane === InputContinuousLane ||
443 - updateLane === InputContinuousHydrationLane)
444 - ) {
445 - return DefaultLane;
446 - }
436 return updateLane;
437 }
438
@@ -454,13 +443,6 @@ export function requestUpdateLane(fiber: Fiber): Lane {
443 // use that directly.
444 // TODO: Move this type conversion to the event priority module.
445 const eventLane: Lane = (getCurrentEventPriority(): any);
457 - if (
458 - enableSyncDefaultUpdates &&
459 - (eventLane === InputContinuousLane ||
460 - eventLane === InputContinuousHydrationLane)
461 - ) {
462 - return DefaultLane;
463 - }
446 return eventLane;
447 }
448
@@ -811,13 +793,10 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
793 return null;
794 }
795
814 - let exitStatus =
815 - enableSyncDefaultUpdates &&
816 - (includesSomeLane(lanes, DefaultLane) ||
817 - includesSomeLane(lanes, DefaultHydrationLane))
818 - ? // Time slicing is disabled for default updates in this root.
819 - renderRootSync(root, lanes)
820 - : renderRootConcurrent(root, lanes);
796 + let exitStatus = shouldTimeSlice(root, lanes)
797 + ? renderRootConcurrent(root, lanes)
798 + : // Time slicing is disabled for default updates in this root.
799 + renderRootSync(root, lanes);
800 if (exitStatus !== RootIncomplete) {
801 if (exitStatus === RootErrored) {
802 executionContext |= RetryAfterError;
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
-12
@@ -1405,18 +1405,6 @@ describe('ReactHooksWithNoopRenderer', () => {
1405 ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () => {
1406 setParentState(false);
1407 });
1408 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
1409 - // TODO: Default updates do not interrupt transition updates, to
1410 - // prevent starvation. However, when sync default updates are enabled,
1411 - // continuous updates are treated like default updates. In this case,
1412 - // we probably don't want this behavior; continuous should be allowed
1413 - // to interrupt.
1414 - expect(Scheduler).toFlushUntilNextPaint([
1415 - 'Child two render',
1416 - 'Child one commit',
1417 - 'Child two commit',
1418 - ]);
1419 - }
1408 expect(Scheduler).toFlushUntilNextPaint([
1409 'Parent false render',
1410 'Parent false commit',
packages/react-reconciler/src/__tests__/ReactUpdatePriority-test.js
+54
@@ -1,6 +1,8 @@
1 let React;
2 let ReactNoop;
3 let Scheduler;
4 +let ContinuousEventPriority;
5 +let startTransition;
6 let useState;
7 let useEffect;
8
@@ -11,6 +13,9 @@ describe('ReactUpdatePriority', () => {
13 React = require('react');
14 ReactNoop = require('react-noop-renderer');
15 Scheduler = require('scheduler');
16 + ContinuousEventPriority = require('react-reconciler/constants')
17 + .ContinuousEventPriority;
18 + startTransition = React.unstable_startTransition;
19 useState = React.useState;
20 useEffect = React.useEffect;
21 });
@@ -78,4 +83,53 @@ describe('ReactUpdatePriority', () => {
83 // Now the idle update has flushed
84 expect(Scheduler).toHaveYielded(['Idle: 2, Default: 2']);
85 });
86 +
87 + // @gate experimental
88 + test('continuous updates should interrupt transisions', async () => {
89 + const root = ReactNoop.createRoot();
90 +
91 + let setCounter;
92 + let setIsHidden;
93 + function App() {
94 + const [counter, _setCounter] = useState(1);
95 + const [isHidden, _setIsHidden] = useState(false);
96 + setCounter = _setCounter;
97 + setIsHidden = _setIsHidden;
98 + if (isHidden) {
99 + return <Text text={'(hidden)'} />;
100 + }
101 + return (
102 + <>
103 + <Text text={'A' + counter} />
104 + <Text text={'B' + counter} />
105 + <Text text={'C' + counter} />
106 + </>
107 + );
108 + }
109 +
110 + await ReactNoop.act(async () => {
111 + root.render(<App />);
112 + });
113 + expect(Scheduler).toHaveYielded(['A1', 'B1', 'C1']);
114 + expect(root).toMatchRenderedOutput('A1B1C1');
115 +
116 + await ReactNoop.act(async () => {
117 + startTransition(() => {
118 + setCounter(2);
119 + });
120 + expect(Scheduler).toFlushAndYieldThrough(['A2']);
121 + ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () => {
122 + setIsHidden(true);
123 + });
124 + });
125 + expect(Scheduler).toHaveYielded([
126 + // Because the hide update has continous priority, it should interrupt the
127 + // in-progress transition
128 + '(hidden)',
129 + // When the transition resumes, it's a no-op because the children are
130 + // now hidden.
131 + '(hidden)',
132 + ]);
133 + expect(root).toMatchRenderedOutput('(hidden)');
134 + });
135 });
packages/react-reconciler/src/__tests__/SchedulingProfilerLabels-test.internal.js
+5 -13
@@ -168,18 +168,10 @@ describe('SchedulingProfiler labels', () => {
168 event.initEvent('mouseover', true, true);
169 dispatchAndSetCurrentEvent(targetRef.current, event);
170 });
171 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
172 - expect(clearedMarks).toContain(
173 - `--schedule-state-update-${formatLanes(
174 - ReactFiberLane.DefaultLane,
175 - )}-App`,
176 - );
177 - } else {
178 - expect(clearedMarks).toContain(
179 - `--schedule-state-update-${formatLanes(
180 - ReactFiberLane.InputContinuousLane,
181 - )}-App`,
182 - );
183 - }
171 + expect(clearedMarks).toContain(
172 + `--schedule-state-update-${formatLanes(
173 + ReactFiberLane.InputContinuousLane,
174 + )}-App`,
175 + );
176 });
177 });