From 54e88ed12c931fa717738849ba5656cc1cf3527a Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 10 Jul 2021 14:15:11 -0400 Subject: [PATCH] Bugfix: Flush legacy sync passive effects at beginning of event (#21846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re-land recent flushSync changes Adds back #21776 and #21775, which were removed due to an internal e2e test failure. Will attempt to fix in subsequent commits. * Failing test: Legacy mode sync passive effects In concurrent roots, if a render is synchronous, we flush its passive effects synchronously. In legacy roots, we don't do this because all updates are synchronous — so we need to flush at the beginning of the next event. This is how `discreteUpdates` worked. * Flush legacy passive effects at beginning of event Fixes test added in previous commit. --- .../__snapshots__/profilingCache-test.js.snap | 28 ++-- .../src/__tests__/ReactDOMFiber-test.js | 8 +- .../src/__tests__/ReactMount-test.js | 12 +- packages/react-dom/src/client/ReactDOM.js | 4 +- .../react-dom/src/client/ReactDOMLegacy.js | 6 +- .../src/events/ReactDOMUpdateBatching.js | 8 +- packages/react-noop-renderer/src/ReactNoop.js | 2 - .../src/ReactNoopPersistent.js | 1 - .../src/createReactNoop.js | 4 - .../src/ReactFiberReconciler.js | 15 +- .../src/ReactFiberReconciler.new.js | 6 +- .../src/ReactFiberReconciler.old.js | 6 +- .../src/ReactFiberWorkLoop.new.js | 149 +++++------------- .../src/ReactFiberWorkLoop.old.js | 149 +++++------------- .../src/__tests__/ReactFlushSync-test.js | 59 ++++++- .../ReactHooksWithNoopRenderer-test.js | 5 +- .../ReactIncrementalScheduling-test.js | 59 ------- 17 files changed, 184 insertions(+), 337 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/__snapshots__/profilingCache-test.js.snap b/packages/react-devtools-shared/src/__tests__/__snapshots__/profilingCache-test.js.snap index b27231efdffc..2ab8751e3cbc 100644 --- a/packages/react-devtools-shared/src/__tests__/__snapshots__/profilingCache-test.js.snap +++ b/packages/react-devtools-shared/src/__tests__/__snapshots__/profilingCache-test.js.snap @@ -40,7 +40,7 @@ Object { 6 => 1, }, "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 16, "updaters": Array [ Object { @@ -87,7 +87,7 @@ Object { 4 => 2, }, "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 15, "updaters": Array [ Object { @@ -186,7 +186,7 @@ Object { 6 => 1, }, "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 12, "updaters": Array [ Object { @@ -445,7 +445,7 @@ Object { ], ], "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 12, "updaters": Array [ Object { @@ -938,7 +938,7 @@ Object { ], ], "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 11, "updaters": Array [ Object { @@ -1597,7 +1597,7 @@ Object { 17 => 1, }, "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 24, "updaters": Array [ Object { @@ -1687,7 +1687,7 @@ Object { "fiberActualDurations": Map {}, "fiberSelfDurations": Map {}, "passiveEffectDuration": 0, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 34, "updaters": Array [ Object { @@ -2223,7 +2223,7 @@ Object { ], ], "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 24, "updaters": Array [ Object { @@ -2310,7 +2310,7 @@ Object { "fiberActualDurations": Array [], "fiberSelfDurations": Array [], "passiveEffectDuration": 0, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 34, "updaters": Array [ Object { @@ -2431,7 +2431,7 @@ Object { 2 => 0, }, "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 0, "updaters": Array [ Object { @@ -2506,7 +2506,7 @@ Object { 3 => 0, }, "passiveEffectDuration": 0, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 0, "updaters": Array [ Object { @@ -2715,7 +2715,7 @@ Object { ], ], "passiveEffectDuration": 0, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 0, "updaters": Array [ Object { @@ -3071,7 +3071,7 @@ Object { 7 => 0, }, "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 0, "updaters": Array [ Object { @@ -3515,7 +3515,7 @@ Object { ], ], "passiveEffectDuration": null, - "priorityLevel": "Normal", + "priorityLevel": "Immediate", "timestamp": 0, "updaters": Array [ Object { diff --git a/packages/react-dom/src/__tests__/ReactDOMFiber-test.js b/packages/react-dom/src/__tests__/ReactDOMFiber-test.js index 36c8367af5d4..ff1ea1771fc8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFiber-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFiber-test.js @@ -1154,19 +1154,13 @@ describe('ReactDOMFiber', () => { expect(ops).toEqual(['A']); if (__DEV__) { - const errorCalls = console.error.calls.count(); + expect(console.error.calls.count()).toBe(2); expect(console.error.calls.argsFor(0)[0]).toMatch( 'ReactDOM.render is no longer supported in React 18', ); expect(console.error.calls.argsFor(1)[0]).toMatch( 'ReactDOM.render is no longer supported in React 18', ); - // TODO: this warning shouldn't be firing in the first place if user didn't call it. - for (let i = 2; i < errorCalls; i++) { - expect(console.error.calls.argsFor(i)[0]).toMatch( - 'unstable_flushDiscreteUpdates: Cannot flush updates when React is already rendering.', - ); - } } }); diff --git a/packages/react-dom/src/__tests__/ReactMount-test.js b/packages/react-dom/src/__tests__/ReactMount-test.js index a214c32f58d0..9571905edaf5 100644 --- a/packages/react-dom/src/__tests__/ReactMount-test.js +++ b/packages/react-dom/src/__tests__/ReactMount-test.js @@ -277,7 +277,7 @@ describe('ReactMount', () => { expect(calls).toBe(5); }); - it('initial mount is sync inside batchedUpdates, but task work is deferred until the end of the batch', () => { + it('initial mount of legacy root is sync inside batchedUpdates, as if it were wrapped in flushSync', () => { const container1 = document.createElement('div'); const container2 = document.createElement('div'); @@ -302,12 +302,12 @@ describe('ReactMount', () => { // Initial mount on another root. Should flush immediately. ReactDOM.render(a, container2); - // The update did not flush yet. - expect(container1.textContent).toEqual('1'); - // The initial mount flushed, but not the update scheduled in cDM. - expect(container2.textContent).toEqual('a'); + // The earlier update also flushed, since flushSync flushes all pending + // sync work across all roots. + expect(container1.textContent).toEqual('2'); + // Layout updates are also flushed synchronously + expect(container2.textContent).toEqual('a!'); }); - // All updates have flushed. expect(container1.textContent).toEqual('2'); expect(container2.textContent).toEqual('a!'); }); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 8e974697075f..80fc3d1e4a05 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -23,8 +23,8 @@ import {createEventHandle} from './ReactDOMEventHandle'; import { batchedUpdates, discreteUpdates, - flushDiscreteUpdates, flushSync, + flushSyncWithoutWarningIfAlreadyRendering, flushControlled, injectIntoDevTools, attemptSynchronousHydration, @@ -100,7 +100,7 @@ setRestoreImplementation(restoreControlledState); setBatchingImplementation( batchedUpdates, discreteUpdates, - flushDiscreteUpdates, + flushSyncWithoutWarningIfAlreadyRendering, ); function createPortal( diff --git a/packages/react-dom/src/client/ReactDOMLegacy.js b/packages/react-dom/src/client/ReactDOMLegacy.js index a62a7fb444c5..f03350cb2924 100644 --- a/packages/react-dom/src/client/ReactDOMLegacy.js +++ b/packages/react-dom/src/client/ReactDOMLegacy.js @@ -29,7 +29,7 @@ import { createContainer, findHostInstanceWithNoPortals, updateContainer, - unbatchedUpdates, + flushSyncWithoutWarningIfAlreadyRendering, getPublicRootInstance, findHostInstance, findHostInstanceWithWarning, @@ -174,7 +174,7 @@ function legacyRenderSubtreeIntoContainer( }; } // Initial mount should not be batched. - unbatchedUpdates(() => { + flushSyncWithoutWarningIfAlreadyRendering(() => { updateContainer(children, fiberRoot, parentComponent, callback); }); } else { @@ -357,7 +357,7 @@ export function unmountComponentAtNode(container: Container) { } // Unmount should not be batched. - unbatchedUpdates(() => { + flushSyncWithoutWarningIfAlreadyRendering(() => { legacyRenderSubtreeIntoContainer(null, null, container, false, () => { // $FlowFixMe This should probably use `delete container._reactRootContainer` container._reactRootContainer = null; diff --git a/packages/react-dom/src/events/ReactDOMUpdateBatching.js b/packages/react-dom/src/events/ReactDOMUpdateBatching.js index f2fbfe4c3f32..d3c46630b148 100644 --- a/packages/react-dom/src/events/ReactDOMUpdateBatching.js +++ b/packages/react-dom/src/events/ReactDOMUpdateBatching.js @@ -23,7 +23,7 @@ let batchedUpdatesImpl = function(fn, bookkeeping) { let discreteUpdatesImpl = function(fn, a, b, c, d) { return fn(a, b, c, d); }; -let flushDiscreteUpdatesImpl = function() {}; +let flushSyncImpl = function() {}; let isInsideEventHandler = false; @@ -39,7 +39,7 @@ function finishEventHandler() { // bails out of the update without touching the DOM. // TODO: Restore state in the microtask, after the discrete updates flush, // instead of early flushing them here. - flushDiscreteUpdatesImpl(); + flushSyncImpl(); restoreStateIfNeeded(); } } @@ -67,9 +67,9 @@ export function discreteUpdates(fn, a, b, c, d) { export function setBatchingImplementation( _batchedUpdatesImpl, _discreteUpdatesImpl, - _flushDiscreteUpdatesImpl, + _flushSyncImpl, ) { batchedUpdatesImpl = _batchedUpdatesImpl; discreteUpdatesImpl = _discreteUpdatesImpl; - flushDiscreteUpdatesImpl = _flushDiscreteUpdatesImpl; + flushSyncImpl = _flushSyncImpl; } diff --git a/packages/react-noop-renderer/src/ReactNoop.js b/packages/react-noop-renderer/src/ReactNoop.js index c09fa2d8000f..a225deaf961f 100644 --- a/packages/react-noop-renderer/src/ReactNoop.js +++ b/packages/react-noop-renderer/src/ReactNoop.js @@ -38,10 +38,8 @@ export const { flushExpired, batchedUpdates, deferredUpdates, - unbatchedUpdates, discreteUpdates, idleUpdates, - flushDiscreteUpdates, flushSync, flushPassiveEffects, act, diff --git a/packages/react-noop-renderer/src/ReactNoopPersistent.js b/packages/react-noop-renderer/src/ReactNoopPersistent.js index 97876990a9b5..86bb87c065e4 100644 --- a/packages/react-noop-renderer/src/ReactNoopPersistent.js +++ b/packages/react-noop-renderer/src/ReactNoopPersistent.js @@ -38,7 +38,6 @@ export const { flushExpired, batchedUpdates, deferredUpdates, - unbatchedUpdates, discreteUpdates, idleUpdates, flushDiscreteUpdates, diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 8dbadafd2c22..5ea510d73d6e 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -901,8 +901,6 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { deferredUpdates: NoopRenderer.deferredUpdates, - unbatchedUpdates: NoopRenderer.unbatchedUpdates, - discreteUpdates: NoopRenderer.discreteUpdates, idleUpdates(fn: () => T): T { @@ -915,8 +913,6 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { } }, - flushDiscreteUpdates: NoopRenderer.flushDiscreteUpdates, - flushSync(fn: () => mixed) { NoopRenderer.flushSync(fn); }, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index bc169fd8a647..d25783164e98 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -18,12 +18,11 @@ import { createContainer as createContainer_old, updateContainer as updateContainer_old, batchedUpdates as batchedUpdates_old, - unbatchedUpdates as unbatchedUpdates_old, deferredUpdates as deferredUpdates_old, discreteUpdates as discreteUpdates_old, - flushDiscreteUpdates as flushDiscreteUpdates_old, flushControlled as flushControlled_old, flushSync as flushSync_old, + flushSyncWithoutWarningIfAlreadyRendering as flushSyncWithoutWarningIfAlreadyRendering_old, flushPassiveEffects as flushPassiveEffects_old, getPublicRootInstance as getPublicRootInstance_old, attemptSynchronousHydration as attemptSynchronousHydration_old, @@ -56,12 +55,11 @@ import { createContainer as createContainer_new, updateContainer as updateContainer_new, batchedUpdates as batchedUpdates_new, - unbatchedUpdates as unbatchedUpdates_new, deferredUpdates as deferredUpdates_new, discreteUpdates as discreteUpdates_new, - flushDiscreteUpdates as flushDiscreteUpdates_new, flushControlled as flushControlled_new, flushSync as flushSync_new, + flushSyncWithoutWarningIfAlreadyRendering as flushSyncWithoutWarningIfAlreadyRendering_new, flushPassiveEffects as flushPassiveEffects_new, getPublicRootInstance as getPublicRootInstance_new, attemptSynchronousHydration as attemptSynchronousHydration_new, @@ -99,22 +97,19 @@ export const updateContainer = enableNewReconciler export const batchedUpdates = enableNewReconciler ? batchedUpdates_new : batchedUpdates_old; -export const unbatchedUpdates = enableNewReconciler - ? unbatchedUpdates_new - : unbatchedUpdates_old; export const deferredUpdates = enableNewReconciler ? deferredUpdates_new : deferredUpdates_old; export const discreteUpdates = enableNewReconciler ? discreteUpdates_new : discreteUpdates_old; -export const flushDiscreteUpdates = enableNewReconciler - ? flushDiscreteUpdates_new - : flushDiscreteUpdates_old; export const flushControlled = enableNewReconciler ? flushControlled_new : flushControlled_old; export const flushSync = enableNewReconciler ? flushSync_new : flushSync_old; +export const flushSyncWithoutWarningIfAlreadyRendering = enableNewReconciler + ? flushSyncWithoutWarningIfAlreadyRendering_new + : flushSyncWithoutWarningIfAlreadyRendering_old; export const flushPassiveEffects = enableNewReconciler ? flushPassiveEffects_new : flushPassiveEffects_old; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index f406f8592b0d..7ab995a5fcb0 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -52,12 +52,11 @@ import { scheduleUpdateOnFiber, flushRoot, batchedUpdates, - unbatchedUpdates, flushSync, flushControlled, deferredUpdates, discreteUpdates, - flushDiscreteUpdates, + flushSyncWithoutWarningIfAlreadyRendering, flushPassiveEffects, } from './ReactFiberWorkLoop.new'; import { @@ -327,12 +326,11 @@ export function updateContainer( export { batchedUpdates, - unbatchedUpdates, deferredUpdates, discreteUpdates, - flushDiscreteUpdates, flushControlled, flushSync, + flushSyncWithoutWarningIfAlreadyRendering, flushPassiveEffects, }; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.old.js b/packages/react-reconciler/src/ReactFiberReconciler.old.js index f6ae425b4400..da127689c7c6 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.old.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.old.js @@ -52,12 +52,11 @@ import { scheduleUpdateOnFiber, flushRoot, batchedUpdates, - unbatchedUpdates, flushSync, flushControlled, deferredUpdates, discreteUpdates, - flushDiscreteUpdates, + flushSyncWithoutWarningIfAlreadyRendering, flushPassiveEffects, } from './ReactFiberWorkLoop.old'; import { @@ -327,12 +326,11 @@ export function updateContainer( export { batchedUpdates, - unbatchedUpdates, deferredUpdates, discreteUpdates, - flushDiscreteUpdates, flushControlled, flushSync, + flushSyncWithoutWarningIfAlreadyRendering, flushPassiveEffects, }; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 7f429c873438..b6f697edfee3 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -246,12 +246,11 @@ const { type ExecutionContext = number; -export const NoContext = /* */ 0b00000; -const BatchedContext = /* */ 0b00001; -const LegacyUnbatchedContext = /* */ 0b00010; -const RenderContext = /* */ 0b00100; -const CommitContext = /* */ 0b01000; -export const RetryAfterError = /* */ 0b10000; +export const NoContext = /* */ 0b0000; +const BatchedContext = /* */ 0b0001; +const RenderContext = /* */ 0b0010; +const CommitContext = /* */ 0b0100; +export const RetryAfterError = /* */ 0b1000; type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5; const RootIncomplete = 0; @@ -515,35 +514,19 @@ export function scheduleUpdateOnFiber( } } - if (lane === SyncLane) { - if ( - // Check if we're inside unbatchedUpdates - (executionContext & LegacyUnbatchedContext) !== NoContext && - // Check if we're not already rendering - (executionContext & (RenderContext | CommitContext)) === NoContext - ) { - // This is a legacy edge case. The initial mount of a ReactDOM.render-ed - // root inside of batchedUpdates should be synchronous, but layout updates - // should be deferred until the end of the batch. - performSyncWorkOnRoot(root); - } else { - ensureRootIsScheduled(root, eventTime); - if ( - executionContext === NoContext && - (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(); - } - } - } else { - // Schedule other updates after in case the callback is sync. - ensureRootIsScheduled(root, eventTime); + ensureRootIsScheduled(root, eventTime); + if ( + lane === SyncLane && + executionContext === NoContext && + (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(); } return root; @@ -1044,34 +1027,6 @@ export function getExecutionContext(): ExecutionContext { return executionContext; } -export function flushDiscreteUpdates() { - // TODO: Should be able to flush inside batchedUpdates, but not inside `act`. - // However, `act` uses `batchedUpdates`, so there's no way to distinguish - // those two cases. Need to fix this before exposing flushDiscreteUpdates - // as a public API. - if ( - (executionContext & (BatchedContext | RenderContext | CommitContext)) !== - NoContext - ) { - if (__DEV__) { - if ((executionContext & RenderContext) !== NoContext) { - console.error( - 'unstable_flushDiscreteUpdates: Cannot flush updates when React is ' + - 'already rendering.', - ); - } - } - // We're already rendering, so we can't synchronously flush pending work. - // This is probably a nested event dispatch triggered by a lifecycle/effect, - // like `el.focus()`. Exit. - return; - } - flushSyncCallbacks(); - // If the discrete updates scheduled passive effects, flush them now so that - // they fire before the next serial event. - flushPassiveEffects(); -} - export function deferredUpdates(fn: () => A): A { const previousPriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; @@ -1123,26 +1078,19 @@ export function discreteUpdates( } } -export function unbatchedUpdates(fn: (a: A) => R, a: A): R { - const prevExecutionContext = executionContext; - executionContext &= ~BatchedContext; - executionContext |= LegacyUnbatchedContext; - try { - return fn(a); - } finally { - executionContext = prevExecutionContext; - // If there were legacy sync updates, flush them at the end of the outer - // most batchedUpdates-like method. - if (executionContext === NoContext) { - resetRenderTimer(); - // TODO: I think this call is redundant, because we flush inside - // scheduleUpdateOnFiber when LegacyUnbatchedContext is set. - flushSyncCallbacksOnlyInLegacyMode(); - } +export function flushSyncWithoutWarningIfAlreadyRendering( + fn: A => R, + a: A, +): R { + // In legacy mode, we flush pending passive effects at the beginning of the + // next event, not at the end of the previous one. + if ( + rootWithPendingPassiveEffects !== null && + rootWithPendingPassiveEffects.tag === LegacyRoot + ) { + flushPassiveEffects(); } -} -export function flushSync(fn: A => R, a: A): R { const prevExecutionContext = executionContext; executionContext |= BatchedContext; @@ -1165,18 +1113,23 @@ export function flushSync(fn: A => R, a: A): R { // the stack. if ((executionContext & (RenderContext | CommitContext)) === NoContext) { flushSyncCallbacks(); - } else { - if (__DEV__) { - console.error( - 'flushSync was called from inside a lifecycle method. React cannot ' + - 'flush when React is already rendering. Consider moving this call to ' + - 'a scheduler task or micro task.', - ); - } } } } +export function flushSync(fn: A => R, a: A): R { + if (__DEV__) { + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + console.error( + 'flushSync was called from inside a lifecycle method. React cannot ' + + 'flush when React is already rendering. Consider moving this call to ' + + 'a scheduler task or micro task.', + ); + } + } + return flushSyncWithoutWarningIfAlreadyRendering(fn, a); +} + export function flushControlled(fn: () => mixed): void { const prevExecutionContext = executionContext; executionContext |= BatchedContext; @@ -1974,24 +1927,6 @@ function commitRootImpl(root, renderPriorityLevel) { throw error; } - if ((executionContext & LegacyUnbatchedContext) !== NoContext) { - if (__DEV__) { - if (enableDebugTracing) { - logCommitStopped(); - } - } - - if (enableSchedulingProfiler) { - markCommitStopped(); - } - - // This is a legacy edge case. We just committed the initial mount of - // a ReactDOM.render-ed root inside of batchedUpdates. The commit fired - // synchronously, but layout updates should be deferred until the end - // of the batch. - return null; - } - // If the passive effects are the result of a discrete render, flush them // synchronously at the end of the current task so that the result is // immediately observable. Otherwise, we assume that they are not diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index f356fc3f1156..22c8395cb098 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -246,12 +246,11 @@ const { type ExecutionContext = number; -export const NoContext = /* */ 0b00000; -const BatchedContext = /* */ 0b00001; -const LegacyUnbatchedContext = /* */ 0b00010; -const RenderContext = /* */ 0b00100; -const CommitContext = /* */ 0b01000; -export const RetryAfterError = /* */ 0b10000; +export const NoContext = /* */ 0b0000; +const BatchedContext = /* */ 0b0001; +const RenderContext = /* */ 0b0010; +const CommitContext = /* */ 0b0100; +export const RetryAfterError = /* */ 0b1000; type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5; const RootIncomplete = 0; @@ -515,35 +514,19 @@ export function scheduleUpdateOnFiber( } } - if (lane === SyncLane) { - if ( - // Check if we're inside unbatchedUpdates - (executionContext & LegacyUnbatchedContext) !== NoContext && - // Check if we're not already rendering - (executionContext & (RenderContext | CommitContext)) === NoContext - ) { - // This is a legacy edge case. The initial mount of a ReactDOM.render-ed - // root inside of batchedUpdates should be synchronous, but layout updates - // should be deferred until the end of the batch. - performSyncWorkOnRoot(root); - } else { - ensureRootIsScheduled(root, eventTime); - if ( - executionContext === NoContext && - (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(); - } - } - } else { - // Schedule other updates after in case the callback is sync. - ensureRootIsScheduled(root, eventTime); + ensureRootIsScheduled(root, eventTime); + if ( + lane === SyncLane && + executionContext === NoContext && + (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(); } return root; @@ -1044,34 +1027,6 @@ export function getExecutionContext(): ExecutionContext { return executionContext; } -export function flushDiscreteUpdates() { - // TODO: Should be able to flush inside batchedUpdates, but not inside `act`. - // However, `act` uses `batchedUpdates`, so there's no way to distinguish - // those two cases. Need to fix this before exposing flushDiscreteUpdates - // as a public API. - if ( - (executionContext & (BatchedContext | RenderContext | CommitContext)) !== - NoContext - ) { - if (__DEV__) { - if ((executionContext & RenderContext) !== NoContext) { - console.error( - 'unstable_flushDiscreteUpdates: Cannot flush updates when React is ' + - 'already rendering.', - ); - } - } - // We're already rendering, so we can't synchronously flush pending work. - // This is probably a nested event dispatch triggered by a lifecycle/effect, - // like `el.focus()`. Exit. - return; - } - flushSyncCallbacks(); - // If the discrete updates scheduled passive effects, flush them now so that - // they fire before the next serial event. - flushPassiveEffects(); -} - export function deferredUpdates(fn: () => A): A { const previousPriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; @@ -1123,26 +1078,19 @@ export function discreteUpdates( } } -export function unbatchedUpdates(fn: (a: A) => R, a: A): R { - const prevExecutionContext = executionContext; - executionContext &= ~BatchedContext; - executionContext |= LegacyUnbatchedContext; - try { - return fn(a); - } finally { - executionContext = prevExecutionContext; - // If there were legacy sync updates, flush them at the end of the outer - // most batchedUpdates-like method. - if (executionContext === NoContext) { - resetRenderTimer(); - // TODO: I think this call is redundant, because we flush inside - // scheduleUpdateOnFiber when LegacyUnbatchedContext is set. - flushSyncCallbacksOnlyInLegacyMode(); - } +export function flushSyncWithoutWarningIfAlreadyRendering( + fn: A => R, + a: A, +): R { + // In legacy mode, we flush pending passive effects at the beginning of the + // next event, not at the end of the previous one. + if ( + rootWithPendingPassiveEffects !== null && + rootWithPendingPassiveEffects.tag === LegacyRoot + ) { + flushPassiveEffects(); } -} -export function flushSync(fn: A => R, a: A): R { const prevExecutionContext = executionContext; executionContext |= BatchedContext; @@ -1165,18 +1113,23 @@ export function flushSync(fn: A => R, a: A): R { // the stack. if ((executionContext & (RenderContext | CommitContext)) === NoContext) { flushSyncCallbacks(); - } else { - if (__DEV__) { - console.error( - 'flushSync was called from inside a lifecycle method. React cannot ' + - 'flush when React is already rendering. Consider moving this call to ' + - 'a scheduler task or micro task.', - ); - } } } } +export function flushSync(fn: A => R, a: A): R { + if (__DEV__) { + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + console.error( + 'flushSync was called from inside a lifecycle method. React cannot ' + + 'flush when React is already rendering. Consider moving this call to ' + + 'a scheduler task or micro task.', + ); + } + } + return flushSyncWithoutWarningIfAlreadyRendering(fn, a); +} + export function flushControlled(fn: () => mixed): void { const prevExecutionContext = executionContext; executionContext |= BatchedContext; @@ -1974,24 +1927,6 @@ function commitRootImpl(root, renderPriorityLevel) { throw error; } - if ((executionContext & LegacyUnbatchedContext) !== NoContext) { - if (__DEV__) { - if (enableDebugTracing) { - logCommitStopped(); - } - } - - if (enableSchedulingProfiler) { - markCommitStopped(); - } - - // This is a legacy edge case. We just committed the initial mount of - // a ReactDOM.render-ed root inside of batchedUpdates. The commit fired - // synchronously, but layout updates should be deferred until the end - // of the batch. - return null; - } - // If the passive effects are the result of a discrete render, flush them // synchronously at the end of the current task so that the result is // immediately observable. Otherwise, we assume that they are not diff --git a/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js b/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js index a3a74d739a9a..20d846f64b03 100644 --- a/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js +++ b/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js @@ -128,7 +128,7 @@ describe('ReactFlushSync', () => { }); }); - test('do not flush passive effects synchronously in legacy mode', async () => { + test('do not flush passive effects synchronously after render in legacy mode', async () => { function App() { useEffect(() => { Scheduler.unstable_yieldValue('Effect'); @@ -152,6 +152,40 @@ describe('ReactFlushSync', () => { expect(Scheduler).toHaveYielded(['Effect']); }); + test('flush pending passive effects before scope is called in legacy mode', async () => { + let currentStep = 0; + + function App({step}) { + useEffect(() => { + currentStep = step; + Scheduler.unstable_yieldValue('Effect: ' + step); + }, [step]); + return ; + } + + const root = ReactNoop.createLegacyRoot(); + await act(async () => { + ReactNoop.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 1, + // Because we're in legacy mode, we shouldn't have flushed the passive + // effects yet. + ]); + expect(root).toMatchRenderedOutput('1'); + + ReactNoop.flushSync(() => { + // This should render step 2 because the passive effect has already + // fired, before the scope function is called. + root.render(); + }); + expect(Scheduler).toHaveYielded(['Effect: 1', 2]); + expect(root).toMatchRenderedOutput('2'); + }); + expect(Scheduler).toHaveYielded(['Effect: 2']); + }); + test("do not flush passive effects synchronously when they aren't the result of a sync render", async () => { function App() { useEffect(() => { @@ -173,4 +207,27 @@ describe('ReactFlushSync', () => { // Effect flushes after paint. expect(Scheduler).toHaveYielded(['Effect']); }); + + test('does not flush pending passive effects', async () => { + function App() { + useEffect(() => { + Scheduler.unstable_yieldValue('Effect'); + }, []); + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + expect(Scheduler).toFlushUntilNextPaint(['Child']); + expect(root).toMatchRenderedOutput('Child'); + + // Passive effects are pending. Calling flushSync should not affect them. + ReactNoop.flushSync(); + // Effects still haven't fired. + expect(Scheduler).toHaveYielded([]); + }); + // Now the effects have fired. + expect(Scheduler).toHaveYielded(['Effect']); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index fc5e61cd78ee..7de523293448 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -1795,10 +1795,11 @@ describe('ReactHooksWithNoopRenderer', () => { return ; } await act(async () => { - ReactNoop.renderLegacySyncRoot(); + ReactNoop.flushSync(() => { + ReactNoop.renderLegacySyncRoot(); + }); // Even in legacy mode, effects are deferred until after paint - ReactNoop.flushSync(); expect(Scheduler).toHaveYielded(['Count: (empty)']); expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); }); diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalScheduling-test.js b/packages/react-reconciler/src/__tests__/ReactIncrementalScheduling-test.js index b603b03c696f..9c9490085147 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalScheduling-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalScheduling-test.js @@ -349,63 +349,4 @@ describe('ReactIncrementalScheduling', () => { // The updates should all be flushed with Task priority expect(ReactNoop).toMatchRenderedOutput(); }); - - it('can opt-out of batching using unbatchedUpdates', () => { - ReactNoop.flushSync(() => { - ReactNoop.render(); - expect(ReactNoop.getChildren()).toEqual([]); - // Should not have flushed yet because we're still batching - - // unbatchedUpdates reverses the effect of batchedUpdates, so sync - // updates are not batched - ReactNoop.unbatchedUpdates(() => { - ReactNoop.render(); - expect(ReactNoop).toMatchRenderedOutput(); - ReactNoop.render(); - expect(ReactNoop).toMatchRenderedOutput(); - }); - - ReactNoop.render(); - expect(ReactNoop).toMatchRenderedOutput(); - }); - // Remaining update is now flushed - expect(ReactNoop).toMatchRenderedOutput(); - }); - - it('nested updates are always deferred, even inside unbatchedUpdates', () => { - let instance; - class Foo extends React.Component { - state = {step: 0}; - componentDidUpdate() { - Scheduler.unstable_yieldValue('componentDidUpdate: ' + this.state.step); - if (this.state.step === 1) { - ReactNoop.unbatchedUpdates(() => { - // This is a nested state update, so it should not be - // flushed synchronously, even though we wrapped it - // in unbatchedUpdates. - this.setState({step: 2}); - }); - expect(Scheduler).toHaveYielded([ - 'render: 1', - 'componentDidUpdate: 1', - ]); - expect(ReactNoop).toMatchRenderedOutput(); - } - } - render() { - Scheduler.unstable_yieldValue('render: ' + this.state.step); - instance = this; - return ; - } - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['render: 0']); - expect(ReactNoop).toMatchRenderedOutput(); - - ReactNoop.flushSync(() => { - instance.setState({step: 1}); - }); - expect(Scheduler).toHaveYielded(['render: 2', 'componentDidUpdate: 2']); - expect(ReactNoop).toMatchRenderedOutput(); - }); });