Skip to content

[test] Stabilize flaky browser tests - #5543

Merged
michaldudak merged 9 commits into
mui:masterfrom
michaldudak:claude/test-flakiness-f4c464
Aug 21, 2026
Merged

[test] Stabilize flaky browser tests#5543
michaldudak merged 9 commits into
mui:masterfrom
michaldudak:claude/test-flakiness-f4c464

Conversation

@michaldudak

@michaldudak michaldudak commented Aug 19, 2026

Copy link
Copy Markdown
Member

Fixes the structural causes behind the browser-only test flakes. All of them are invisible in jsdom, which is why they survive local runs — and CI's retry: 1 retries them into a green check, so they never show up in the check status either. The failure data came from CircleCI Insights (/insights/gh/mui/base-ui/flaky-tests and .../workflows/<wf>/test-metrics, both public).

fireEvent silently drops timeStamp

timeStamp is read-only and not a valid EventInit member, so fireEvent.pointerMove(el, { timeStamp: 1050 }) never sets it. jsdom stamps events off the (faked) clock, but real browsers stamp them off the real monotonic clock. Probing both environments with identical input:

env requested actual event.timeStamp
jsdom [1050, 1100] [1000, 1100]
chromium [1050, 1100] [711.899…, 712]

So in the browser these tests weren't simulating the gesture they describe — the velocity math in useSwipeDismiss was reading whatever wall-clock gap the runner happened to leave between two calls. With MAX_RELEASE_VELOCITY_AGE_MS at 80, an 80ms scheduling hiccup silently flips a dismiss decision.

This matches the reported failures: DrawerSwipeArea.test.tsx > allows a later click-only activation to dismiss after a swipe is the top failing test across all three browser jobs (2/82 each), and DrawerViewport.test.tsx > does not dismiss from a fast swipe that was never attributed to the snap point axis reproduced locally on a full run, settling on '200px' instead of '100px'.

firePointer

Adds firePointer to packages/react/test/pointer.ts, exported from #test-utils. It builds the event with createEvent, applies the timestamp with an explicit Object.defineProperty, then dispatches — generalizing what DrawerRoot.test.tsx previously did by hand. A timeStamp is required, and one that isn't > 0 throws rather than falling back: React's synthetic event reads event.timeStamp || Date.now(), and getValidTimeStamp in useSwipeDismiss rejects anything <= 0, so a zero stamp can only reintroduce the real-clock dependency. packages/react/test/pointer.test.tsx covers all three properties.

Two guards keep this from regressing:

  • A no-restricted-syntax rule in eslint.config.mjs rejects timeStamp inside any fireEvent.* init in a test file. Flat config replaces rule options rather than merging them, so the block re-includes the base config's own no-restricted-syntax entries (React namespace imports, throw Error(), the window.setTimeout family) and throws at config load if the extraction ever comes back empty.
  • A note in AGENTS.md.

swipe() now runs on a fixed timeline by default

DrawerSwipeArea.test.tsx's swipe() helper previously applied timestamps only when a caller opted in with timeStepMs; every other pointer swipe inherited the runner's clock. It now defaults to startTimeMs: 1, timeStepMs: 16, so every pointer gesture is timed.

This is the change with the widest blast radius in the PR — roughly twenty tests are re-timed, and in jsdom several move from recording no drag samples at all (identical frozen timestamps meant recordDragSample skipped every one) to recording real velocity. Both environments now exercise the same branch. beforeRelease receives the gesture's timeline so anything it injects stays on the same clock, and the one caller that needed to age a sample past the flick window (slowSwipe) expresses that as timeStepMs: 100 instead of a real await wait(81).

window.PointerEvent = window.MouseEvent removed

jsdom 27 implements PointerEvent, so the beforeAll shim from jsdom#2527 is obsolete — and actively harmful, because it also ran under browser mode, where it cost pointerId and pointerType on every event in the file. Removed from DrawerRoot, DrawerSwipeArea, DrawerViewport, DrawerVirtualKeyboardProvider and useSwipeDismiss tests. Three useSwipeDismiss release-velocity tests that were skipIf(!isJSDOM) now run in the browser too.

SliderRoot.test.tsx keeps the shim, because jsdom still doesn't implement the pointer-capture methods on Element. Its comment now records what the shim actually costs and how to drop it.

waitFor budget consumed by real delays

The two flakiest tests by CircleCI's count are the PreviewCard "any trigger on hover" tests, with p95 durations of 1.00s and 1.02s against testing-library's 1000ms waitFor budget. Each runs three sequential real 300ms CLOSE_DELAY waits, every one consuming ~315ms of a 1000ms budget.

