⏱️ test: Make Active Item Observer Assertions Deterministic (#15147)

The two attribute-flip tests mutated inside act() and then raced a 4 second
waitFor against MutationObserver delivery, so they failed once the client
workspace gained enough suites for a worker to stall past that budget.

Wait on actual observer delivery instead. The hook registers its observer on
mount, so it is ahead of the test's in delivery order and has already reacted
by the time the promise resolves. The new helper filters on data-active-item
because React writes data-active onto the same element when it re-renders, and
an unfiltered observer would resolve on that write instead.

This removes the last wall-clock dependence in the file, so the 20 second
jest timeout is no longer needed.
This commit is contained in:
Marco Beretta 2026-08-24 05:16:14 +02:00 committed by GitHub
parent 0a9cf6c1f6
commit 4d8f145526
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -2,7 +2,7 @@
* @jest-environment @happy-dom/jest-environment
*/
import React from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import useIsActiveItem from '../useIsActiveItem';
@ -11,18 +11,25 @@ function Probe() {
return <div ref={ref} data-testid="probe" data-active={isActive ? 'true' : 'false'} />;
}
/**
* Observer delivery lands on the next microtask in this environment; the wait exists so the
* test does not depend on that scheduling detail, with budget to ride out a stalled host.
*/
const OBSERVER_WAIT = { timeout: 4000 };
/** One test chains two OBSERVER_WAITs, whose budget exceeds Jest's 5s default. */
jest.setTimeout(20_000);
const getProbe = (container: HTMLElement) =>
container.querySelector('[data-testid="probe"]') as HTMLDivElement;
/**
* Resolves once a `data-active-item` mutation has been delivered to observers. The hook
* registers its observer on mount, so it is always ahead of this one in delivery order,
* which means the hook has already reacted by the time this resolves. Filtering matters:
* React writes `data-active` onto the same element when it re-renders, and an unfiltered
* observer would resolve on that write instead of on the attribute under test.
*/
const nextActiveItemMutation = (element: HTMLElement): Promise<void> =>
new Promise((resolve) => {
const observer = new MutationObserver(() => {
observer.disconnect();
resolve();
});
observer.observe(element, { attributes: true, attributeFilter: ['data-active-item'] });
});
/**
* Resolves once an attribute mutation on `element` has been delivered to observers. The
* hook's observer filters on `data-active-item`, so it is never notified of the unrelated
@ -48,26 +55,32 @@ describe('useIsActiveItem', () => {
const { container } = render(<Probe />);
const probe = getProbe(container);
act(() => {
const delivered = nextActiveItemMutation(probe);
await act(async () => {
probe.setAttribute('data-active-item', '');
await delivered;
});
await waitFor(() => expect(probe.getAttribute('data-active')).toBe('true'), OBSERVER_WAIT);
expect(probe.getAttribute('data-active')).toBe('true');
});
it('flips isActive back to false when data-active-item is removed', async () => {
const { container } = render(<Probe />);
const probe = getProbe(container);
act(() => {
const added = nextActiveItemMutation(probe);
await act(async () => {
probe.setAttribute('data-active-item', '');
await added;
});
await waitFor(() => expect(probe.getAttribute('data-active')).toBe('true'), OBSERVER_WAIT);
expect(probe.getAttribute('data-active')).toBe('true');
act(() => {
const removed = nextActiveItemMutation(probe);
await act(async () => {
probe.removeAttribute('data-active-item');
await removed;
});
await waitFor(() => expect(probe.getAttribute('data-active')).toBe('false'), OBSERVER_WAIT);
expect(probe.getAttribute('data-active')).toBe('false');
});
it('ignores unrelated attribute mutations', async () => {