Skip to content

Commit 89acfa6

Browse files
Fix native event batching in concurrent mode (#21010)
* Fix native event batching in concurrent mode * Wrap DevTools test updates with act These tests expect the `scheduleUpdate` DevTools hook to trigger a synchronous re-render with legacy semantics, but flushing in a microtask is fine. Wrapping the updates with `act` fixes it. * Testing nits * Nit: Check executionContext === NoContext first In the common case it will be false and the binary expression will short circuit. Co-authored-by: Andrew Clark <git@andrewclark.io>
1 parent 0203b65 commit 89acfa6

File tree

4 files changed

+70
-16
lines changed

4 files changed

+70
-16
lines changed

packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -201,32 +201,32 @@ describe('React hooks DevTools integration', () => {
201201
if (__DEV__) {
202202
// First render was locked
203203
expect(renderer.toJSON().children).toEqual(['Loading']);
204-
scheduleUpdate(fiber); // Re-render
204+
act(() => scheduleUpdate(fiber)); // Re-render
205205
expect(renderer.toJSON().children).toEqual(['Loading']);
206206

207207
// Release the lock
208208
setSuspenseHandler(() => false);
209-
scheduleUpdate(fiber); // Re-render
209+
act(() => scheduleUpdate(fiber)); // Re-render
210210
expect(renderer.toJSON().children).toEqual(['Done']);
211-
scheduleUpdate(fiber); // Re-render
211+
act(() => scheduleUpdate(fiber)); // Re-render
212212
expect(renderer.toJSON().children).toEqual(['Done']);
213213

214214
// Lock again
215215
setSuspenseHandler(() => true);
216-
scheduleUpdate(fiber); // Re-render
216+
act(() => scheduleUpdate(fiber)); // Re-render
217217
expect(renderer.toJSON().children).toEqual(['Loading']);
218218

219219
// Release the lock again
220220
setSuspenseHandler(() => false);
221-
scheduleUpdate(fiber); // Re-render
221+
act(() => scheduleUpdate(fiber)); // Re-render
222222
expect(renderer.toJSON().children).toEqual(['Done']);
223223

224224
// Ensure it checks specific fibers.
225225
setSuspenseHandler(f => f === fiber || f === fiber.alternate);
226-
scheduleUpdate(fiber); // Re-render
226+
act(() => scheduleUpdate(fiber)); // Re-render
227227
expect(renderer.toJSON().children).toEqual(['Loading']);
228228
setSuspenseHandler(f => f !== fiber && f !== fiber.alternate);
229-
scheduleUpdate(fiber); // Re-render
229+
act(() => scheduleUpdate(fiber)); // Re-render
230230
expect(renderer.toJSON().children).toEqual(['Done']);
231231
} else {
232232
expect(renderer.toJSON().children).toEqual(['Done']);
@@ -259,33 +259,33 @@ describe('React hooks DevTools integration', () => {
259259
if (__DEV__) {
260260
// First render was locked
261261
expect(renderer.toJSON().children).toEqual(['Loading']);
262-
scheduleUpdate(fiber); // Re-render
262+
act(() => scheduleUpdate(fiber)); // Re-render
263263
expect(renderer.toJSON().children).toEqual(['Loading']);
264264

265265
// Release the lock
266266
setSuspenseHandler(() => false);
267-
scheduleUpdate(fiber); // Re-render
267+
act(() => scheduleUpdate(fiber)); // Re-render
268268
Scheduler.unstable_flushAll();
269269
expect(renderer.toJSON().children).toEqual(['Done']);
270-
scheduleUpdate(fiber); // Re-render
270+
act(() => scheduleUpdate(fiber)); // Re-render
271271
expect(renderer.toJSON().children).toEqual(['Done']);
272272

273273
// Lock again
274274
setSuspenseHandler(() => true);
275-
scheduleUpdate(fiber); // Re-render
275+
act(() => scheduleUpdate(fiber)); // Re-render
276276
expect(renderer.toJSON().children).toEqual(['Loading']);
277277

278278
// Release the lock again
279279
setSuspenseHandler(() => false);
280-
scheduleUpdate(fiber); // Re-render
280+
act(() => scheduleUpdate(fiber)); // Re-render
281281
expect(renderer.toJSON().children).toEqual(['Done']);
282282

283283
// Ensure it checks specific fibers.
284284
setSuspenseHandler(f => f === fiber || f === fiber.alternate);
285-
scheduleUpdate(fiber); // Re-render
285+
act(() => scheduleUpdate(fiber)); // Re-render
286286
expect(renderer.toJSON().children).toEqual(['Loading']);
287287
setSuspenseHandler(f => f !== fiber && f !== fiber.alternate);
288-
scheduleUpdate(fiber); // Re-render
288+
act(() => scheduleUpdate(fiber)); // Re-render
289289
expect(renderer.toJSON().children).toEqual(['Done']);
290290
} else {
291291
expect(renderer.toJSON().children).toEqual(['Done']);

packages/react-dom/src/__tests__/ReactDOMNativeEventHeuristic-test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,4 +281,52 @@ describe('ReactDOMNativeEventHeuristic-test', () => {
281281
expect(container.textContent).toEqual('hovered');
282282
});
283283
});
284+
285+
// @gate experimental
286+
it('should batch inside native events', async () => {
287+
const root = ReactDOM.unstable_createRoot(container);
288+
289+
const target = React.createRef(null);
290+
function Foo() {
291+
const [count, setCount] = React.useState(0);
292+
const countRef = React.useRef(-1);
293+
294+
React.useLayoutEffect(() => {
295+
countRef.current = count;
296+
target.current.onclick = () => {
297+
setCount(countRef.current + 1);
298+
// Now update again. If these updates are batched, then this should be
299+
// a no-op, because we didn't re-render yet and `countRef` hasn't
300+
// been mutated.
301+
setCount(countRef.current + 1);
302+
};
303+
});
304+
return <div ref={target}>Count: {count}</div>;
305+
}
306+
307+
await act(async () => {
308+
root.render(<Foo />);
309+
});
310+
expect(container.textContent).toEqual('Count: 0');
311+
312+
// Ignore act warning. We can't use act because it forces batched updates.
313+
spyOnDev(console, 'error');
314+
315+
const pressEvent = document.createEvent('Event');
316+
pressEvent.initEvent('click', true, true);
317+
dispatchAndSetCurrentEvent(target.current, pressEvent);
318+
// If this is 2, that means the `setCount` calls were not batched.
319+
expect(container.textContent).toEqual('Count: 1');
320+
321+
// Assert that the `act` warnings were the only ones that fired.
322+
if (__DEV__) {
323+
expect(console.error).toHaveBeenCalledTimes(2);
324+
expect(console.error.calls.argsFor(0)[0]).toContain(
325+
'was not wrapped in act',
326+
);
327+
expect(console.error.calls.argsFor(1)[0]).toContain(
328+
'was not wrapped in act',
329+
);
330+
}
331+
});
284332
});

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,10 @@ export function scheduleUpdateOnFiber(
547547
} else {
548548
ensureRootIsScheduled(root, eventTime);
549549
schedulePendingInteractions(root, lane);
550-
if (executionContext === NoContext) {
550+
if (
551+
executionContext === NoContext &&
552+
(fiber.mode & ConcurrentMode) === NoMode
553+
) {
551554
// Flush the synchronous work now, unless we're already working or inside
552555
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
553556
// scheduleCallbackForFiber to preserve the ability to schedule a callback

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,10 @@ export function scheduleUpdateOnFiber(
547547
} else {
548548
ensureRootIsScheduled(root, eventTime);
549549
schedulePendingInteractions(root, lane);
550-
if (executionContext === NoContext) {
550+
if (
551+
executionContext === NoContext &&
552+
(fiber.mode & ConcurrentMode) === NoMode
553+
) {
551554
// Flush the synchronous work now, unless we're already working or inside
552555
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
553556
// scheduleCallbackForFiber to preserve the ability to schedule a callback

0 commit comments

Comments
 (0)