diff --git a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js index d8d0690aa9c2a..a4f8613e61546 100644 --- a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js @@ -963,7 +963,7 @@ describe('ProfilingCache', () => { 2 => 0, }, "passiveEffectDuration": null, - "priorityLevel": "Immediate", + "priorityLevel": "Normal", "timestamp": 0, "updaters": [ { diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index c8df0a9e14673..2733d0ccf3515 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -63,6 +63,7 @@ function FiberRootNode( this.cancelPendingCommit = null; this.context = null; this.pendingContext = null; + this.next = null; this.callbackNode = null; this.callbackPriority = NoLane; this.eventTimes = createLaneMap(NoLanes); diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js new file mode 100644 index 0000000000000..5068194aa24cc --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -0,0 +1,464 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {FiberRoot} from './ReactInternalTypes'; +import type {Lane} from './ReactFiberLane'; +import type {PriorityLevel} from 'scheduler/src/SchedulerPriorities'; + +import {enableDeferRootSchedulingToMicrotask} from 'shared/ReactFeatureFlags'; +import { + NoLane, + NoLanes, + SyncLane, + getHighestPriorityLane, + getNextLanes, + includesOnlyNonUrgentLanes, + includesSyncLane, + markStarvedLanesAsExpired, +} from './ReactFiberLane'; +import { + CommitContext, + NoContext, + RenderContext, + getExecutionContext, + getWorkInProgressRoot, + getWorkInProgressRootRenderLanes, + isWorkLoopSuspendedOnData, + performConcurrentWorkOnRoot, + performSyncWorkOnRoot, +} from './ReactFiberWorkLoop'; +import {LegacyRoot} from './ReactRootTags'; +import { + ImmediatePriority as ImmediateSchedulerPriority, + UserBlockingPriority as UserBlockingSchedulerPriority, + NormalPriority as NormalSchedulerPriority, + IdlePriority as IdleSchedulerPriority, + cancelCallback as Scheduler_cancelCallback, + scheduleCallback as Scheduler_scheduleCallback, + now, +} from './Scheduler'; +import { + DiscreteEventPriority, + ContinuousEventPriority, + DefaultEventPriority, + IdleEventPriority, + lanesToEventPriority, +} from './ReactEventPriorities'; +import {supportsMicrotasks, scheduleMicrotask} from './ReactFiberHostConfig'; + +import ReactSharedInternals from 'shared/ReactSharedInternals'; +const {ReactCurrentActQueue} = ReactSharedInternals; + +// A linked list of all the roots with pending work. In an idiomatic app, +// there's only a single root, but we do support multi root apps, hence this +// extra complexity. But this module is optimized for the single root case. +let firstScheduledRoot: FiberRoot | null = null; +let lastScheduledRoot: FiberRoot | null = null; + +// Used to prevent redundant mircotasks from being scheduled. +let didScheduleMicrotask: boolean = false; +// `act` "microtasks" are scheduled on the `act` queue instead of an actual +// microtask, so we have to dedupe those separately. This wouldn't be an issue +// if we required all `act` calls to be awaited, which we might in the future. +let didScheduleMicrotask_act: boolean = false; + +// Used to quickly bail out of flushSync if there's no sync work to do. +let mightHavePendingSyncWork: boolean = false; + +let isFlushingWork: boolean = false; + +export function ensureRootIsScheduled(root: FiberRoot): void { + // This function is called whenever a root receives an update. It does two + // things 1) it ensures the root is in the root schedule, and 2) it ensures + // there's a pending microtask to process the root schedule. + // + // Most of the actual scheduling logic does not happen until + // `scheduleTaskForRootDuringMicrotask` runs. + + // Add the root to the schedule + if (root === lastScheduledRoot || root.next !== null) { + // Fast path. This root is already scheduled. + } else { + if (lastScheduledRoot === null) { + firstScheduledRoot = lastScheduledRoot = root; + } else { + lastScheduledRoot.next = root; + lastScheduledRoot = root; + } + } + + // Any time a root received an update, we set this to true until the next time + // we process the schedule. If it's false, then we can quickly exit flushSync + // without consulting the schedule. + mightHavePendingSyncWork = true; + + // At the end of the current event, go through each of the roots and ensure + // there's a task scheduled for each one at the correct priority. + if (__DEV__ && ReactCurrentActQueue.current !== null) { + // We're inside an `act` scope. + if (!didScheduleMicrotask_act) { + didScheduleMicrotask_act = true; + scheduleImmediateTask(processRootScheduleInMicrotask); + } + } else { + if (!didScheduleMicrotask) { + didScheduleMicrotask = true; + scheduleImmediateTask(processRootScheduleInMicrotask); + } + } + + if (!enableDeferRootSchedulingToMicrotask) { + // While this flag is disabled, we schedule the render task immediately + // instead of waiting a microtask. + // TODO: We need to land enableDeferRootSchedulingToMicrotask ASAP to + // unblock additional features we have planned. + scheduleTaskForRootDuringMicrotask(root, now()); + } +} + +export function flushSyncWorkOnAllRoots() { + // This is allowed to be called synchronously, but the caller should check + // the execution context first. + flushSyncWorkAcrossRoots_impl(false); +} + +export function flushSyncWorkOnLegacyRootsOnly() { + // This is allowed to be called synchronously, but the caller should check + // the execution context first. + flushSyncWorkAcrossRoots_impl(true); +} + +function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) { + if (isFlushingWork) { + // Prevent reentrancy. + // TODO: Is this overly defensive? The callers must check the execution + // context first regardless. + return; + } + + if (!mightHavePendingSyncWork) { + // Fast path. There's no sync work to do. + return; + } + + const workInProgressRoot = getWorkInProgressRoot(); + const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes(); + + // There may or may not be synchronous work scheduled. Let's check. + let didPerformSomeWork; + let errors: Array | null = null; + isFlushingWork = true; + do { + didPerformSomeWork = false; + let root = firstScheduledRoot; + while (root !== null) { + if (onlyLegacy && root.tag !== LegacyRoot) { + // Skip non-legacy roots. + } else { + const nextLanes = getNextLanes( + root, + root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, + ); + if (includesSyncLane(nextLanes)) { + // This root has pending sync work. Flush it now. + try { + // TODO: Pass nextLanes as an argument instead of computing it again + // inside performSyncWorkOnRoot. + didPerformSomeWork = true; + performSyncWorkOnRoot(root); + } catch (error) { + // Collect errors so we can rethrow them at the end + if (errors === null) { + errors = [error]; + } else { + errors.push(error); + } + } + } + } + root = root.next; + } + } while (didPerformSomeWork); + isFlushingWork = false; + + // If any errors were thrown, rethrow them right before exiting. + // TODO: Consider returning these to the caller, to allow them to decide + // how/when to rethrow. + if (errors !== null) { + if (errors.length > 1) { + if (typeof AggregateError === 'function') { + // eslint-disable-next-line no-undef + throw new AggregateError(errors); + } else { + for (let i = 1; i < errors.length; i++) { + scheduleImmediateTask(throwError.bind(null, errors[i])); + } + const firstError = errors[0]; + throw firstError; + } + } else { + const error = errors[0]; + throw error; + } + } +} + +function throwError(error: mixed) { + throw error; +} + +function processRootScheduleInMicrotask() { + // This function is always called inside a microtask. It should never be + // called synchronously. + didScheduleMicrotask = false; + if (__DEV__) { + didScheduleMicrotask_act = false; + } + + // We'll recompute this as we iterate through all the roots and schedule them. + mightHavePendingSyncWork = false; + + const currentTime = now(); + + let prev = null; + let root = firstScheduledRoot; + while (root !== null) { + const next = root.next; + const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime); + if (nextLanes === NoLane) { + // This root has no more pending work. Remove it from the schedule. To + // guard against subtle reentrancy bugs, this microtask is the only place + // we do this — you can add roots to the schedule whenever, but you can + // only remove them here. + + // Null this out so we know it's been removed from the schedule. + root.next = null; + if (prev === null) { + // This is the new head of the list + firstScheduledRoot = next; + } else { + prev.next = next; + } + if (next === null) { + // This is the new tail of the list + lastScheduledRoot = prev; + } + } else { + // This root still has work. Keep it in the list. + prev = root; + if (includesSyncLane(nextLanes)) { + mightHavePendingSyncWork = true; + } + } + root = next; + } + + // At the end of the microtask, flush any pending synchronous work. This has + // to come at the end, because it does actual rendering work that might throw. + flushSyncWorkOnAllRoots(); +} + +function scheduleTaskForRootDuringMicrotask( + root: FiberRoot, + currentTime: number, +): Lane { + // This function is always called inside a microtask, or at the very end of a + // rendering task right before we yield to the main thread. It should never be + // called synchronously. + // + // TODO: Unless enableDeferRootSchedulingToMicrotask is off. We need to land + // that ASAP to unblock additional features we have planned. + // + // This function also never performs React work synchronously; it should + // only schedule work to be performed later, in a separate task or microtask. + + // Check if any lanes are being starved by other work. If so, mark them as + // expired so we know to work on those next. + markStarvedLanesAsExpired(root, currentTime); + + // Determine the next lanes to work on, and their priority. + const workInProgressRoot = getWorkInProgressRoot(); + const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes(); + const nextLanes = getNextLanes( + root, + root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, + ); + + const existingCallbackNode = root.callbackNode; + if ( + nextLanes === NoLanes || + // If this root is currently suspended and waiting for data to resolve, don't + // schedule a task to render it. We'll either wait for a ping, or wait to + // receive an update. + (isWorkLoopSuspendedOnData() && root === workInProgressRoot) || + // We should only interrupt a pending commit if the new update + // is urgent. + (root.cancelPendingCommit !== null && includesOnlyNonUrgentLanes(nextLanes)) + ) { + // Fast path: There's nothing to work on. + if (existingCallbackNode !== null) { + cancelCallback(existingCallbackNode); + } + root.callbackNode = null; + root.callbackPriority = NoLane; + return NoLane; + } + + // Schedule a new callback in the host environment. + if (includesSyncLane(nextLanes)) { + // Synchronous work is always flushed at the end of the microtask, so we + // don't need to schedule an additional task. + if (existingCallbackNode !== null) { + cancelCallback(existingCallbackNode); + } + root.callbackPriority = SyncLane; + root.callbackNode = null; + return SyncLane; + } else { + // We use the highest priority lane to represent the priority of the callback. + const existingCallbackPriority = root.callbackPriority; + const newCallbackPriority = getHighestPriorityLane(nextLanes); + + if ( + newCallbackPriority === existingCallbackPriority && + // Special case related to `act`. If the currently scheduled task is a + // Scheduler task, rather than an `act` task, cancel it and re-schedule + // on the `act` queue. + !( + __DEV__ && + ReactCurrentActQueue.current !== null && + existingCallbackNode !== fakeActCallbackNode + ) + ) { + // The priority hasn't changed. We can reuse the existing task. + return newCallbackPriority; + } else { + // Cancel the existing callback. We'll schedule a new one below. + cancelCallback(existingCallbackNode); + } + + let schedulerPriorityLevel; + switch (lanesToEventPriority(nextLanes)) { + case DiscreteEventPriority: + schedulerPriorityLevel = ImmediateSchedulerPriority; + break; + case ContinuousEventPriority: + schedulerPriorityLevel = UserBlockingSchedulerPriority; + break; + case DefaultEventPriority: + schedulerPriorityLevel = NormalSchedulerPriority; + break; + case IdleEventPriority: + schedulerPriorityLevel = IdleSchedulerPriority; + break; + default: + schedulerPriorityLevel = NormalSchedulerPriority; + break; + } + + const newCallbackNode = scheduleCallback( + schedulerPriorityLevel, + performConcurrentWorkOnRoot.bind(null, root), + ); + + root.callbackPriority = newCallbackPriority; + root.callbackNode = newCallbackNode; + return newCallbackPriority; + } +} + +export type RenderTaskFn = (didTimeout: boolean) => RenderTaskFn | null; + +export function getContinuationForRoot( + root: FiberRoot, + originalCallbackNode: mixed, +): RenderTaskFn | null { + // This is called at the end of `performConcurrentWorkOnRoot` to determine + // if we need to schedule a continuation task. + // + // Usually `scheduleTaskForRootDuringMicrotask` only runs inside a microtask; + // however, since most of the logic for determining if we need a continuation + // versus a new task is the same, we cheat a bit and call it here. This is + // only safe to do because we know we're at the end of the browser task. + // So although it's not an actual microtask, it might as well be. + scheduleTaskForRootDuringMicrotask(root, now()); + if (root.callbackNode === originalCallbackNode) { + // The task node scheduled for this root is the same one that's + // currently executed. Need to return a continuation. + return performConcurrentWorkOnRoot.bind(null, root); + } + return null; +} + +const fakeActCallbackNode = {}; + +function scheduleCallback( + priorityLevel: PriorityLevel, + callback: RenderTaskFn, +) { + if (__DEV__ && ReactCurrentActQueue.current !== null) { + // Special case: We're inside an `act` scope (a testing utility). + // Instead of scheduling work in the host environment, add it to a + // fake internal queue that's managed by the `act` implementation. + ReactCurrentActQueue.current.push(callback); + return fakeActCallbackNode; + } else { + return Scheduler_scheduleCallback(priorityLevel, callback); + } +} + +function cancelCallback(callbackNode: mixed) { + if (__DEV__ && callbackNode === fakeActCallbackNode) { + // Special `act` case: check if this is the fake callback node used by + // the `act` implementation. + } else if (callbackNode !== null) { + Scheduler_cancelCallback(callbackNode); + } +} + +function scheduleImmediateTask(cb: () => mixed) { + if (__DEV__ && ReactCurrentActQueue.current !== null) { + // Special case: Inside an `act` scope, we push microtasks to the fake `act` + // callback queue. This is because we currently support calling `act` + // without awaiting the result. The plan is to deprecate that, and require + // that you always await the result so that the microtasks have a chance to + // run. But it hasn't happened yet. + ReactCurrentActQueue.current.push(() => { + cb(); + return null; + }); + } + + // TODO: Can we land supportsMicrotasks? Which environments don't support it? + // Alternatively, can we move this check to the host config? + if (supportsMicrotasks) { + scheduleMicrotask(() => { + // In Safari, appending an iframe forces microtasks to run. + // https://github.com/facebook/react/issues/22459 + // We don't support running callbacks in the middle of render + // or commit so we need to check against that. + const executionContext = getExecutionContext(); + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + // Note that this would still prematurely flush the callbacks + // if this happens outside render or commit phase (e.g. in an event). + + // Intentionally using a macrotask instead of a microtask here. This is + // wrong semantically but it prevents an infinite loop. The bug is + // Safari's, not ours, so we just do our best to not crash even though + // the behavior isn't completely correct. + Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb); + return; + } + cb(); + }); + } else { + // If microtasks are not supported, use Scheduler. + Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb); + } +} diff --git a/packages/react-reconciler/src/ReactFiberSyncTaskQueue.js b/packages/react-reconciler/src/ReactFiberSyncTaskQueue.js deleted file mode 100644 index 0b11be38a1dac..0000000000000 --- a/packages/react-reconciler/src/ReactFiberSyncTaskQueue.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import type {SchedulerCallback} from './Scheduler'; - -import { - DiscreteEventPriority, - getCurrentUpdatePriority, - setCurrentUpdatePriority, -} from './ReactEventPriorities'; -import {ImmediatePriority, scheduleCallback} from './Scheduler'; - -let syncQueue: Array | null = null; -let includesLegacySyncCallbacks: boolean = false; -let isFlushingSyncQueue: boolean = false; - -export function scheduleSyncCallback(callback: SchedulerCallback) { - // Push this callback into an internal queue. We'll flush these either in - // the next tick, or earlier if something calls `flushSyncCallbackQueue`. - if (syncQueue === null) { - syncQueue = [callback]; - } else { - // Push onto existing queue. Don't need to schedule a callback because - // we already scheduled one when we created the queue. - syncQueue.push(callback); - } -} - -export function scheduleLegacySyncCallback(callback: SchedulerCallback) { - includesLegacySyncCallbacks = true; - scheduleSyncCallback(callback); -} - -export function flushSyncCallbacksOnlyInLegacyMode() { - // Only flushes the queue if there's a legacy sync callback scheduled. - // TODO: There's only a single type of callback: performSyncOnWorkOnRoot. So - // it might make more sense for the queue to be a list of roots instead of a - // list of generic callbacks. Then we can have two: one for legacy roots, one - // for concurrent roots. And this method would only flush the legacy ones. - if (includesLegacySyncCallbacks) { - flushSyncCallbacks(); - } -} - -export function flushSyncCallbacks(): null { - if (!isFlushingSyncQueue && syncQueue !== null) { - // Prevent re-entrance. - isFlushingSyncQueue = true; - - // Set the event priority to discrete - // TODO: Is this necessary anymore? The only user code that runs in this - // queue is in the render or commit phases, which already set the - // event priority. Should be able to remove. - const previousUpdatePriority = getCurrentUpdatePriority(); - setCurrentUpdatePriority(DiscreteEventPriority); - - let errors: Array | null = null; - - const queue = syncQueue; - // $FlowFixMe[incompatible-use] found when upgrading Flow - for (let i = 0; i < queue.length; i++) { - // $FlowFixMe[incompatible-use] found when upgrading Flow - let callback: SchedulerCallback = queue[i]; - try { - do { - const isSync = true; - // $FlowFixMe[incompatible-type] we bail out when we get a null - callback = callback(isSync); - } while (callback !== null); - } catch (error) { - // Collect errors so we can rethrow them at the end - if (errors === null) { - errors = [error]; - } else { - errors.push(error); - } - } - } - - syncQueue = null; - includesLegacySyncCallbacks = false; - setCurrentUpdatePriority(previousUpdatePriority); - isFlushingSyncQueue = false; - - if (errors !== null) { - if (errors.length > 1) { - if (typeof AggregateError === 'function') { - // eslint-disable-next-line no-undef - throw new AggregateError(errors); - } else { - for (let i = 1; i < errors.length; i++) { - scheduleCallback( - ImmediatePriority, - throwError.bind(null, errors[i]), - ); - } - const firstError = errors[0]; - throw firstError; - } - } else { - const error = errors[0]; - throw error; - } - } - } - - return null; -} - -function throwError(error: mixed) { - throw error; -} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f1810f8a785c1..55c30ae81a896 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -22,6 +22,7 @@ import type { TransitionAbort, } from './ReactFiberTracingMarkerComponent'; import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; +import type {RenderTaskFn} from './ReactFiberRootScheduler'; import { replayFailedUnitOfWorkWithInvokeGuardedCallback, @@ -45,21 +46,12 @@ import is from 'shared/objectIs'; import { // Aliased because `act` will override and push to an internal queue scheduleCallback as Scheduler_scheduleCallback, - cancelCallback as Scheduler_cancelCallback, shouldYield, requestPaint, now, - ImmediatePriority as ImmediateSchedulerPriority, - UserBlockingPriority as UserBlockingSchedulerPriority, NormalPriority as NormalSchedulerPriority, IdlePriority as IdleSchedulerPriority, } from './Scheduler'; -import { - flushSyncCallbacks, - flushSyncCallbacksOnlyInLegacyMode, - scheduleSyncCallback, - scheduleLegacySyncCallback, -} from './ReactFiberSyncTaskQueue'; import { logCommitStarted, logCommitStopped, @@ -78,9 +70,7 @@ import { noTimeout, afterActiveInstanceBlur, getCurrentEventPriority, - supportsMicrotasks, errorHydratingContainer, - scheduleMicrotask, prepareRendererToRender, resetRendererAfterRender, startSuspendingCommit, @@ -153,7 +143,6 @@ import { includesBlockingLane, includesExpiredLane, getNextLanes, - markStarvedLanesAsExpired, getLanesToRetrySynchronouslyOnError, getMostRecentEventTime, markRootUpdated, @@ -161,7 +150,6 @@ import { markRootPinged, markRootEntangled, markRootFinished, - getHighestPriorityLane, addFiberToLanesMap, movePendingFibersToMemoized, addTransitionToLanesMap, @@ -172,9 +160,7 @@ import { } from './ReactFiberLane'; import { DiscreteEventPriority, - ContinuousEventPriority, DefaultEventPriority, - IdleEventPriority, getCurrentUpdatePriority, setCurrentUpdatePriority, lowerEventPriority, @@ -289,6 +275,12 @@ import { } from './ReactFiberSuspenseContext'; import {resolveDefaultProps} from './ReactFiberLazyComponent'; import {resetChildReconcilerOnUnwind} from './ReactChildFiber'; +import { + ensureRootIsScheduled, + flushSyncWorkOnAllRoots, + flushSyncWorkOnLegacyRootsOnly, + getContinuationForRoot, +} from './ReactFiberRootScheduler'; const ceil = Math.ceil; @@ -609,6 +601,10 @@ export function getWorkInProgressRootRenderLanes(): Lanes { return workInProgressRootRenderLanes; } +export function isWorkLoopSuspendedOnData(): boolean { + return workInProgressSuspendedReason === SuspendedOnData; +} + export function requestEventTime(): number { if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { // We're inside React, so it's fine to read the actual time. @@ -819,21 +815,24 @@ export function scheduleUpdateOnFiber( } } - ensureRootIsScheduled(root, eventTime); + ensureRootIsScheduled(root); if ( lane === SyncLane && executionContext === NoContext && - (fiber.mode & ConcurrentMode) === NoMode && - // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode. - !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy) + (fiber.mode & ConcurrentMode) === NoMode ) { - // Flush the synchronous work now, unless we're already working or inside - // a batch. This is intentionally inside scheduleUpdateOnFiber instead of - // scheduleCallbackForFiber to preserve the ability to schedule a callback - // without immediately flushing it. We only do this for user-initiated - // updates, to preserve historical behavior of legacy mode. - resetRenderTimer(); - flushSyncCallbacksOnlyInLegacyMode(); + if (__DEV__ && ReactCurrentActQueue.isBatchingLegacy) { + // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode. + ReactCurrentActQueue.didScheduleLegacyUpdate = true; + } else { + // Flush the synchronous work now, unless we're already working or inside + // a batch. This is intentionally inside scheduleUpdateOnFiber instead of + // scheduleCallbackForFiber to preserve the ability to schedule a callback + // without immediately flushing it. We only do this for user-initiated + // updates, to preserve historical behavior of legacy mode. + resetRenderTimer(); + flushSyncWorkOnLegacyRootsOnly(); + } } } } @@ -855,7 +854,7 @@ export function scheduleInitialHydrationOnRoot( const current = root.current; current.lanes = lane; markRootUpdated(root, lane, eventTime); - ensureRootIsScheduled(root, eventTime); + ensureRootIsScheduled(root); } export function isUnsafeClassRenderPhaseUpdate(fiber: Fiber): boolean { @@ -864,172 +863,12 @@ export function isUnsafeClassRenderPhaseUpdate(fiber: Fiber): boolean { return (executionContext & RenderContext) !== NoContext; } -// Use this function to schedule a task for a root. There's only one task per -// root; if a task was already scheduled, we'll check to make sure the priority -// of the existing task is the same as the priority of the next level that the -// root has work on. This function is called on every update, and right before -// exiting a task. -function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { - const existingCallbackNode = root.callbackNode; - - // Check if any lanes are being starved by other work. If so, mark them as - // expired so we know to work on those next. - markStarvedLanesAsExpired(root, currentTime); - - // Determine the next lanes to work on, and their priority. - const nextLanes = getNextLanes( - root, - root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, - ); - - if (nextLanes === NoLanes) { - // Special case: There's nothing to work on. - if (existingCallbackNode !== null) { - cancelCallback(existingCallbackNode); - } - root.callbackNode = null; - root.callbackPriority = NoLane; - return; - } - - // If this root is currently suspended and waiting for data to resolve, don't - // schedule a task to render it. We'll either wait for a ping, or wait to - // receive an update. - if ( - workInProgressSuspendedReason === SuspendedOnData && - workInProgressRoot === root - ) { - root.callbackPriority = NoLane; - root.callbackNode = null; - return; - } - - const cancelPendingCommit = root.cancelPendingCommit; - if (cancelPendingCommit !== null) { - // We should only interrupt a pending commit if the new update - // is urgent. - if (includesOnlyNonUrgentLanes(nextLanes)) { - // The new update is not urgent. Don't interrupt the pending commit. - root.callbackPriority = NoLane; - root.callbackNode = null; - return; - } - } - - // We use the highest priority lane to represent the priority of the callback. - const newCallbackPriority = getHighestPriorityLane(nextLanes); - - // Check if there's an existing task. We may be able to reuse it. - const existingCallbackPriority = root.callbackPriority; - if ( - existingCallbackPriority === newCallbackPriority && - // Special case related to `act`. If the currently scheduled task is a - // Scheduler task, rather than an `act` task, cancel it and re-scheduled - // on the `act` queue. - !( - __DEV__ && - ReactCurrentActQueue.current !== null && - existingCallbackNode !== fakeActCallbackNode - ) - ) { - if (__DEV__) { - // If we're going to re-use an existing task, it needs to exist. - // Assume that discrete update microtasks are non-cancellable and null. - // TODO: Temporary until we confirm this warning is not fired. - if ( - existingCallbackNode == null && - !includesSyncLane(existingCallbackPriority) - ) { - console.error( - 'Expected scheduled callback to exist. This error is likely caused by a bug in React. Please file an issue.', - ); - } - } - // The priority hasn't changed. We can reuse the existing task. Exit. - return; - } - - if (existingCallbackNode != null) { - // Cancel the existing callback. We'll schedule a new one below. - cancelCallback(existingCallbackNode); - } - - // Schedule a new callback. - let newCallbackNode; - if (includesSyncLane(newCallbackPriority)) { - // Special case: Sync React callbacks are scheduled on a special - // internal queue - if (root.tag === LegacyRoot) { - if (__DEV__ && ReactCurrentActQueue.isBatchingLegacy !== null) { - ReactCurrentActQueue.didScheduleLegacyUpdate = true; - } - scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null, root)); - } else { - scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root)); - } - if (supportsMicrotasks) { - // Flush the queue in a microtask. - if (__DEV__ && ReactCurrentActQueue.current !== null) { - // Inside `act`, use our internal `act` queue so that these get flushed - // at the end of the current scope even when using the sync version - // of `act`. - ReactCurrentActQueue.current.push(flushSyncCallbacks); - } else { - scheduleMicrotask(() => { - // In Safari, appending an iframe forces microtasks to run. - // https://github.com/facebook/react/issues/22459 - // We don't support running callbacks in the middle of render - // or commit so we need to check against that. - if ( - (executionContext & (RenderContext | CommitContext)) === - NoContext - ) { - // Note that this would still prematurely flush the callbacks - // if this happens outside render or commit phase (e.g. in an event). - flushSyncCallbacks(); - } - }); - } - } else { - // Flush the queue in an Immediate task. - scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks); - } - newCallbackNode = null; - } else { - let schedulerPriorityLevel; - switch (lanesToEventPriority(nextLanes)) { - case DiscreteEventPriority: - schedulerPriorityLevel = ImmediateSchedulerPriority; - break; - case ContinuousEventPriority: - schedulerPriorityLevel = UserBlockingSchedulerPriority; - break; - case DefaultEventPriority: - schedulerPriorityLevel = NormalSchedulerPriority; - break; - case IdleEventPriority: - schedulerPriorityLevel = IdleSchedulerPriority; - break; - default: - schedulerPriorityLevel = NormalSchedulerPriority; - break; - } - newCallbackNode = scheduleCallback( - schedulerPriorityLevel, - performConcurrentWorkOnRoot.bind(null, root), - ); - } - - root.callbackPriority = newCallbackPriority; - root.callbackNode = newCallbackNode; -} - // This is the entry point for every concurrent task, i.e. anything that // goes through Scheduler. -function performConcurrentWorkOnRoot( +export function performConcurrentWorkOnRoot( root: FiberRoot, didTimeout: boolean, -): $FlowFixMe { +): RenderTaskFn | null { if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { resetNestedUpdateFlag(); } @@ -1062,6 +901,7 @@ function performConcurrentWorkOnRoot( // Determine the next lanes to work on, using the fields stored // on the root. + // TODO: This was already computed in the caller. Pass it as an argument. let lanes = getNextLanes( root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, @@ -1108,7 +948,7 @@ function performConcurrentWorkOnRoot( const fatalError = workInProgressRootFatalError; prepareFreshStack(root, NoLanes); markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); throw fatalError; } @@ -1157,7 +997,7 @@ function performConcurrentWorkOnRoot( const fatalError = workInProgressRootFatalError; prepareFreshStack(root, NoLanes); markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); throw fatalError; } @@ -1173,13 +1013,8 @@ function performConcurrentWorkOnRoot( } } - ensureRootIsScheduled(root, now()); - if (root.callbackNode === originalCallbackNode) { - // The task node scheduled for this root is the same one that's - // currently executed. Need to return a continuation. - return performConcurrentWorkOnRoot.bind(null, root); - } - return null; + ensureRootIsScheduled(root); + return getContinuationForRoot(root, originalCallbackNode); } function recoverFromConcurrentError( @@ -1531,7 +1366,7 @@ function markRootSuspended(root: FiberRoot, suspendedLanes: Lanes) { // This is the entry point for synchronous tasks that don't go // through Scheduler -function performSyncWorkOnRoot(root: FiberRoot) { +export function performSyncWorkOnRoot(root: FiberRoot): null { if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { syncNestedUpdateFlag(); } @@ -1542,10 +1377,11 @@ function performSyncWorkOnRoot(root: FiberRoot) { flushPassiveEffects(); + // TODO: This was already computed in the caller. Pass it as an argument. let lanes = getNextLanes(root, NoLanes); if (!includesSyncLane(lanes)) { // There's no remaining sync work left. - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); return null; } @@ -1574,7 +1410,7 @@ function performSyncWorkOnRoot(root: FiberRoot) { const fatalError = workInProgressRootFatalError; prepareFreshStack(root, NoLanes); markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); throw fatalError; } @@ -1583,7 +1419,7 @@ function performSyncWorkOnRoot(root: FiberRoot) { // cases where need to exit the current render without producing a // consistent tree or committing. markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); return null; } @@ -1600,7 +1436,7 @@ function performSyncWorkOnRoot(root: FiberRoot) { // Before exiting, make sure there's a callback scheduled for the next // pending level. - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); return null; } @@ -1608,10 +1444,13 @@ function performSyncWorkOnRoot(root: FiberRoot) { export function flushRoot(root: FiberRoot, lanes: Lanes) { if (lanes !== NoLanes) { markRootEntangled(root, mergeLanes(lanes, SyncLane)); - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); if ((executionContext & (RenderContext | CommitContext)) === NoContext) { resetRenderTimer(); - flushSyncCallbacks(); + // TODO: For historical reasons this flushes all sync work across all + // roots. It shouldn't really matter either way, but we could change this + // to only flush the given root. + flushSyncWorkOnAllRoots(); } } } @@ -1649,7 +1488,7 @@ export function batchedUpdates(fn: A => R, a: A): R { !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy) ) { resetRenderTimer(); - flushSyncCallbacksOnlyInLegacyMode(); + flushSyncWorkOnLegacyRootsOnly(); } } } @@ -1717,7 +1556,7 @@ export function flushSync(fn: (() => R) | void): R | void { // Note that this will happen even if batchedUpdates is higher up // the stack. if ((executionContext & (RenderContext | CommitContext)) === NoContext) { - flushSyncCallbacks(); + flushSyncWorkOnAllRoots(); } } } @@ -2313,7 +2152,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { // Ensure the root is scheduled. We should do this even if we're // currently working on a different root, so that we resume // rendering later. - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); }; thenable.then(onResolution, onResolution); break outer; @@ -3150,7 +2989,7 @@ function commitRootImpl( // Always call this before exiting `commitRoot`, to ensure that any // additional work on this root is scheduled. - ensureRootIsScheduled(root, now()); + ensureRootIsScheduled(root); if (recoverableErrors !== null) { // There were errors during this render, but recovered from them without @@ -3205,7 +3044,7 @@ function commitRootImpl( } // If layout work was scheduled, flush it now. - flushSyncCallbacks(); + flushSyncWorkOnAllRoots(); if (__DEV__) { if (enableDebugTracing) { @@ -3411,7 +3250,7 @@ function flushPassiveEffectsImpl() { executionContext = prevExecutionContext; - flushSyncCallbacks(); + flushSyncWorkOnAllRoots(); if (enableTransitionTracing) { const prevPendingTransitionCallbacks = currentPendingTransitionCallbacks; @@ -3496,7 +3335,7 @@ function captureCommitPhaseErrorOnRoot( const eventTime = requestEventTime(); if (root !== null) { markRootUpdated(root, SyncLane, eventTime); - ensureRootIsScheduled(root, eventTime); + ensureRootIsScheduled(root); } } @@ -3539,7 +3378,7 @@ export function captureCommitPhaseError( const eventTime = requestEventTime(); if (root !== null) { markRootUpdated(root, SyncLane, eventTime); - ensureRootIsScheduled(root, eventTime); + ensureRootIsScheduled(root); } return; } @@ -3617,7 +3456,6 @@ function pingSuspendedRoot( pingCache.delete(wakeable); } - const eventTime = requestEventTime(); markRootPinged(root, pingedLanes); warnIfSuspenseResolutionNotWrappedWithActDEV(root); @@ -3660,7 +3498,7 @@ function pingSuspendedRoot( } } - ensureRootIsScheduled(root, eventTime); + ensureRootIsScheduled(root); } function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) { @@ -3678,7 +3516,7 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) { const root = enqueueConcurrentRenderForLane(boundaryFiber, retryLane); if (root !== null) { markRootUpdated(root, retryLane, eventTime); - ensureRootIsScheduled(root, eventTime); + ensureRootIsScheduled(root); } } @@ -4160,14 +3998,6 @@ function scheduleCallback(priorityLevel: any, callback) { } } -function cancelCallback(callbackNode: any) { - if (__DEV__ && callbackNode === fakeActCallbackNode) { - return; - } - // In production, always call Scheduler. This function will be stripped out. - return Scheduler_cancelCallback(callbackNode); -} - function shouldForceFlushFallbacksInDEV() { // Never force flush in production. This function should get stripped out. return __DEV__ && ReactCurrentActQueue.current !== null; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index a672b9669abcf..ae1e75bfe9467 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -240,6 +240,10 @@ type BaseFiberRootProperties = { MutableSource | MutableSourceVersion, > | null, + // Used to create a linked list that represent all the roots that have + // pending work scheduled on them. + next: FiberRoot | null, + // Node returned by Scheduler.scheduleCallback. Represents the next rendering // task that the root will work on. callbackNode: any, diff --git a/packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js b/packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js index 129b79f743144..8c1bc7475be36 100644 --- a/packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js +++ b/packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js @@ -3,7 +3,6 @@ let ReactNoop; let Scheduler; let act; let assertLog; -let waitForThrow; let overrideQueueMicrotask; let flushFakeMicrotasks; @@ -43,7 +42,6 @@ describe('ReactFlushSync (AggregateError not available)', () => { const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; - waitForThrow = InternalTestUtils.waitForThrow; }); function Text({text}) { @@ -95,15 +93,6 @@ describe('ReactFlushSync (AggregateError not available)', () => { // AggregateError is not available, React throws the first error, then // throws the remaining errors in separate tasks. expect(error).toBe(aahh); - - // TODO: Currently the remaining error is rethrown in an Immediate Scheduler - // task, but this may change to a timer or microtask in the future. The - // exact mechanism is an implementation detail; they just need to be logged - // in the order the occurred. - - // This will start throwing if we change it to rethrow in a microtask. - flushFakeMicrotasks(); - - await waitForThrow(nooo); + expect(flushFakeMicrotasks).toThrow(nooo); }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js b/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js index 7413e51a3ab14..b224e4d776482 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js @@ -552,19 +552,8 @@ describe('ReactIncrementalUpdates', () => { // The transition should not have expired, so we should be able to // partially render it. await waitFor(['A']); - - // FIXME: We should be able to partially render B, too, but currently it - // expires. This is an existing bug that I discovered, which will be fixed - // in a PR that I'm currently working on. - // - // Correct behavior: - // await waitFor(['B']); - // await waitForAll(['C', 'D']); - // - // Current behavior: - await waitFor(['B'], { - additionalLogsAfterAttemptingToYield: ['C', 'D'], - }); + await waitFor(['B']); + await waitForAll(['C', 'D']); }); it('regression: does not expire soon due to previous expired work', async () => { diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index 3602a0190be65..de6b7f973e66b 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -258,13 +258,22 @@ describe(`onRender`, () => { // TODO: unstable_now is called by more places than just the profiler. // Rewrite this test so it's less fragile. - assertLog([ - 'read current time', - 'read current time', - 'read current time', - 'read current time', - 'read current time', - ]); + if (gate(flags => flags.enableDeferRootSchedulingToMicrotask)) { + assertLog([ + 'read current time', + 'read current time', + 'read current time', + ]); + } else { + assertLog([ + 'read current time', + 'read current time', + 'read current time', + 'read current time', + 'read current time', + 'read current time', + ]); + } // Restore original mock jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock')); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 6d20fe0f9a9a2..619b4bf152a9b 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -48,6 +48,10 @@ export const enableSchedulerDebugging = false; // Need to remove didTimeout argument from Scheduler before landing export const disableSchedulerTimeoutInWorkLoop = false; +// This will break some internal tests at Meta so we need to gate this until +// those can be fixed. +export const enableDeferRootSchedulingToMicrotask = true; + // ----------------------------------------------------------------------------- // Slated for removal in the future (significant effort) // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index b7bfa74d1590f..2270b18ae3340 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -21,6 +21,7 @@ import typeof * as DynamicFlagsType from 'ReactNativeInternalFeatureFlags'; // update the test configuration. export const enableUseRefAccessWarning = __VARIANT__; +export const enableDeferRootSchedulingToMicrotask = __VARIANT__; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): DynamicFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 726a8aedb7916..3b54b4d9d60d1 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -17,7 +17,8 @@ import * as dynamicFlags from 'ReactNativeInternalFeatureFlags'; // We destructure each value before re-exporting to avoid a dynamic look-up on // the exports object every time a flag is read. -export const {enableUseRefAccessWarning} = dynamicFlags; +export const {enableUseRefAccessWarning, enableDeferRootSchedulingToMicrotask} = + dynamicFlags; // The rest of the flags are static for better dead code elimination. export const enableDebugTracing = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index b069cb3bf7fb8..0efdd6e8b18c1 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -69,6 +69,7 @@ export const enableHostSingletons = true; export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; +export const enableDeferRootSchedulingToMicrotask = true; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index d71e48ee9282d..f4d7962376d46 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -69,6 +69,7 @@ export const enableHostSingletons = true; export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; +export const enableDeferRootSchedulingToMicrotask = true; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 392a73ecd7b40..4dbf373f8c182 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -66,6 +66,7 @@ export const enableFloat = true; export const enableHostSingletons = true; export const useModernStrictMode = false; +export const enableDeferRootSchedulingToMicrotask = true; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 54eb8bd7bc45c..a193740fb0ab7 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -71,6 +71,7 @@ export const enableHostSingletons = true; export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; +export const enableDeferRootSchedulingToMicrotask = true; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 64670368b8401..69c5fad8938a6 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -23,6 +23,7 @@ export const enableLazyContextPropagation = __VARIANT__; export const enableUnifiedSyncLane = __VARIANT__; export const enableTransitionTracing = __VARIANT__; export const enableCustomElementPropertySupport = __VARIANT__; +export const enableDeferRootSchedulingToMicrotask = __VARIANT__; // Enable this flag to help with concurrent mode debugging. // It logs information to the console about React scheduling, rendering, and commit phases. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 00f9ad5d00bc0..229b60c37f155 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -27,6 +27,7 @@ export const { enableUnifiedSyncLane, enableTransitionTracing, enableCustomElementPropertySupport, + enableDeferRootSchedulingToMicrotask, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build. diff --git a/scripts/flow/xplat.js b/scripts/flow/xplat.js index 5a0d26b66f667..8e14cf5a04185 100644 --- a/scripts/flow/xplat.js +++ b/scripts/flow/xplat.js @@ -9,4 +9,5 @@ declare module 'ReactNativeInternalFeatureFlags' { declare export var enableUseRefAccessWarning: boolean; + declare export var enableDeferRootSchedulingToMicrotask: boolean; }