From 0e3c7e1d62efb6238b69e5295d45b9bd2dcf9181 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sun, 28 Mar 2021 18:50:30 -0500 Subject: [PATCH] Fix: flushSync changes priority inside effect (#21122) When called from inside an effect, flushSync cannot synchronously flush its updates because React is already working. So we fire a warning. However, we should still change the priority of the updates to sync so that they flush at the end of the current task. This only affects useEffect because updates inside useLayoutEffect (and the rest of the commit phase, like ref callbacks) are already sync. --- .../src/ReactFiberWorkLoop.new.js | 22 +++---- .../src/ReactFiberWorkLoop.old.js | 22 +++---- .../src/__tests__/ReactFlushSync-test.js | 57 +++++++++++++++++++ 3 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactFlushSync-test.js diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index e562cb7cdbc46..bfc7d4c177e90 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -1166,16 +1166,6 @@ export function unbatchedUpdates(fn: (a: A) => R, a: A): R { export function flushSync(fn: A => R, a: A): R { const prevExecutionContext = executionContext; - if ((prevExecutionContext & (RenderContext | CommitContext)) !== NoContext) { - 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.', - ); - } - return fn(a); - } executionContext |= BatchedContext; const previousPriority = getCurrentUpdatePriority(); @@ -1192,7 +1182,17 @@ export function flushSync(fn: A => R, a: A): R { // Flush the immediate callbacks that were scheduled during this batch. // Note that this will happen even if batchedUpdates is higher up // the stack. - flushSyncCallbackQueue(); + if ((executionContext & (RenderContext | CommitContext)) === NoContext) { + flushSyncCallbackQueue(); + } 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.', + ); + } + } } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 56f09ab049c13..a116812313edf 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -1166,16 +1166,6 @@ export function unbatchedUpdates(fn: (a: A) => R, a: A): R { export function flushSync(fn: A => R, a: A): R { const prevExecutionContext = executionContext; - if ((prevExecutionContext & (RenderContext | CommitContext)) !== NoContext) { - 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.', - ); - } - return fn(a); - } executionContext |= BatchedContext; const previousPriority = getCurrentUpdatePriority(); @@ -1192,7 +1182,17 @@ export function flushSync(fn: A => R, a: A): R { // Flush the immediate callbacks that were scheduled during this batch. // Note that this will happen even if batchedUpdates is higher up // the stack. - flushSyncCallbackQueue(); + if ((executionContext & (RenderContext | CommitContext)) === NoContext) { + flushSyncCallbackQueue(); + } 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.', + ); + } + } } } diff --git a/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js b/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js new file mode 100644 index 0000000000000..3c96f5ee65914 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js @@ -0,0 +1,57 @@ +let React; +let ReactNoop; +let Scheduler; +let useState; +let useEffect; + +describe('ReactFlushSync', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + useState = React.useState; + useEffect = React.useEffect; + }); + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + test('changes priority of updates in useEffect', async () => { + function App() { + const [syncState, setSyncState] = useState(0); + const [state, setState] = useState(0); + useEffect(() => { + if (syncState !== 1) { + setState(1); + ReactNoop.flushSync(() => setSyncState(1)); + } + }, [syncState, state]); + return ; + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + // This will yield right before the passive effect fires + expect(Scheduler).toFlushUntilNextPaint(['0, 0']); + + // The passive effect will schedule a sync update and a normal update. + // They should commit in two separate batches. First the sync one. + expect(() => + expect(Scheduler).toFlushUntilNextPaint(['1, 0']), + ).toErrorDev('flushSync was called from inside a lifecycle method'); + + // The remaining update is not sync + ReactNoop.flushSync(); + expect(Scheduler).toHaveYielded([]); + + // Now flush it. + expect(Scheduler).toFlushUntilNextPaint(['1, 1']); + }); + expect(root).toMatchRenderedOutput('1, 1'); + }); +});