Skip to content

Commit f8ef4ff

Browse files
authored
Flush discrete passive effects before paint (#21150)
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.
1 parent b48b38a commit f8ef4ff

File tree

5 files changed

+108
-9
lines changed

5 files changed

+108
-9
lines changed

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

+15
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,21 @@ function commitRootImpl(root, renderPriorityLevel) {
20152015
return null;
20162016
}
20172017

2018+
// If the passive effects are the result of a discrete render, flush them
2019+
// synchronously at the end of the current task so that the result is
2020+
// immediately observable. Otherwise, we assume that they are not
2021+
// order-dependent and do not need to be observed by external systems, so we
2022+
// can wait until after paint.
2023+
// TODO: We can optimize this by not scheduling the callback earlier. Since we
2024+
// currently schedule the callback in multiple places, will wait until those
2025+
// are consolidated.
2026+
if (
2027+
includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
2028+
root.tag !== LegacyRoot
2029+
) {
2030+
flushPassiveEffects();
2031+
}
2032+
20182033
// If layout work was scheduled, flush it now.
20192034
flushSyncCallbackQueue();
20202035

packages/react-reconciler/src/ReactFiberWorkLoop.old.js

+15
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,21 @@ function commitRootImpl(root, renderPriorityLevel) {
20152015
return null;
20162016
}
20172017

2018+
// If the passive effects are the result of a discrete render, flush them
2019+
// synchronously at the end of the current task so that the result is
2020+
// immediately observable. Otherwise, we assume that they are not
2021+
// order-dependent and do not need to be observed by external systems, so we
2022+
// can wait until after paint.
2023+
// TODO: We can optimize this by not scheduling the callback earlier. Since we
2024+
// currently schedule the callback in multiple places, will wait until those
2025+
// are consolidated.
2026+
if (
2027+
includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
2028+
root.tag !== LegacyRoot
2029+
) {
2030+
flushPassiveEffects();
2031+
}
2032+
20182033
// If layout work was scheduled, flush it now.
20192034
flushSyncCallbackQueue();
20202035

packages/react-reconciler/src/__tests__/ReactFlushSync-test.js

+69
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,73 @@ describe('ReactFlushSync', () => {
9797
expect(Scheduler).toHaveYielded(['1, 1']);
9898
expect(root).toMatchRenderedOutput('1, 1');
9999
});
100+
101+
test('flushes passive effects synchronously when they are the result of a sync render', async () => {
102+
function App() {
103+
useEffect(() => {
104+
Scheduler.unstable_yieldValue('Effect');
105+
}, []);
106+
return <Text text="Child" />;
107+
}
108+
109+
const root = ReactNoop.createRoot();
110+
await ReactNoop.act(async () => {
111+
ReactNoop.flushSync(() => {
112+
root.render(<App />);
113+
});
114+
expect(Scheduler).toHaveYielded([
115+
'Child',
116+
// Because the pending effect was the result of a sync update, calling
117+
// flushSync should flush it.
118+
'Effect',
119+
]);
120+
expect(root).toMatchRenderedOutput('Child');
121+
});
122+
});
123+
124+
test('do not flush passive effects synchronously in legacy mode', async () => {
125+
function App() {
126+
useEffect(() => {
127+
Scheduler.unstable_yieldValue('Effect');
128+
}, []);
129+
return <Text text="Child" />;
130+
}
131+
132+
const root = ReactNoop.createLegacyRoot();
133+
await ReactNoop.act(async () => {
134+
ReactNoop.flushSync(() => {
135+
root.render(<App />);
136+
});
137+
expect(Scheduler).toHaveYielded([
138+
'Child',
139+
// Because we're in legacy mode, we shouldn't have flushed the passive
140+
// effects yet.
141+
]);
142+
expect(root).toMatchRenderedOutput('Child');
143+
});
144+
// Effect flushes after paint.
145+
expect(Scheduler).toHaveYielded(['Effect']);
146+
});
147+
148+
test("do not flush passive effects synchronously when they aren't the result of a sync render", async () => {
149+
function App() {
150+
useEffect(() => {
151+
Scheduler.unstable_yieldValue('Effect');
152+
}, []);
153+
return <Text text="Child" />;
154+
}
155+
156+
const root = ReactNoop.createRoot();
157+
await ReactNoop.act(async () => {
158+
root.render(<App />);
159+
expect(Scheduler).toFlushUntilNextPaint([
160+
'Child',
161+
// Because the passive effect was not the result of a sync update, it
162+
// should not flush before paint.
163+
]);
164+
expect(root).toMatchRenderedOutput('Child');
165+
});
166+
// Effect flushes after paint.
167+
expect(Scheduler).toHaveYielded(['Effect']);
168+
});
100169
});

packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ let useDeferredValue;
3333
let forwardRef;
3434
let memo;
3535
let act;
36+
let ContinuousEventPriority;
3637

3738
describe('ReactHooksWithNoopRenderer', () => {
3839
beforeEach(() => {
@@ -57,6 +58,8 @@ describe('ReactHooksWithNoopRenderer', () => {
5758
useDeferredValue = React.unstable_useDeferredValue;
5859
Suspense = React.Suspense;
5960
act = ReactNoop.act;
61+
ContinuousEventPriority = require('react-reconciler/constants')
62+
.ContinuousEventPriority;
6063

6164
textCache = new Map();
6265

@@ -1351,10 +1354,10 @@ describe('ReactHooksWithNoopRenderer', () => {
13511354
expect(Scheduler).toFlushAndYieldThrough(['Child one render']);
13521355

13531356
// Schedule unmount for the parent that unmounts children with pending update.
1354-
ReactNoop.flushSync(() => {
1357+
ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () => {
13551358
setParentState(false);
13561359
});
1357-
expect(Scheduler).toHaveYielded([
1360+
expect(Scheduler).toFlushUntilNextPaint([
13581361
'Parent false render',
13591362
'Parent false commit',
13601363
]);

packages/react/src/__tests__/ReactProfiler-test.internal.js

+4-7
Original file line numberDiff line numberDiff line change
@@ -3764,13 +3764,10 @@ describe('Profiler', () => {
37643764
);
37653765
});
37663766

3767-
// Profiler tag causes passive effects to be scheduled,
3768-
// so the interactions are still not completed.
3769-
expect(Scheduler).toHaveYielded(['SecondComponent']);
3770-
expect(onInteractionTraced).toHaveBeenCalledTimes(2);
3771-
expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
3772-
expect(Scheduler).toFlushAndYieldThrough(['onPostCommit']);
3773-
3767+
expect(Scheduler).toHaveYielded([
3768+
'SecondComponent',
3769+
'onPostCommit',
3770+
]);
37743771
expect(onInteractionTraced).toHaveBeenCalledTimes(2);
37753772
expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(
37763773
1,

0 commit comments

Comments
 (0)