Skip to content

Commit

Permalink
Flush discrete passive effects before paint (#21150)
Browse files Browse the repository at this point in the history
If a discrete render results in passive effects, we should flush them
synchronously at the end of the current task so that the result is
immediately observable. For example, if a passive effect adds an event
listener, the listener will be added before the next input.

We don't need to do this for effects that don't have discrete/sync
priority, because we assume they are not order-dependent and do not
need to be observed by external systems.

For legacy mode, we will maintain the existing behavior, since it hasn't
been reported as an issue, and we'd have to do additional work to
distinguish "legacy default sync" from "discrete sync" to prevent all
passive effects from being treated this way.
  • Loading branch information
acdlite committed Mar 31, 2021
1 parent b48b38a commit f8ef4ff
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 9 deletions.
15 changes: 15 additions & 0 deletions packages/react-reconciler/src/ReactFiberWorkLoop.new.js
Expand Up @@ -2015,6 +2015,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.
flushSyncCallbackQueue();

Expand Down
15 changes: 15 additions & 0 deletions packages/react-reconciler/src/ReactFiberWorkLoop.old.js
Expand Up @@ -2015,6 +2015,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.
flushSyncCallbackQueue();

Expand Down
69 changes: 69 additions & 0 deletions packages/react-reconciler/src/__tests__/ReactFlushSync-test.js
Expand Up @@ -97,4 +97,73 @@ describe('ReactFlushSync', () => {
expect(Scheduler).toHaveYielded(['1, 1']);
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 <Text text="Child" />;
}

const root = ReactNoop.createRoot();
await ReactNoop.act(async () => {
ReactNoop.flushSync(() => {
root.render(<App />);
});
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 <Text text="Child" />;
}

const root = ReactNoop.createLegacyRoot();
await ReactNoop.act(async () => {
ReactNoop.flushSync(() => {
root.render(<App />);
});
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 <Text text="Child" />;
}

const root = ReactNoop.createRoot();
await ReactNoop.act(async () => {
root.render(<App />);
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']);
});
});
Expand Up @@ -33,6 +33,7 @@ let useDeferredValue;
let forwardRef;
let memo;
let act;
let ContinuousEventPriority;

describe('ReactHooksWithNoopRenderer', () => {
beforeEach(() => {
Expand All @@ -57,6 +58,8 @@ describe('ReactHooksWithNoopRenderer', () => {
useDeferredValue = React.unstable_useDeferredValue;
Suspense = React.Suspense;
act = ReactNoop.act;
ContinuousEventPriority = require('react-reconciler/constants')
.ContinuousEventPriority;

textCache = new Map();

Expand Down Expand Up @@ -1351,10 +1354,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',
]);
Expand Down
11 changes: 4 additions & 7 deletions packages/react/src/__tests__/ReactProfiler-test.internal.js
Expand Up @@ -3764,13 +3764,10 @@ describe('Profiler', () => {
);
});

// Profiler tag causes passive effects to be scheduled,
// so the interactions are still not completed.
expect(Scheduler).toHaveYielded(['SecondComponent']);
expect(onInteractionTraced).toHaveBeenCalledTimes(2);
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
expect(Scheduler).toFlushAndYieldThrough(['onPostCommit']);

expect(Scheduler).toHaveYielded([
'SecondComponent',
'onPostCommit',
]);
expect(onInteractionTraced).toHaveBeenCalledTimes(2);
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(
1,
Expand Down

0 comments on commit f8ef4ff

Please sign in to comment.