They assert which trigger opens the card, not close timing, so closeDelay={0} removes the timing race entirely. PreviewCardRoot.detached-triggers.test.tsx goes 7833ms → 5881ms.

Missing pointer guard

TooltipRoot.detached-triggers.test.tsx, TooltipProvider.test.tsx and TooltipTrigger.test.tsx lacked the beforeEach(resetBrowserPointer) that every sibling popup test file has. All appear in the CI flaky data, and TooltipProvider failed once locally — a fake-timer test failing only in the browser, which points at the real pointer, the one input fake timers can't control.

Benchmark determinism

The real cursor persists across a benchmark run and can come to rest over one of the 300 tooltip triggers in tooltip.bench.tsx, opening a tooltip on its own. That adds ~9 render passes which land inside the measurement window only some of the time, so iterations stop matching and the harness's equality check fails. Mount is what these benchmarks measure, so the triggers never need to be hoverable: pointerEvents: 'none' is set on the container (not per trigger, to keep React from writing an inline style onto all 300 nodes inside the timed path) and the unused delay={0} is dropped. mixed.bench.tsx gets the same treatment; MountList in shared.tsx grows an optional style prop to allow it.

Verification

  • Full jsdom suite + 2× full chromium suite clean (8736 tests)
  • 15/15 clean repeats on the changed areas (927 tests each)
  • pnpm typescript, pnpm eslint, pnpm prettier clean
  • Side benefit, measured on one machine: DrawerSwipeArea.test.tsx 443ms → 248ms in chromium, from no longer waiting on real wall-clock gaps. DrawerViewport.test.tsx is unchanged within noise — its gestures were already driven by vi.setSystemTime, so there was no real sleep to remove, only bookkeeping.

Follow-up, not in this PR

Velocity-sensitive gestures that never specified a timestamp keep the same exposure, since there was no written-down timeline to honor — fixing them means choosing one per test, which is a semantic change better made deliberately, probably alongside a shared swipe-gesture helper. Two groups remain:

  • Touch, ~130 call sites across DrawerSwipeArea.test.tsx and DrawerViewport.test.tsx. firePointer has no touch equivalent yet. The touch flake reproduced 0/25 in isolation and only surfaced once under full-suite contention.
  • Untimed pointer, 18 gesture releases still fired through bare fireEvent.pointer* — 9 in DrawerViewport.test.tsx, 6 in useSwipeDismiss.test.tsx, 3 in DrawerSwipeArea.test.tsx. DrawerViewport.test.tsx > navigates snap points from a drag that was never attributed to the swipe axis is the clearest case: it is the direct sibling of a test fixed here, asserts a snap-point outcome that depends on release velocity, and fires five untimed events. Note the new lint rule cannot see these — it only fires when a timeStamp is present — so they need finding by hand or a broader rule.

🤖 Generated with Claude Code

Fixes the two structural causes behind the browser-only flakes reported by
CircleCI Insights. Both are invisible in jsdom, and CI's `retry: 1` hides them
from the check status.

`timeStamp` is read-only and not a valid `EventInit` member, so passing it to
`fireEvent` never set it. jsdom stamps events off the (faked) clock, but real
browsers stamp them off the real monotonic clock, so the swipe velocity math in
`useSwipeDismiss` was reading whatever wall-clock gap the runner happened to
leave between two calls rather than the timeline the test describes. Requesting
`[1050, 1100]` yielded `[1000, 1100]` in jsdom but `[711.9, 712]` in chromium.
With `MAX_RELEASE_VELOCITY_AGE_MS` at 80, an 80ms scheduling hiccup flips a
dismiss decision. Adds `firePointer`, which applies the timestamp explicitly,
generalizing the approach `DrawerRoot.test.tsx` already took by hand.

The PreviewCard hover tests ran three sequential real 300ms close delays, each
consuming ~315ms of testing-library's 1000ms `waitFor` budget; their p95 on CI
sat at 1.00s and 1.02s. They assert which trigger opens the card, not close
timing, so `closeDelay={0}` removes the timing race.

Adds the `resetBrowserPointer` guard that sibling popup test files already have
to two tooltip files that were missing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Aug 19, 2026

Copy link
Copy Markdown

commit: 711fe26

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 19, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 0B(0.00%) 0B(0.00%)

Details of bundle changes

Performance

Total duration: 1,092.00 ms +26.67 ms(+2.5%) | Renders: 76 (▼-16) | Paint: 1,762.58 ms +6.09 ms(+0.3%)

Test Duration Renders
Tooltip mount (300 contained roots) 50.26 ms 🔺+18.04 ms(+56.0%) 1 (▼-9)
Mixed surface mount (app-like density) 73.80 ms +1.95 ms(+2.7%) 5 (▼-7)

