mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
## Overview
For events, the browser will yield to microtasks between calling event
handers, allowing time to flush work inbetween. For example, in the
browser, this code will log the flushes between events:
```js
<body onclick="console.log('body'); Promise.resolve().then(() => console.log('flush body'));">
<div onclick="console.log('div'); Promise.resolve().then(() => console.log('flush div'));">
hi
</div>
</body>
// Logs
div
flush div
body
flush body
```
[Sandbox](https://codesandbox.io/s/eloquent-noether-mw2cjg?file=/index.html)
The problem is, `dispatchEvent` (either in the browser, or JSDOM) does
not yield to microtasks. Which means, this code will log the flushes
after the events:
```js
const target = document.getElementsByTagName("div")[0];
const nativeEvent = document.createEvent("Event");
nativeEvent.initEvent("click", true, true);
target.dispatchEvent(nativeEvent);
// Logs
div
body
flush div
flush body
```
## The problem
This mostly isn't a problem because React attaches event handler at the
root, and calls the event handlers on components via the synthetic event
system. We handle flushing between calling event handlers as needed.
However, if you're mixing capture and bubbling events, or using multiple
roots, then the problem of not flushing microtasks between events can
come into play. This was found when converting a test to `createRoot` in
https://github.com/facebook/react/pull/28050#discussion_r1462118422, and
that test is an example of where this is an issue with nested roots.
Here's a sandox for
[discrete](https://codesandbox.io/p/sandbox/red-http-2wg8k5) and
[continuous](https://codesandbox.io/p/sandbox/gracious-voice-6r7tsc?file=%2Fsrc%2Findex.js%3A25%2C28)
events, showing how the test should behave. The existing test, when
switched to `createRoot` matches the browser behavior for continuous
events, but not discrete. Continuous events should be batched, and
discrete should flush individually.
## The fix
This PR implements the fix suggested by @sebmarkbage, to manually
traverse the path up from the element and dispatch events, yielding
between each call.
305 lines
8.4 KiB
JavaScript
305 lines
8.4 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*/
|
|
|
|
import * as SchedulerMock from 'scheduler/unstable_mock';
|
|
import {diff} from 'jest-diff';
|
|
import {equals} from '@jest/expect-utils';
|
|
import enqueueTask from './enqueueTask';
|
|
import simulateBrowserEventDispatch from './simulateBrowserEventDispatch';
|
|
|
|
export {act} from './internalAct';
|
|
|
|
function assertYieldsWereCleared(caller) {
|
|
const actualYields = SchedulerMock.unstable_clearLog();
|
|
if (actualYields.length !== 0) {
|
|
const error = Error(
|
|
'The event log is not empty. Call assertLog(...) first.',
|
|
);
|
|
Error.captureStackTrace(error, caller);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function waitForMicrotasks() {
|
|
return new Promise(resolve => {
|
|
enqueueTask(() => resolve());
|
|
});
|
|
}
|
|
|
|
export async function waitFor(expectedLog, options) {
|
|
assertYieldsWereCleared(waitFor);
|
|
|
|
// Create the error object before doing any async work, to get a better
|
|
// stack trace.
|
|
const error = new Error();
|
|
Error.captureStackTrace(error, waitFor);
|
|
|
|
const stopAfter = expectedLog.length;
|
|
const actualLog = [];
|
|
do {
|
|
// Wait until end of current task/microtask.
|
|
await waitForMicrotasks();
|
|
if (SchedulerMock.unstable_hasPendingWork()) {
|
|
SchedulerMock.unstable_flushNumberOfYields(stopAfter - actualLog.length);
|
|
actualLog.push(...SchedulerMock.unstable_clearLog());
|
|
if (stopAfter > actualLog.length) {
|
|
// Continue flushing until we've logged the expected number of items.
|
|
} else {
|
|
// Once we've reached the expected sequence, wait one more microtask to
|
|
// flush any remaining synchronous work.
|
|
await waitForMicrotasks();
|
|
actualLog.push(...SchedulerMock.unstable_clearLog());
|
|
break;
|
|
}
|
|
} else {
|
|
// There's no pending work, even after a microtask.
|
|
break;
|
|
}
|
|
} while (true);
|
|
|
|
if (options && options.additionalLogsAfterAttemptingToYield) {
|
|
expectedLog = expectedLog.concat(
|
|
options.additionalLogsAfterAttemptingToYield,
|
|
);
|
|
}
|
|
|
|
if (equals(actualLog, expectedLog)) {
|
|
return;
|
|
}
|
|
|
|
error.message = `
|
|
Expected sequence of events did not occur.
|
|
|
|
${diff(expectedLog, actualLog)}
|
|
`;
|
|
throw error;
|
|
}
|
|
|
|
export async function waitForAll(expectedLog) {
|
|
assertYieldsWereCleared(waitForAll);
|
|
|
|
// Create the error object before doing any async work, to get a better
|
|
// stack trace.
|
|
const error = new Error();
|
|
Error.captureStackTrace(error, waitForAll);
|
|
|
|
do {
|
|
// Wait until end of current task/microtask.
|
|
await waitForMicrotasks();
|
|
if (!SchedulerMock.unstable_hasPendingWork()) {
|
|
// There's no pending work, even after a microtask. Stop flushing.
|
|
break;
|
|
}
|
|
SchedulerMock.unstable_flushAllWithoutAsserting();
|
|
} while (true);
|
|
|
|
const actualLog = SchedulerMock.unstable_clearLog();
|
|
if (equals(actualLog, expectedLog)) {
|
|
return;
|
|
}
|
|
|
|
error.message = `
|
|
Expected sequence of events did not occur.
|
|
|
|
${diff(expectedLog, actualLog)}
|
|
`;
|
|
throw error;
|
|
}
|
|
|
|
export async function waitForThrow(expectedError: mixed): mixed {
|
|
assertYieldsWereCleared(waitForThrow);
|
|
|
|
// Create the error object before doing any async work, to get a better
|
|
// stack trace.
|
|
const error = new Error();
|
|
Error.captureStackTrace(error, waitForThrow);
|
|
|
|
do {
|
|
// Wait until end of current task/microtask.
|
|
await waitForMicrotasks();
|
|
if (!SchedulerMock.unstable_hasPendingWork()) {
|
|
// There's no pending work, even after a microtask. Stop flushing.
|
|
error.message = 'Expected something to throw, but nothing did.';
|
|
throw error;
|
|
}
|
|
try {
|
|
SchedulerMock.unstable_flushAllWithoutAsserting();
|
|
} catch (x) {
|
|
if (expectedError === undefined) {
|
|
// If no expected error was provided, then assume the caller is OK with
|
|
// any error being thrown. We're returning the error so they can do
|
|
// their own checks, if they wish.
|
|
return x;
|
|
}
|
|
if (equals(x, expectedError)) {
|
|
return x;
|
|
}
|
|
if (
|
|
typeof expectedError === 'string' &&
|
|
typeof x === 'object' &&
|
|
x !== null &&
|
|
typeof x.message === 'string'
|
|
) {
|
|
if (x.message.includes(expectedError)) {
|
|
return x;
|
|
} else {
|
|
error.message = `
|
|
Expected error was not thrown.
|
|
|
|
${diff(expectedError, x.message)}
|
|
`;
|
|
throw error;
|
|
}
|
|
}
|
|
error.message = `
|
|
Expected error was not thrown.
|
|
|
|
${diff(expectedError, x)}
|
|
`;
|
|
throw error;
|
|
}
|
|
} while (true);
|
|
}
|
|
|
|
// This is prefixed with `unstable_` because you should almost always try to
|
|
// avoid using it in tests. It's really only for testing a particular
|
|
// implementation detail (update starvation prevention).
|
|
export async function unstable_waitForExpired(expectedLog): mixed {
|
|
assertYieldsWereCleared(unstable_waitForExpired);
|
|
|
|
// Create the error object before doing any async work, to get a better
|
|
// stack trace.
|
|
const error = new Error();
|
|
Error.captureStackTrace(error, unstable_waitForExpired);
|
|
|
|
// Wait until end of current task/microtask.
|
|
await waitForMicrotasks();
|
|
SchedulerMock.unstable_flushExpired();
|
|
|
|
const actualLog = SchedulerMock.unstable_clearLog();
|
|
if (equals(actualLog, expectedLog)) {
|
|
return;
|
|
}
|
|
|
|
error.message = `
|
|
Expected sequence of events did not occur.
|
|
|
|
${diff(expectedLog, actualLog)}
|
|
`;
|
|
throw error;
|
|
}
|
|
|
|
// TODO: This name is a bit misleading currently because it will stop as soon as
|
|
// React yields for any reason, not just for a paint. I've left it this way for
|
|
// now because that's how untable_flushUntilNextPaint already worked, but maybe
|
|
// we should split these use cases into separate APIs.
|
|
export async function waitForPaint(expectedLog) {
|
|
assertYieldsWereCleared(waitForPaint);
|
|
|
|
// Create the error object before doing any async work, to get a better
|
|
// stack trace.
|
|
const error = new Error();
|
|
Error.captureStackTrace(error, waitForPaint);
|
|
|
|
// Wait until end of current task/microtask.
|
|
await waitForMicrotasks();
|
|
if (SchedulerMock.unstable_hasPendingWork()) {
|
|
// Flush until React yields.
|
|
SchedulerMock.unstable_flushUntilNextPaint();
|
|
// Wait one more microtask to flush any remaining synchronous work.
|
|
await waitForMicrotasks();
|
|
}
|
|
|
|
const actualLog = SchedulerMock.unstable_clearLog();
|
|
if (equals(actualLog, expectedLog)) {
|
|
return;
|
|
}
|
|
|
|
error.message = `
|
|
Expected sequence of events did not occur.
|
|
|
|
${diff(expectedLog, actualLog)}
|
|
`;
|
|
throw error;
|
|
}
|
|
|
|
export async function waitForDiscrete(expectedLog) {
|
|
assertYieldsWereCleared(waitForDiscrete);
|
|
|
|
// Create the error object before doing any async work, to get a better
|
|
// stack trace.
|
|
const error = new Error();
|
|
Error.captureStackTrace(error, waitForDiscrete);
|
|
|
|
// Wait until end of current task/microtask.
|
|
await waitForMicrotasks();
|
|
|
|
const actualLog = SchedulerMock.unstable_clearLog();
|
|
if (equals(actualLog, expectedLog)) {
|
|
return;
|
|
}
|
|
|
|
error.message = `
|
|
Expected sequence of events did not occur.
|
|
|
|
${diff(expectedLog, actualLog)}
|
|
`;
|
|
throw error;
|
|
}
|
|
|
|
export function assertLog(expectedLog) {
|
|
const actualLog = SchedulerMock.unstable_clearLog();
|
|
if (equals(actualLog, expectedLog)) {
|
|
return;
|
|
}
|
|
|
|
const error = new Error(`
|
|
Expected sequence of events did not occur.
|
|
|
|
${diff(expectedLog, actualLog)}
|
|
`);
|
|
Error.captureStackTrace(error, assertLog);
|
|
throw error;
|
|
}
|
|
|
|
// Simulates dispatching events, waiting for microtasks in between.
|
|
// This matches the browser behavior, which will flush microtasks
|
|
// between each event handler. This will allow discrete events to
|
|
// flush between events across different event handlers.
|
|
export async function simulateEventDispatch(
|
|
node: Node,
|
|
eventType: string,
|
|
): Promise<void> {
|
|
// Ensure the node is in the document.
|
|
for (let current = node; current; current = current.parentNode) {
|
|
if (current === document) {
|
|
break;
|
|
} else if (current.parentNode == null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const customEvent = new Event(eventType, {
|
|
bubbles: true,
|
|
});
|
|
|
|
Object.defineProperty(customEvent, 'target', {
|
|
// Override the target to the node on which we dispatched the event.
|
|
value: node,
|
|
});
|
|
|
|
const impl = Object.getOwnPropertySymbols(node)[0];
|
|
const oldDispatch = node[impl].dispatchEvent;
|
|
try {
|
|
node[impl].dispatchEvent = simulateBrowserEventDispatch;
|
|
|
|
await node.dispatchEvent(customEvent);
|
|
} finally {
|
|
node[impl].dispatchEvent = oldDispatch;
|
|
}
|
|
}
|