@samitouri / QOS-React-2 / commits / 8b580a89d6

Idle updates should not be blocked by hidden work (#16871)

* Idle updates should not be blocked by hidden work Use the special `Idle` expiration time for updates that are triggered at Scheduler's `IdlePriority`, instead of `Never`. The key difference between Idle and Never¹ is that Never work can be committed in an inconsistent state without tearing the UI. The main example is offscreen content, like a hidden subtree. ¹ "Never" isn't the best name. I originally called it that because it "never" expires, but neither does Idle. Since it's mostly used for offscreen subtrees, we could call it "Offscreen." However, it's also used for dehydrated Suspense boundaries, which are inconsistent in the sense that they haven't finished yet, but aren't visibly inconsistent because the server rendered HTML matches what the hydrated tree would look like. * Reset as early as possible using local variable * Updates in a hidden effect should be Idle I had made them Never to avoid an extra render when a hidden effect updates the hidden component -- if they are Idle, we have to render once at Idle, which bails out on the hidden subtree, then again at Never to actually process the update -- but the problem of needing an extra render pass to bail out hidden updates already exists and we should fix that properly instead of adding yet another special case.

Andrew Clark committed Sep 23, 2019 at 20:52 UTC 8b580a89d6dbbde8a3ed69475899addef1751116
5 files changed +104 -18
packages/react-reconciler/src/ReactFiberExpirationTime.js
+10 -3
@@ -21,9 +21,16 @@ import {
21 export type ExpirationTime = number;
22
23 export const NoWork = 0;
24 -// TODO: Think of a better name for Never.
24 +// TODO: Think of a better name for Never. The key difference with Idle is that
25 +// Never work can be committed in an inconsistent state without tearing the UI.
26 +// The main example is offscreen content, like a hidden subtree. So one possible
27 +// name is Offscreen. However, it also includes dehydrated Suspense boundaries,
28 +// which are inconsistent in the sense that they haven't finished yet, but
29 +// aren't visibly inconsistent because the server rendered HTML matches what the
30 +// hydrated tree would look like.
31 export const Never = 1;
26 -// TODO: Use the Idle expiration time for idle state updates
32 +// Idle is slightly higher priority than Never. It must completely finish in
33 +// order to be consistent.
34 export const Idle = 2;
35 export const Sync = MAX_SIGNED_31_BIT_INT;
36 export const Batched = Sync - 1;
@@ -115,7 +122,7 @@ export function inferPriorityFromExpirationTime(
122 if (expirationTime === Sync) {
123 return ImmediatePriority;
124 }
118 - if (expirationTime === Never) {
125 + if (expirationTime === Never || expirationTime === Idle) {
126 return IdlePriority;
127 }
128 const msUntil =
packages/react-reconciler/src/ReactFiberWorkLoop.js
+16 -13
@@ -321,6 +321,7 @@ export function computeExpirationForFiber(
321
322 if ((executionContext & RenderContext) !== NoContext) {
323 // Use whatever time we're already rendering
324 + // TODO: Should there be a way to opt out, like with `runWithPriority`?
325 return renderExpirationTime;
326 }
327
@@ -347,7 +348,7 @@ export function computeExpirationForFiber(
348 expirationTime = computeAsyncExpiration(currentTime);
349 break;
350 case IdlePriority:
350 - expirationTime = Never;
351 + expirationTime = Idle;
352 break;
353 default:
354 invariant(false, 'Expected a valid priority level');
@@ -1406,14 +1407,14 @@ export function markRenderEventTimeAndConfig(
1407 ): void {
1408 if (
1409 expirationTime < workInProgressRootLatestProcessedExpirationTime &&
1409 - expirationTime > Never
1410 + expirationTime > Idle
1411 ) {
1412 workInProgressRootLatestProcessedExpirationTime = expirationTime;
1413 }
1414 if (suspenseConfig !== null) {
1415 if (
1416 expirationTime < workInProgressRootLatestSuspenseTimeout &&
1416 - expirationTime > Never
1417 + expirationTime > Idle
1418 ) {
1419 workInProgressRootLatestSuspenseTimeout = expirationTime;
1420 // Most of the time we only have one config and getting wrong is not bad.
@@ -2203,24 +2204,25 @@ function commitLayoutEffects(
2204 }
2205
2206 export function flushPassiveEffects() {
2207 + if (pendingPassiveEffectsRenderPriority !== NoPriority) {
2208 + const priorityLevel =
2209 + pendingPassiveEffectsRenderPriority > NormalPriority
2210 + ? NormalPriority
2211 + : pendingPassiveEffectsRenderPriority;
2212 + pendingPassiveEffectsRenderPriority = NoPriority;
2213 + return runWithPriority(priorityLevel, flushPassiveEffectsImpl);
2214 + }
2215 +}
2216 +
2217 +function flushPassiveEffectsImpl() {
2218 if (rootWithPendingPassiveEffects === null) {
2219 return false;
2220 }
2221 const root = rootWithPendingPassiveEffects;
2222 const expirationTime = pendingPassiveEffectsExpirationTime;
2211 - const renderPriorityLevel = pendingPassiveEffectsRenderPriority;
2223 rootWithPendingPassiveEffects = null;
2224 pendingPassiveEffectsExpirationTime = NoWork;
2214 - pendingPassiveEffectsRenderPriority = NoPriority;
2215 - const priorityLevel =
2216 - renderPriorityLevel > NormalPriority ? NormalPriority : renderPriorityLevel;
2217 - return runWithPriority(
2218 - priorityLevel,
2219 - flushPassiveEffectsImpl.bind(null, root, expirationTime),
2220 - );
2221 -}
2225
2223 -function flushPassiveEffectsImpl(root, expirationTime) {
2226 invariant(
2227 (executionContext & (RenderContext | CommitContext)) === NoContext,
2228 'Cannot flush passive effects while already rendering.',
@@ -2263,6 +2265,7 @@ function flushPassiveEffectsImpl(root, expirationTime) {
2265 }
2266
2267 executionContext = prevExecutionContext;
2268 +
2269 flushSyncCallbackQueue();
2270
2271 // If additional passive effects were scheduled, increment a counter. If this
packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js
+56
@@ -352,4 +352,60 @@ describe('ReactSchedulerIntegration', () => {
352 Scheduler.unstable_flushUntilNextPaint();
353 expect(Scheduler).toHaveYielded(['A', 'B', 'C']);
354 });
355 +
356 + it('idle updates are not blocked by offscreen work', async () => {
357 + function Text({text}) {
358 + Scheduler.unstable_yieldValue(text);
359 + return text;
360 + }
361 +
362 + function App({label}) {
363 + return (
364 + <>
365 + <Text text={`Visible: ` + label} />
366 + <div hidden={true}>
367 + <Text text={`Hidden: ` + label} />
368 + </div>
369 + </>
370 + );
371 + }
372 +
373 + const root = ReactNoop.createRoot();
374 + await ReactNoop.act(async () => {
375 + root.render(<App label="A" />);
376 +
377 + // Commit the visible content
378 + expect(Scheduler).toFlushUntilNextPaint(['Visible: A']);
379 + expect(root).toMatchRenderedOutput(
380 + <>
381 + Visible: A
382 + <div hidden={true} />
383 + </>,
384 + );
385 +
386 + // Before the hidden content has a chance to render, schedule an
387 + // idle update
388 + runWithPriority(IdlePriority, () => {
389 + root.render(<App label="B" />);
390 + });
391 +
392 + // The next commit should only include the visible content
393 + expect(Scheduler).toFlushUntilNextPaint(['Visible: B']);
394 + expect(root).toMatchRenderedOutput(
395 + <>
396 + Visible: B
397 + <div hidden={true} />
398 + </>,
399 + );
400 + });
401 +
402 + // The hidden content commits later
403 + expect(Scheduler).toHaveYielded(['Hidden: B']);
404 + expect(root).toMatchRenderedOutput(
405 + <>
406 + Visible: B
407 + <div hidden={true}>Hidden: B</div>
408 + </>,
409 + );
410 + });
411 });
packages/react/src/__tests__/ReactDOMTracing-test.internal.js
+12 -2
@@ -134,7 +134,12 @@ describe('ReactDOMTracing', () => {
134 expect(
135 onInteractionScheduledWorkCompleted,
136 ).toHaveBeenLastNotifiedOfInteraction(interaction);
137 - expect(onRender).toHaveBeenCalledTimes(3);
137 + // TODO: This is 4 instead of 3 because this update was scheduled at
138 + // idle priority, and idle updates are slightly higher priority than
139 + // offscreen work. So it takes two render passes to finish it. Profiler
140 + // calls `onRender` for the first render even though everything
141 + // bails out.
142 + expect(onRender).toHaveBeenCalledTimes(4);
143 expect(onRender).toHaveLastRenderedWithInteractions(
144 new Set([interaction]),
145 );
@@ -281,7 +286,12 @@ describe('ReactDOMTracing', () => {
286 expect(
287 onInteractionScheduledWorkCompleted,
288 ).toHaveBeenLastNotifiedOfInteraction(interaction);
284 - expect(onRender).toHaveBeenCalledTimes(3);
289 + // TODO: This is 4 instead of 3 because this update was scheduled at
290 + // idle priority, and idle updates are slightly higher priority than
291 + // offscreen work. So it takes two render passes to finish it. Profiler
292 + // calls `onRender` for the first render even though everything
293 + // bails out.
294 + expect(onRender).toHaveBeenCalledTimes(4);
295 expect(onRender).toHaveLastRenderedWithInteractions(
296 new Set([interaction]),
297 );
scripts/jest/matchers/schedulerTestMatchers.js
+10
@@ -44,6 +44,15 @@ function toFlushAndYieldThrough(Scheduler, expectedYields) {
44 });
45 }
46
47 +function toFlushUntilNextPaint(Scheduler, expectedYields) {
48 + assertYieldsWereCleared(Scheduler);
49 + Scheduler.unstable_flushUntilNextPaint();
50 + const actualYields = Scheduler.unstable_clearYields();
51 + return captureAssertion(() => {
52 + expect(actualYields).toEqual(expectedYields);
53 + });
54 +}
55 +
56 function toFlushWithoutYielding(Scheduler) {
57 return toFlushAndYield(Scheduler, []);
58 }
@@ -76,6 +85,7 @@ function toFlushAndThrow(Scheduler, ...rest) {
85 module.exports = {
86 toFlushAndYield,
87 toFlushAndYieldThrough,
88 + toFlushUntilNextPaint,
89 toFlushWithoutYielding,
90 toFlushExpired,
91 toHaveYielded,