13 tests within noise — details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 711fe26
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a88312ab49b32000879189e
😎 Deploy Preview https://deploy-preview-5543--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

`Benchmark tests` fails intermittently on unrelated PRs with

  Tooltip mount (300 contained roots)
  AssertionError: Iteration N render events differ from iteration 0:
    expected [ 'bench:mount' ] to deeply equal [ 'bench:mount', 'bench:update', ...(8) ]

seen at iterations 10, 11 and 16 on three different branches, and once in the
baseline step that runs merge-base code.

The benchmark rendered 300 hoverable `delay={0}` triggers. The real Playwright
cursor persists across a run, and when it comes to rest over one of them the
tooltip opens by itself, adding ~9 render passes for the open cascade. The
harness closes its recording window on the first paint entry, so those passes
land inside the window only some of the time and iterations stop matching.

Measured locally: cursor away 1 render, cursor parked over the trigger area 10
renders — the same count CI reports — and 1 render again with the triggers made
inert. `delay={0}` is dropped along with it; a mount benchmark never hovers.

Render counts are unchanged on an uncontaminated run (mixed stays at 5), so this
removes the interference without altering what is measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michaldudak
michaldudak marked this pull request as ready for review August 20, 2026 07:50
michaldudak and others added 7 commits August 20, 2026 09:50
The first pass converted only call sites matching `timeStamp:`, which missed
`swipe()` in DrawerSwipeArea.test.tsx — it passes the stamp as shorthand
(`...(useTimeStamp ? { timeStamp } : null)`), so both the rewrite and the
leftover check skipped it. That left `allows a later click-only activation to
dismiss after a swipe`, cited as the top CI flake, still deriving release
velocity from real wall-clock gaps. `swipe()` now routes through `firePointer`,
and the timeline is no longer opt-in: pointer swipes default to a 1ms start with
16ms steps, so every `swipeUp`/`swipeLeft` caller gets a fixed timeline instead
of only the two that passed `timeStepMs`.

`uses a size-based swipe threshold by default` aged its drag sample past
`MAX_RELEASE_VELOCITY_AGE_MS` with a real `wait(81)` — an 81ms sleep clearing an
80ms threshold by one millisecond. It now steps the gesture at 100ms on its own
timeline.

React's synthetic event reads `event.timeStamp || Date.now()`, and
`useSwipeDismiss` reads the synthetic event, so a zero stamp reached handlers as
wall-clock time; `getValidTimeStamp` rejects `<= 0` anyway. `firePointer` now
throws on non-positive stamps, and the three `timeStamp: 0` sites start at 1.

Also folds `simulateTimestampedPointerSwipe` and `simulateTimedSwipe` into one
`firePointer`-based helper, dropping the browser branch's real sleeps, and adds
the pointer guard to TooltipTrigger.test.tsx.

The benchmark fix moves `pointer-events: none` off the 300/200 individual
triggers and onto the containers, keeping per-row style objects out of the mount
path being timed. Verified still 1 render with the cursor parked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
simulateTimedRightSwipe and simulateTimedDownSwipe were missed in the previous
pass. Their jsdom branches were unreachable — every caller is skipIf(isJSDOM)
with useFakeTimers = isJSDOM — so the nine browser-only tests using them ran the
untimed branch, sleeping on the wall clock while their startTime/moveTime/endTime
arguments went unread. Both now build a step list and delegate to
simulateTimedSwipe, which drops the last real sleeps from the file:
DrawerRoot.test.tsx goes from ~2017ms to 422ms in chromium.

A canary throw confirmed simulateTimedSwipe's `vi.isFakeTimers()` branch is dead
in both environments, so it is removed rather than left as decoration.

Nothing pinned the helper's behaviour, so a future @testing-library or React
change — or a well-meaning simplification back to `fireEvent[type](element,
init)` — would silently return all 53 call sites to real-clock timing with every
test still green. test/pointer.test.tsx asserts a React handler observes the
requested stamps, and that a non-positive stamp throws. Verified it fails on that
exact regression: jsdom reports epoch values and chromium reports [297.5, 298,
298] against the expected [1000, 1050, 1100].

`timeStamp` is now required rather than optional, so omitting it is a compile
error instead of a silent return to the runner's clock. Typecheck is unchanged
(77 pre-existing errors before and after), confirming all call sites pass one.

`useTimeStamp` in swipe() was constant once the touch branch returns early, so
the dead spreads and guards are inlined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the 30 per-event `vi.setSystemTime` calls interleaved with `firePointer`
in DrawerViewport.test.tsx and useSwipeDismiss.test.tsx. They were the mechanism
that made these gestures work under jsdom while `timeStamp` was being dropped;
now that the stamp is applied explicitly, they state the timeline a second time
without feeding anything. The pre-render base in each test is kept.

