diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js index be14c4695089c..db60ab8be5360 100644 --- a/packages/react-reconciler/src/ReactFiberRootScheduler.js +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -119,6 +119,15 @@ export function ensureRootIsScheduled(root: FiberRoot): void { // unblock additional features we have planned. scheduleTaskForRootDuringMicrotask(root, now()); } + + if ( + __DEV__ && + ReactCurrentActQueue.isBatchingLegacy && + root.tag === LegacyRoot + ) { + // Special `act` case: Record whenever a legacy update is scheduled. + ReactCurrentActQueue.didScheduleLegacyUpdate = true; + } } export function flushSyncWorkOnAllRoots() { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 1715eab466c9d..401defb280fe4 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -828,7 +828,6 @@ export function scheduleUpdateOnFiber( ) { 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 diff --git a/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js b/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js index e64a208c54dc1..8615d4ab2667e 100644 --- a/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIsomorphicAct-test.js @@ -17,10 +17,13 @@ let Suspense; let DiscreteEventPriority; let startTransition; let waitForMicrotasks; +let Scheduler; +let assertLog; describe('isomorphic act()', () => { beforeEach(() => { React = require('react'); + Scheduler = require('scheduler'); ReactNoop = require('react-noop-renderer'); DiscreteEventPriority = @@ -31,6 +34,7 @@ describe('isomorphic act()', () => { startTransition = React.startTransition; waitForMicrotasks = require('internal-test-utils').waitForMicrotasks; + assertLog = require('internal-test-utils').assertLog; }); beforeEach(() => { @@ -41,6 +45,11 @@ describe('isomorphic act()', () => { jest.restoreAllMocks(); }); + function Text({text}) { + Scheduler.log(text); + return text; + } + // @gate __DEV__ test('bypasses queueMicrotask', async () => { const root = ReactNoop.createRoot(); @@ -132,19 +141,67 @@ describe('isomorphic act()', () => { const root = ReactNoop.createLegacyRoot(); await act(async () => { - // These updates are batched. This replicates the behavior of the original - // `act` implementation, for compatibility. - root.render('A'); - root.render('B'); - // Nothing has rendered yet. - expect(root).toMatchRenderedOutput(null); + queueMicrotask(() => { + Scheduler.log('Current tree in microtask: ' + root.getChildrenAsJSX()); + root.render(); + }); + root.render(); + root.render(); + await null; - // Updates are flushed after the first await. - expect(root).toMatchRenderedOutput('B'); + assertLog([ + // A and B should render in a single batch _before_ the microtask queue + // has run. This replicates the behavior of the original `act` + // implementation, for compatibility. + 'B', + 'Current tree in microtask: B', + + // C isn't scheduled until a microtask, so it's rendered separately. + 'C', + ]); + + // Subsequent updates should also render in separate batches. + root.render(); + root.render(); + assertLog(['D', 'E']); + }); + }); - // Subsequent updates in the same scope aren't batched. - root.render('C'); - expect(root).toMatchRenderedOutput('C'); + // @gate __DEV__ + test('in legacy mode, in an async scope, updates are batched until the first `await` (regression test: batchedUpdates)', async () => { + const root = ReactNoop.createLegacyRoot(); + + await act(async () => { + queueMicrotask(() => { + Scheduler.log('Current tree in microtask: ' + root.getChildrenAsJSX()); + root.render(); + }); + + // This is a regression test. The presence of `batchedUpdates` would cause + // these updates to not flush until a microtask. The correct behavior is + // that they flush before the microtask queue, regardless of whether + // they are wrapped with `batchedUpdates`. + ReactNoop.batchedUpdates(() => { + root.render(); + root.render(); + }); + + await null; + assertLog([ + // A and B should render in a single batch _before_ the microtask queue + // has run. This replicates the behavior of the original `act` + // implementation, for compatibility. + 'B', + 'Current tree in microtask: B', + + // C isn't scheduled until a microtask, so it's rendered separately. + 'C', + ]); + + // Subsequent updates should also render in separate batches. + root.render(); + root.render(); + assertLog(['D', 'E']); }); }); diff --git a/scripts/jest/matchers/reactTestMatchers.js b/scripts/jest/matchers/reactTestMatchers.js index ecf0329a0407d..63d03c5d70936 100644 --- a/scripts/jest/matchers/reactTestMatchers.js +++ b/scripts/jest/matchers/reactTestMatchers.js @@ -20,13 +20,13 @@ function captureAssertion(fn) { return {pass: true}; } -function assertYieldsWereCleared(Scheduler) { +function assertYieldsWereCleared(Scheduler, caller) { const actualYields = Scheduler.unstable_clearLog(); if (actualYields.length !== 0) { const error = Error( 'The event log is not empty. Call assertLog(...) first.' ); - Error.captureStackTrace(error, assertYieldsWereCleared); + Error.captureStackTrace(error, caller); throw error; } } @@ -34,7 +34,7 @@ function assertYieldsWereCleared(Scheduler) { function toMatchRenderedOutput(ReactNoop, expectedJSX) { if (typeof ReactNoop.getChildrenAsJSX === 'function') { const Scheduler = ReactNoop._Scheduler; - assertYieldsWereCleared(Scheduler); + assertYieldsWereCleared(Scheduler, toMatchRenderedOutput); return captureAssertion(() => { expect(ReactNoop.getChildrenAsJSX()).toEqual(expectedJSX); });