Skip to content

postmortem · · 7 min

The test that clicked a stale node

A React test failed on CI three times, never locally, always a few milliseconds past its deadline, on branches that did not touch the component. The first fix treated it as a timing stall and helped once. The real mechanism was a click landing on a DOM node the component had already replaced. How the failure was reproduced to the millisecond, and the shape of a test that survives it.

A pull request that changed six files under one feature failed CI on a test for a different feature: a chat panel's booking card. The test had been flaky before — twice, always on CI, always at the wait's deadline plus a few milliseconds. The previous fix had been a general one in the test setup, and it had worked once. This time it did not, and the reason was more interesting than a slow runner.

The failure

terminal
FAIL  crm-cards.test.tsx > the booking card > opens the real /book page, prefit to the panel, from the card
TestingLibraryElementError: Unable to find an element with the title: Book a meeting.
  ... 5289 ms

The test is four lines:

terminal
render(<ChatViewPage />);
fireEvent.click(await screen.findByRole("button", { name: /book a meeting/i }));
const frame = await screen.findByTitle("Book a meeting");
expect(frame).toHaveAttribute("src", expect.stringContaining("/book?slug=meet-sabina"));

Find the card, click it, wait for the iframe. The wait's deadline was five seconds plus a 250 ms re-check; the failure came at 5,289 ms. Locally the file ran fifteen tests in 1.4 seconds, every time.

The first theory, and why it had been right before

The earlier fix had addressed a real mechanism. Under Node, React commits state that arrived outside an event through its scheduler, which runs on setImmediate — the event loop's check phase. Testing Library's waitFor polls on a timer, and its deadline is a timer — the timers phase, which runs first in every loop iteration. When a starved worker process resumes with both due, the poll sees a DOM the commit has not reached, the deadline fires, and the test fails one tick before the element appears. The two earlier failures were at 1,053 ms against a 1,000 ms deadline and 5,040 ms against 5,000 — deadline plus a few milliseconds, the signature exactly.

The fix for that was a wrapper around the wait: on failure, let the check phase run once, then look again briefly. A test that was going to pass now passes; a test that was going to fail still fails, a quarter of a second later.

This failure was at deadline plus 289 ms. The re-check had run, and the frame still was not there. Something other than a late commit.

Read the click handler

The card's click handler is two synchronous state updates:

terminal
function openBooking() {
  setBookingComplete(false);
  setView("booking");
}

Under fireEvent, which wraps the dispatch in act, those commit before fireEvent.click returns. There is no asynchronous step between the click and the iframe. A stalled commit cannot explain a frame that never arrives in five seconds; it could only explain one that arrives late.

What can explain a frame that never arrives: a click that reached nothing. findByRole resolves as soon as the card exists in the DOM — on the home view's first paint. If anything re-renders the card between that moment and the click, the node the test holds is detached. React's event delegation never sees a click on a detached node. No handler runs, no state changes, the frame never renders, and the wait runs to its deadline with the DOM exactly as it was.

Does anything re-render the card after first paint? The panel's config is state, and at least two asynchronous paths replace it after mount — an effect that resolves the panel's configuration, and a message handler for the settings preview. Whether one of them lands in the gap on a starved runner is a question of timing, which is why it never happened on a laptop.

A click on a node the component already replacednaive: find, then click the node you foundfindByRole → node Are-render: A → Bclick(A) · detachedfindByTitle … 5,000 ms … failsno handler runs, nothing changes, the DOM is exactly as it was — for the whole waitfixed: wait for the effect, re-query a live card while it is absentclick(A)frame absent?click(getByRole → B)frame present · assert srcthe handler is idempotent, so a second click on a live node costs nothing and rescues the race

Find-then-click is two reads of a DOM that may change between them. Asserting on the effect and re-querying closes the gap.

The fix

Wait for the frame, and while it is absent, click a live card again. The handler is idempotent — setting the view to "booking" twice is one state — so a second click on a live node is free, and a click on a stale one is recovered:

terminal
async function openBookingFromCard(seed?: HTMLElement) {
  fireEvent.click(seed ?? (await screen.findByRole("button", { name: /book a meeting/i })));
  return waitFor(() => {
    const frame = screen.queryByTitle("Book a meeting");
    if (frame) return frame;
    fireEvent.click(screen.getByRole("button", { name: /book a meeting/i }));
    return screen.getByTitle("Book a meeting");
  }, { timeout: 10_000 });
}

The order inside the callback matters: check for the frame first. After a successful click the home view is gone, so getByRole for the card would throw and the retry would spin until the deadline even though the frame exists. And the seed parameter is the race, injected — a node a caller already holds.

The guard: reproduce the mechanism, not the symptom

A flake fix without a test that fails on the old code is a hope. The companion test builds the exact race: find the card, re-render it out from under that node through the component's own config message — booking off, then on again — confirm the node is detached, and hand the detached node to the helper:

terminal
it("reaches the frame even when the card re-rendered between finding it and clicking it", async () => {
  render(<ChatViewPage />);
  const stale = await screen.findByRole("button", { name: /book a meeting/i });

  postConfig({ ...enabled, booking_enabled: false, booking_slug: null });
  await waitFor(() => expect(screen.queryByRole("button", { name: /book a meeting/i })).not.toBeInTheDocument());
  postConfig(enabled);
  await screen.findByRole("button", { name: /book a meeting/i });
  expect(stale.isConnected).toBe(false);

  const frame = await openBookingFromCard(stale);
  expect(frame).toHaveAttribute("src", expect.stringContaining("/book?slug=meet-sabina"));
});

Then the revert check. With the helper swapped for its naive form — click the seed, wait for the frame — the companion test fails in 5,313 ms. CI had failed in 5,289. Same mechanism, reproduced to within the poll interval, on a laptop, deterministically. With the real helper it passes; the original test passes five runs out of five; the suite is green.

What generalises

  • "Find, then act on what you found" is two reads of a moving DOM. The safe form is: act, then wait for the effect, re-querying the actor if the effect has not appeared. It costs nothing when the first click lands.
  • A deadline-plus-a-few-milliseconds failure has more than one cause. A late commit and a click that never registered look identical from the error message. Read the handler: if it is synchronous, the commit theory is out.
  • Make the guard inject the race. A longer timeout would have hidden this forever. A test that detaches the node on purpose and passes only with the fix is the difference between a fix and a delay.
  • Revert the fix and watch the guard fail. That is how I know the number in the last paragraph is real and not a coincidence.

Working through something like this? I help teams ship AI and cloud systems that hold up, and cost what they should.