diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 7c659b7775a1e..99f3e014148f5 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -1976,6 +1976,21 @@ function commitRootImpl(root, renderPriorityLevel) { 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 + // order-dependent and do not need to be observed by external systems, so we + // can wait until after paint. + // TODO: We can optimize this by not scheduling the callback earlier. Since we + // currently schedule the callback in multiple places, will wait until those + // are consolidated. + if ( + includesSomeLane(pendingPassiveEffectsLanes, SyncLane) && + root.tag !== LegacyRoot + ) { + flushPassiveEffects(); + } + // If layout work was scheduled, flush it now. flushSyncCallbacks(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 530646f8a4d29..e7328b989083b 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -1976,6 +1976,21 @@ function commitRootImpl(root, renderPriorityLevel) { 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 + // order-dependent and do not need to be observed by external systems, so we + // can wait until after paint. + // TODO: We can optimize this by not scheduling the callback earlier. Since we + // currently schedule the callback in multiple places, will wait until those + // are consolidated. + if ( + includesSomeLane(pendingPassiveEffectsLanes, SyncLane) && + root.tag !== LegacyRoot + ) { + flushPassiveEffects(); + } + // If layout work was scheduled, flush it now. flushSyncCallbacks(); diff --git a/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js b/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js index f763e44705aaa..327b4a87ca678 100644 --- a/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js +++ b/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js @@ -61,4 +61,73 @@ describe('ReactFlushSync', () => { }); expect(root).toMatchRenderedOutput('1, 1'); }); + + test('flushes passive effects synchronously when they are the result of a sync render', async () => { + function App() { + useEffect(() => { + Scheduler.unstable_yieldValue('Effect'); + }, []); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + ReactNoop.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Child', + // Because the pending effect was the result of a sync update, calling + // flushSync should flush it. + 'Effect', + ]); + expect(root).toMatchRenderedOutput('Child'); + }); + }); + + test('do not flush passive effects synchronously in legacy mode', async () => { + function App() { + useEffect(() => { + Scheduler.unstable_yieldValue('Effect'); + }, []); + return ; + } + + const root = ReactNoop.createLegacyRoot(); + await ReactNoop.act(async () => { + ReactNoop.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Child', + // Because we're in legacy mode, we shouldn't have flushed the passive + // effects yet. + ]); + expect(root).toMatchRenderedOutput('Child'); + }); + // Effect flushes after paint. + expect(Scheduler).toHaveYielded(['Effect']); + }); + + test("do not flush passive effects synchronously when they aren't the result of a sync render", async () => { + function App() { + useEffect(() => { + Scheduler.unstable_yieldValue('Effect'); + }, []); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + expect(Scheduler).toFlushUntilNextPaint([ + 'Child', + // Because the passive effect was not the result of a sync update, it + // should not flush before paint. + ]); + expect(root).toMatchRenderedOutput('Child'); + }); + // Effect flushes after paint. + 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 a02c64067195c..27d09697fc213 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -32,6 +32,7 @@ let useDeferredValue; let forwardRef; let memo; let act; +let ContinuousEventPriority; describe('ReactHooksWithNoopRenderer', () => { beforeEach(() => { @@ -55,6 +56,8 @@ describe('ReactHooksWithNoopRenderer', () => { useDeferredValue = React.unstable_useDeferredValue; Suspense = React.Suspense; act = ReactNoop.act; + ContinuousEventPriority = require('react-reconciler/constants') + .ContinuousEventPriority; textCache = new Map(); @@ -1397,10 +1400,10 @@ describe('ReactHooksWithNoopRenderer', () => { expect(Scheduler).toFlushAndYieldThrough(['Child one render']); // Schedule unmount for the parent that unmounts children with pending update. - ReactNoop.flushSync(() => { + ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () => { setParentState(false); }); - expect(Scheduler).toHaveYielded([ + expect(Scheduler).toFlushUntilNextPaint([ 'Parent false render', 'Parent false commit', ]);