Confirmed inert rather than assumed: collapsing every `setSystemTime` in both
files to a single constant, leaving the `timeStamp` values untouched, kept all
117 tests green in both environments. Had the gesture logic read the wall clock,
that collapsed timeline would have changed the outcomes.

This also settles a disagreement from the previous round in the reviewer's
favour. The objection raised there — that `useDismiss.ts` and `safePolygon.ts`
read the clock — does not reach these files: the drawer never uses
`safePolygon`, and the `Date.now()` in `useDismiss` sits behind
`event.touches[0]`, which pointer gestures do not take. It also removes an
inconsistency this PR introduced, having stripped the same pattern from
DrawerRoot.test.tsx while leaving it in two sibling files.

The benchmark comments claimed per-trigger styling would allocate 300 style
objects. `INERT_TO_POINTER` is module-level, so it would pass one shared object
300 times; the real cost is React writing an inline style onto 300 nodes inside
the timed path. Reworded to say that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replacing simulateTimestampedPointerSwipe with firePointer regressed twelve
drawer tests. The old helper set every field via Object.defineProperties, which
bypassed the constructor; firePointer routes them through createEvent, and an
unconditional `beforeAll` shim replaced window.PointerEvent with MouseEvent —
so `pointerId` and `pointerType` were dropped. Probed through a React handler:
with the shim both read `undefined` in jsdom *and* chromium, without it both
deliver `pointerId: 1, pointerType: 'mouse'`. jsdom implements PointerEvent now,
so the jsdom#2527 workaround is stale and was degrading the browser run too. It
is removed from all four files that carried it.

Nothing failed today because every consumer keys on 'touch' or 'pen' and
`undefined` falls the same way as 'mouse', but a positive `=== 'mouse'` branch
would have been silently untested.

`beforeRelease` had no way to advance the gesture timeline, so the moves it fires
were stamped off the real clock — landing out of order against the synthetic
timestamps and skipping the release-velocity refinement at useSwipeDismiss.ts:786.
It now receives `nextTimeStamp()`. Measured end to end, the gesture is
[1, 17, 33, 49, 65, 81] rather than [1, 17, 397.5, 49].

A no-restricted-syntax rule now rejects `timeStamp` inside a `fireEvent.*` init
and points at firePointer, so the original footgun is unavailable rather than
merely fixed once.

Also drops the five `useFakeTimers = isJSDOM` blocks, unreachable inside
it.skipIf(isJSDOM) bodies, and the beforeAll imports left unused by the shim
removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `no-restricted-syntax` entry added for `fireEvent`/`timeStamp` replaced the
base config's list rather than extending it — flat config overwrites rule
options — so every `**/*.test.*` file silently lost 11 shared restrictions,
including the React namespace-import rule and the `window.setTimeout` family
that AGENTS.md mandates. Verified with `eslint --print-config`: 11 entries
before, 1 after, 12 now. The base entries are re-included, and extracting zero
of them throws rather than quietly dropping the list again.

Three release-velocity tests were jsdom-only because they needed
`vi.setSystemTime` to control event timing. `firePointer` removed that
dependency, so they now run in both environments — the velocity contract this
work exists to stabilise was still only asserted in jsdom.

pointer.test.tsx asserted `timeStamp` but not the fields that actually
regressed. It now pins the whole init; confirmed it fails on the real
regression by re-adding the PointerEvent shim, which reports `pointerId` and
`pointerType` as undefined in both environments.

The three PreviewCard hover assertions are synchronous again. With `delay={0}`,
useHover.ts calls `setOpen` inline rather than through a timeout, so the bare
assertion is a real guarantee about immediate opening that `waitFor` cannot
distinguish from opening a tick later. The flake was the close path, which
`closeDelay={0}` already fixed.

Also: drops the eight remaining pre-render `vi.setSystemTime` calls; makes
`nextTimeStamp` throw on the touch path, where no event carries it, rather than
handing out a timeline that silently does nothing; rewords the lint message,
which fires on touch events too, to name the constraint instead of a
pointer-only helper; documents `firePointer` in AGENTS.md.

The PointerEvent shim is gone from DrawerVirtualKeyboardProvider too. It stays
in SliderRoot, where it turns out to be load-bearing for a reason its comment
did not state: jsdom implements PointerEvent but not the pointer capture
methods. Comment corrected to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michaldudak
michaldudak merged commit 087c60b into mui:master Aug 21, 2026
23 checks passed
@michaldudak
michaldudak deleted the claude/test-flakiness-f4c464 branch August 21, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant