diff --git a/packages/react-reconciler/src/ReactFiberExpirationTime.js b/packages/react-reconciler/src/ReactFiberExpirationTime.js index e61ed56079e17..a5a9fd12f903f 100644 --- a/packages/react-reconciler/src/ReactFiberExpirationTime.js +++ b/packages/react-reconciler/src/ReactFiberExpirationTime.js @@ -21,9 +21,16 @@ import { export type ExpirationTime = number; export const NoWork = 0; -// TODO: Think of a better name for Never. +// TODO: Think of a better name for Never. The key difference with Idle 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. So one possible +// name is Offscreen. However, it also includes 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. export const Never = 1; -// TODO: Use the Idle expiration time for idle state updates +// Idle is slightly higher priority than Never. It must completely finish in +// order to be consistent. export const Idle = 2; export const Sync = MAX_SIGNED_31_BIT_INT; export const Batched = Sync - 1; @@ -115,7 +122,7 @@ export function inferPriorityFromExpirationTime( if (expirationTime === Sync) { return ImmediatePriority; } - if (expirationTime === Never) { + if (expirationTime === Never || expirationTime === Idle) { return IdlePriority; } const msUntil = diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 50b3d2aaefac5..389ff45ff3232 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -321,6 +321,7 @@ export function computeExpirationForFiber( if ((executionContext & RenderContext) !== NoContext) { // Use whatever time we're already rendering + // TODO: Should there be a way to opt out, like with `runWithPriority`? return renderExpirationTime; } @@ -347,7 +348,7 @@ export function computeExpirationForFiber( expirationTime = computeAsyncExpiration(currentTime); break; case IdlePriority: - expirationTime = Never; + expirationTime = Idle; break; default: invariant(false, 'Expected a valid priority level'); @@ -1406,14 +1407,14 @@ export function markRenderEventTimeAndConfig( ): void { if ( expirationTime < workInProgressRootLatestProcessedExpirationTime && - expirationTime > Never + expirationTime > Idle ) { workInProgressRootLatestProcessedExpirationTime = expirationTime; } if (suspenseConfig !== null) { if ( expirationTime < workInProgressRootLatestSuspenseTimeout && - expirationTime > Never + expirationTime > Idle ) { workInProgressRootLatestSuspenseTimeout = expirationTime; // Most of the time we only have one config and getting wrong is not bad. @@ -2203,24 +2204,25 @@ function commitLayoutEffects( } export function flushPassiveEffects() { + if (pendingPassiveEffectsRenderPriority !== NoPriority) { + const priorityLevel = + pendingPassiveEffectsRenderPriority > NormalPriority + ? NormalPriority + : pendingPassiveEffectsRenderPriority; + pendingPassiveEffectsRenderPriority = NoPriority; + return runWithPriority(priorityLevel, flushPassiveEffectsImpl); + } +} + +function flushPassiveEffectsImpl() { if (rootWithPendingPassiveEffects === null) { return false; } const root = rootWithPendingPassiveEffects; const expirationTime = pendingPassiveEffectsExpirationTime; - const renderPriorityLevel = pendingPassiveEffectsRenderPriority; rootWithPendingPassiveEffects = null; pendingPassiveEffectsExpirationTime = NoWork; - pendingPassiveEffectsRenderPriority = NoPriority; - const priorityLevel = - renderPriorityLevel > NormalPriority ? NormalPriority : renderPriorityLevel; - return runWithPriority( - priorityLevel, - flushPassiveEffectsImpl.bind(null, root, expirationTime), - ); -} -function flushPassiveEffectsImpl(root, expirationTime) { invariant( (executionContext & (RenderContext | CommitContext)) === NoContext, 'Cannot flush passive effects while already rendering.', @@ -2263,6 +2265,7 @@ function flushPassiveEffectsImpl(root, expirationTime) { } executionContext = prevExecutionContext; + flushSyncCallbackQueue(); // If additional passive effects were scheduled, increment a counter. If this diff --git a/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js index 6f91e8323b2f9..8197dbc0fa2b0 100644 --- a/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js @@ -352,4 +352,60 @@ describe('ReactSchedulerIntegration', () => { Scheduler.unstable_flushUntilNextPaint(); expect(Scheduler).toHaveYielded(['A', 'B', 'C']); }); + + it('idle updates are not blocked by offscreen work', async () => { + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function App({label}) { + return ( + <> + + + + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + + // Commit the visible content + expect(Scheduler).toFlushUntilNextPaint(['Visible: A']); + expect(root).toMatchRenderedOutput( + <> + Visible: A +