fix(tests): prevent jest hangs caused by MessageChannel-mocked React scheduler (#39957)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-05-08 14:27:03 -07:00
committed by GitHub
parent cfb0b6e811
commit e934f2af92
14 changed files with 48 additions and 28 deletions

View File

@@ -1160,7 +1160,7 @@ test('does not fire onChange if the same value is selected in single mode', asyn
// Reference for the bug this tests: https://github.com/apache/superset/pull/33043#issuecomment-2809419640
test('typing and deleting the last character for a new option displays correctly', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
render(<Select {...defaultProps} allowNewOptions />);
await open();

View File

@@ -53,7 +53,7 @@ describe('QueryAutoRefresh', () => {
const refreshApi = 'glob:*/api/v1/query/updated_since?*';
beforeEach(() => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
});
afterEach(() => {

View File

@@ -45,7 +45,7 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
dataset_count: 3,
});
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
// Mock the user
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');

View File

@@ -611,7 +611,7 @@ test('should refresh the charts', async () => {
});
test('auto-refresh uses onRefresh with skipped filters and toggles refresh state', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
onRefresh.mockResolvedValue(undefined);
const originalRequestAnimationFrame = window.requestAnimationFrame;

View File

@@ -39,7 +39,7 @@ import FilterBar from '.';
import { FILTERS_CONFIG_MODAL_TEST_ID } from '../FiltersConfigModal/FiltersConfigModal';
import * as dataMaskActions from 'src/dataMask/actions';
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),

View File

@@ -31,7 +31,7 @@ describe('FilterScope TreeInitialization', () => {
let formRef: { current: FormInstance | null };
beforeEach(() => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
formRef = { current: null };
});

View File

@@ -31,7 +31,7 @@ describe('FilterScope TreeSelection', () => {
let formRef: { current: FormInstance | null };
beforeEach(() => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
formRef = { current: null };
});

View File

@@ -50,7 +50,7 @@ const TIME_GRAIN_TUPLES: [string, string][] = [
// "state update on unmounted component" warnings. Scoped fake timers let us
// clear pending work deterministically during teardown for this test only.
beforeEach(() => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
});
afterEach(() => {

View File

@@ -28,7 +28,7 @@ import {
import { CustomFrame } from '../components';
const TODAY = '2024-06-03';
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
jest.setSystemTime(new Date(TODAY).getTime());
const emptyValue = '';

View File

@@ -45,7 +45,7 @@ import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index';
// Mock scrollIntoView to avoid errors in test environment
jest.mock('scroll-into-view-if-needed', () => jest.fn());
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
class MainPreset extends Preset {
constructor() {

View File

@@ -62,7 +62,7 @@ const props = {
beforeEach(() => {
mockJsonFormsChangeTriggered = false;
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
mockedGet.mockReset();
mockedPost.mockReset();

View File

@@ -37,7 +37,7 @@ import {
PluginFilterSelectQueryFormData,
} from './types';
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const selectMultipleProps = {
formData: {
@@ -1286,7 +1286,7 @@ test('resets dependent filter to first item when value does not exist in data',
});
test('renders text input instead of dropdown when operatorType is ILIKE contains', () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1316,7 +1316,7 @@ test('renders text input instead of dropdown when operatorType is ILIKE contains
});
test('renders text input with starts-with placeholder', () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.StartsWith },
@@ -1345,7 +1345,7 @@ test('renders text input with starts-with placeholder', () => {
});
test('typing in LIKE input calls setDataMask with ILIKE Contains payload', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1391,7 +1391,7 @@ test('typing in LIKE input calls setDataMask with ILIKE Contains payload', async
});
test('typing in LIKE input with inverse selection calls setDataMask with NOT ILIKE payload', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: {
@@ -1440,7 +1440,7 @@ test('typing in LIKE input with inverse selection calls setDataMask with NOT ILI
});
test('clear-all resets LIKE input value and calls setDataMask with empty state', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1506,7 +1506,7 @@ test('clear-all resets LIKE input value and calls setDataMask with empty state',
});
test('pending LIKE debounce still applies after rerender recreates updateDataMask', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1573,7 +1573,7 @@ test('pending LIKE debounce still applies after rerender recreates updateDataMas
});
test('pending LIKE debounce is canceled when operatorType switches back to Exact', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1628,7 +1628,7 @@ test('pending LIKE debounce is canceled when operatorType switches back to Exact
});
test('renders standard Select dropdown when operatorType is Exact', () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Exact },

View File

@@ -42,15 +42,16 @@ beforeEach(() => {
});
afterEach(async () => {
// Restore real timers FIRST so the flush below uses real setTimeout,
// preventing a deadlock if a test threw while fake timers were active.
jest.useRealTimers();
// Flush pending React state updates within act() to prevent warnings
// and "document global undefined" errors from async operations
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
// Restore real timers in case a test using fake timers threw early
jest.useRealTimers();
// Reset browser history state to prevent query params leaking between tests
window.history.replaceState({}, '', '/');

View File

@@ -115,6 +115,12 @@ type LaunchQueue = {
) => void;
};
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
// the component's useEffect — calling it inline deadlocks Jest because the
// MessageChannel mock in jsDomWithFetchAPI forces React to schedule via setTimeout.
const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
let savedConsumer:
| ((params: { files?: MockFileHandle[] }) => void | Promise<void>)
@@ -123,7 +129,11 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => {
savedConsumer = consumer;
if (fileHandle) {
consumer({ files: [fileHandle] });
const id = setTimeout(() => {
pendingTimerIds.delete(id);
consumer({ files: [fileHandle] });
}, 0);
pendingTimerIds.add(id);
}
},
};
@@ -132,25 +142,34 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
// In slower CI runners, useEffect may not have registered the consumer yet.
// Wait briefly for it before triggering.
let attempts = 0;
while (!savedConsumer && attempts < 50) {
while (!savedConsumer && attempts < MAX_CONSUMER_POLL_ATTEMPTS) {
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => {
setTimeout(resolve, 0);
});
attempts += 1;
}
await savedConsumer?.(params);
if (!savedConsumer) {
throw new Error(
`LaunchQueue consumer was never registered after ${MAX_CONSUMER_POLL_ATTEMPTS} polling attempts`,
);
}
await savedConsumer(params);
},
};
};
beforeEach(() => {
jest.clearAllMocks();
delete (window as any).launchQueue;
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
.launchQueue;
});
afterEach(() => {
delete (window as any).launchQueue;
pendingTimerIds.forEach(id => clearTimeout(id));
pendingTimerIds.clear();
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
.launchQueue;
});
test('shows error when launchQueue is not supported', async () => {