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>
Ricky committed
Mar 16, 2021 at 13:15 UTC
89acfa639bcecebdb18276ba2c0f4d5beee2de19
4 files changed
+70
-16
packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js
+14
-14
@@ -201,32 +201,32 @@ describe('React hooks DevTools integration', () => {
201
if (__DEV__) {
202
// First render was locked
203
expect(renderer.toJSON().children).toEqual(['Loading']);
204
- scheduleUpdate(fiber); // Re-render
204
+ act(() => scheduleUpdate(fiber)); // Re-render
205
expect(renderer.toJSON().children).toEqual(['Loading']);
206
207
// Release the lock
208
setSuspenseHandler(() => false);
209
- scheduleUpdate(fiber); // Re-render
209
+ act(() => scheduleUpdate(fiber)); // Re-render
210
expect(renderer.toJSON().children).toEqual(['Done']);
211
- scheduleUpdate(fiber); // Re-render
211
+ act(() => scheduleUpdate(fiber)); // Re-render
212
expect(renderer.toJSON().children).toEqual(['Done']);
213
214
// Lock again
215
setSuspenseHandler(() => true);
216
- scheduleUpdate(fiber); // Re-render
216
+ act(() => scheduleUpdate(fiber)); // Re-render
217
expect(renderer.toJSON().children).toEqual(['Loading']);
218
219
// Release the lock again
220
setSuspenseHandler(() => false);
221
- scheduleUpdate(fiber); // Re-render
221
+ act(() => scheduleUpdate(fiber)); // Re-render
222
expect(renderer.toJSON().children).toEqual(['Done']);
223
224
// Ensure it checks specific fibers.
225
setSuspenseHandler(f => f === fiber || f === fiber.alternate);
226
- scheduleUpdate(fiber); // Re-render
226
+ act(() => scheduleUpdate(fiber)); // Re-render
227
expect(renderer.toJSON().children).toEqual(['Loading']);
228
setSuspenseHandler(f => f !== fiber && f !== fiber.alternate);
229
- scheduleUpdate(fiber); // Re-render
229
+ act(() => scheduleUpdate(fiber)); // Re-render
230
expect(renderer.toJSON().children).toEqual(['Done']);
231
} else {
232
expect(renderer.toJSON().children).toEqual(['Done']);
@@ -259,33 +259,33 @@ describe('React hooks DevTools integration', () => {
259
if (__DEV__) {
260
// First render was locked
261
expect(renderer.toJSON().children).toEqual(['Loading']);
262
- scheduleUpdate(fiber); // Re-render
262
+ act(() => scheduleUpdate(fiber)); // Re-render
263
expect(renderer.toJSON().children).toEqual(['Loading']);
264
265
// Release the lock
266
setSuspenseHandler(() => false);
267
- scheduleUpdate(fiber); // Re-render
267
+ act(() => scheduleUpdate(fiber)); // Re-render
268
Scheduler.unstable_flushAll();
269
expect(renderer.toJSON().children).toEqual(['Done']);
270
- scheduleUpdate(fiber); // Re-render
270
+ act(() => scheduleUpdate(fiber)); // Re-render
271
expect(renderer.toJSON().children).toEqual(['Done']);
272
273
// Lock again
274
setSuspenseHandler(() => true);
275
- scheduleUpdate(fiber); // Re-render
275
+ act(() => scheduleUpdate(fiber)); // Re-render
276
expect(renderer.toJSON().children).toEqual(['Loading']);
277
278
// Release the lock again
279
setSuspenseHandler(() => false);
280
- scheduleUpdate(fiber); // Re-render
280
+ act(() => scheduleUpdate(fiber)); // Re-render
281
expect(renderer.toJSON().children).toEqual(['Done']);
282
283
// Ensure it checks specific fibers.
284
setSuspenseHandler(f => f === fiber || f === fiber.alternate);
285
- scheduleUpdate(fiber); // Re-render
285
+ act(() => scheduleUpdate(fiber)); // Re-render
286
expect(renderer.toJSON().children).toEqual(['Loading']);
287
setSuspenseHandler(f => f !== fiber && f !== fiber.alternate);
288
- scheduleUpdate(fiber); // Re-render
288
+ act(() => scheduleUpdate(fiber)); // Re-render
289
expect(renderer.toJSON().children).toEqual(['Done']);
290
} else {
291
expect(renderer.toJSON().children).toEqual(['Done']);
packages/react-dom/src/__tests__/ReactDOMNativeEventHeuristic-test.js
+48
@@ -281,4 +281,52 @@ describe('ReactDOMNativeEventHeuristic-test', () => {
281
expect(container.textContent).toEqual('hovered');
282
});
283
});
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
+ });
332
});
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+4
-1
@@ -547,7 +547,10 @@ export function scheduleUpdateOnFiber(
547
} else {
548
ensureRootIsScheduled(root, eventTime);
549
schedulePendingInteractions(root, lane);
550
- if (executionContext === NoContext) {
550
+ if (
551
+ executionContext === NoContext &&
552
+ (fiber.mode & ConcurrentMode) === NoMode
553
+ ) {
554
// Flush the synchronous work now, unless we're already working or inside
555
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
556
// scheduleCallbackForFiber to preserve the ability to schedule a callback
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+4
-1
@@ -547,7 +547,10 @@ export function scheduleUpdateOnFiber(
547
} else {
548
ensureRootIsScheduled(root, eventTime);
549
schedulePendingInteractions(root, lane);
550
- if (executionContext === NoContext) {
550
+ if (
551
+ executionContext === NoContext &&
552
+ (fiber.mode & ConcurrentMode) === NoMode
553
+ ) {
554
// Flush the synchronous work now, unless we're already working or inside
555
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
556
// scheduleCallbackForFiber to preserve the ability to schedule a callback