diff --git a/packages/internal-test-utils/ReactInternalTestUtils.js b/packages/internal-test-utils/ReactInternalTestUtils.js index 2b7010a06ffeb..3e9a60392d0a6 100644 --- a/packages/internal-test-utils/ReactInternalTestUtils.js +++ b/packages/internal-test-utils/ReactInternalTestUtils.js @@ -218,6 +218,30 @@ ${diff(expectedLog, actualLog)} throw error; } +export async function waitForDiscrete(expectedLog) { + assertYieldsWereCleared(SchedulerMock); + + // 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)) { diff --git a/packages/react-dom/src/__tests__/ReactDOMSafariMicrotaskBug-test.js b/packages/react-dom/src/__tests__/ReactDOMSafariMicrotaskBug-test.js index f4edb90e1fb4a..17b1aed89ce72 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSafariMicrotaskBug-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSafariMicrotaskBug-test.js @@ -13,25 +13,33 @@ let React; let ReactDOMClient; let act; +let assertLog; +let Scheduler; describe('ReactDOMSafariMicrotaskBug-test', () => { let container; - let flushMicrotasksPrematurely; + let overrideQueueMicrotask; + let flushFakeMicrotasks; beforeEach(() => { // In Safari, microtasks don't always run on clean stack. // This setup crudely approximates it. // In reality, the sync flush happens when an iframe is added to the page. // https://github.com/facebook/react/issues/22459 - let queue = []; - window.queueMicrotask = function (cb) { - queue.push(cb); + const originalQueueMicrotask = queueMicrotask; + overrideQueueMicrotask = false; + const fakeMicrotaskQueue = []; + global.queueMicrotask = cb => { + if (overrideQueueMicrotask) { + fakeMicrotaskQueue.push(cb); + } else { + originalQueueMicrotask(cb); + } }; - flushMicrotasksPrematurely = function () { - while (queue.length > 0) { - const prevQueue = queue; - queue = []; - prevQueue.forEach(cb => cb()); + flushFakeMicrotasks = () => { + while (fakeMicrotaskQueue.length > 0) { + const cb = fakeMicrotaskQueue.shift(); + cb(); } }; @@ -40,6 +48,8 @@ describe('ReactDOMSafariMicrotaskBug-test', () => { React = require('react'); ReactDOMClient = require('react-dom/client'); act = require('internal-test-utils').act; + assertLog = require('internal-test-utils').assertLog; + Scheduler = require('scheduler'); document.body.appendChild(container); }); @@ -55,10 +65,14 @@ describe('ReactDOMSafariMicrotaskBug-test', () => { return (
{ + overrideQueueMicrotask = true; if (!ran) { ran = true; setState(1); - flushMicrotasksPrematurely(); + flushFakeMicrotasks(); + Scheduler.log( + 'Content at end of ref callback: ' + container.textContent, + ); } }}> {state} @@ -69,6 +83,7 @@ describe('ReactDOMSafariMicrotaskBug-test', () => { await act(() => { root.render(); }); + assertLog(['Content at end of ref callback: 0']); expect(container.textContent).toBe('1'); }); @@ -78,8 +93,12 @@ describe('ReactDOMSafariMicrotaskBug-test', () => { return ( @@ -95,6 +114,11 @@ describe('ReactDOMSafariMicrotaskBug-test', () => { new MouseEvent('click', {bubbles: true}), ); }); + // This causes the update to flush earlier than usual. This isn't the ideal + // behavior but we use this test to document it. The bug is Safari's, not + // ours, so we just do our best to not crash even though the behavior isn't + // completely correct. + assertLog(['Content at end of click handler: 1']); expect(container.textContent).toBe('1'); }); }); diff --git a/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js b/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js index ccc69785e6bea..104aed13d761f 100644 --- a/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js +++ b/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js @@ -16,6 +16,7 @@ let ReactFeatureFlags; let Scheduler; let act; let waitForAll; +let waitForDiscrete; let assertLog; const setUntrackedChecked = Object.getOwnPropertyDescriptor( @@ -65,6 +66,7 @@ describe('ChangeEventPlugin', () => { const InternalTestUtils = require('internal-test-utils'); waitForAll = InternalTestUtils.waitForAll; + waitForDiscrete = InternalTestUtils.waitForDiscrete; assertLog = InternalTestUtils.assertLog; container = document.createElement('div'); @@ -730,8 +732,7 @@ describe('ChangeEventPlugin', () => { ); // Flush microtask queue. - await null; - assertLog(['render: ']); + await waitForDiscrete(['render: ']); expect(input.value).toBe(''); }); diff --git a/packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js b/packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js index 6cef94bbd35f5..129b79f743144 100644 --- a/packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js +++ b/packages/react-reconciler/src/__tests__/ReactFlushSyncNoAggregateError-test.js @@ -5,6 +5,9 @@ let act; let assertLog; let waitForThrow; +let overrideQueueMicrotask; +let flushFakeMicrotasks; + // TODO: Migrate tests to React DOM instead of React Noop describe('ReactFlushSync (AggregateError not available)', () => { @@ -13,6 +16,26 @@ describe('ReactFlushSync (AggregateError not available)', () => { global.AggregateError = undefined; + // When AggregateError is not available, the errors are rethrown in a + // microtask. This is an implementation detail but we want to test it here + // so override the global one. + const originalQueueMicrotask = queueMicrotask; + overrideQueueMicrotask = false; + const fakeMicrotaskQueue = []; + global.queueMicrotask = cb => { + if (overrideQueueMicrotask) { + fakeMicrotaskQueue.push(cb); + } else { + originalQueueMicrotask(cb); + } + }; + flushFakeMicrotasks = () => { + while (fakeMicrotaskQueue.length > 0) { + const cb = fakeMicrotaskQueue.shift(); + cb(); + } + }; + React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); @@ -47,6 +70,8 @@ describe('ReactFlushSync (AggregateError not available)', () => { const aahh = new Error('AAHH!'); const nooo = new Error('Noooooooooo!'); + // Override the global queueMicrotask so we can test the behavior. + overrideQueueMicrotask = true; let error; try { ReactNoop.flushSync(() => { @@ -70,10 +95,15 @@ describe('ReactFlushSync (AggregateError not available)', () => { // AggregateError is not available, React throws the first error, then // throws the remaining errors in separate tasks. expect(error).toBe(aahh); + // TODO: Currently the remaining error is rethrown in an Immediate Scheduler // task, but this may change to a timer or microtask in the future. The // exact mechanism is an implementation detail; they just need to be logged // in the order the occurred. + + // This will start throwing if we change it to rethrow in a microtask. + flushFakeMicrotasks(); + await waitForThrow(nooo); }); });