Skip to content

setIframeViewport awaits an animation frame with no timeout, hanging tests when the orchestrator page is not composited #10831

Description

@alvinycheung

Describe the bug

page.viewport() can hang forever. The wait bottoms out in the orchestrator's setIframeViewport, which applies the new size synchronously and then awaits a single animation frame before replying viewport:done:

await new Promise(r => requestAnimationFrame(r))

Chromium only runs animation frames for a page it is compositing. When several browser contexts share one browser instance, most orchestrator pages are backgrounded and that frame can simply never arrive. There is no timeout anywhere on the path — page.viewport() resolves only on the viewport:done reply — so the test sits until the suite's testTimeout kills it.

The three links, read from installed dist sources at @vitest/browser@4.1.1:

  1. @vitest/browser/dist/context.jspage.viewport() posts a BroadcastChannel message and returns a promise that settles only on viewport:done. No timeout.
  2. @vitest/browser/dist/client/__vitest_browser__/orchestrator-*.js — the handler calls setIframeViewport, which resizes synchronously, then awaits one animation frame before posting viewport:done.
  3. The resize itself has already been applied by the time the frame is awaited — the await only lets it settle.

What makes this hard to diagnose

The failure has a distinctive and misleading fingerprint:

  • It is binary, not slow — one second versus forever, so it does not look like a performance problem.
  • The stack frame points at user code that never ran. The await lives inside the caller, so the report blames whichever test was next in line. In our case that was an arbitrary story with no interaction and no viewport parameter.
  • It never reproduces in isolation. Running the one failing file alone means one browser context, and that page is the visible, composited one. This wastes a lot of debugging time — the file passes 100% alone and fails intermittently in the full suite.
  • Any diagnostic that drives requestAnimationFrame inside the test iframe masks it, because that keeps the parent compositing. We hit this: our first instrumentation made the bug disappear and changed sub-pixel layout results.

We spent a long time treating this as test flake before finding it.

How it surfaced for us

A Storybook catalog of 1141 browser-mode tests, ~1 run in 10 failing with a 20s timeout on an arbitrary test.

@storybook/addon-vitest makes this reachable on every test: its testStory awaits setViewport(...) unconditionally, before render and before play, falling back to 1200x900 even for a test with no viewport parameter. So a single missed frame anywhere in a large suite takes a test down.

That amplifies it but is not the cause — any consumer calling page.viewport() with multiple contexts can hit it.

Reproduction

Environment: vitest@4.1.1, @vitest/browser@4.1.1, @vitest/browser-playwright@4.1.1, Chromium headless, maxWorkers: 8.

A natural occurrence is rare, so the deterministic version drops the frame directly. Injecting this into the orchestrator page via browser.orchestratorScripts reproduces it exactly, absent inner stack frame included:

// Drop the Nth animation frame in the orchestrator page.
let frame = 0
const raf = window.requestAnimationFrame.bind(window)
window.requestAnimationFrame = cb => {
  frame += 1
  if (frame === Number(DROP)) return 0   // callback never runs -> viewport:done never posted
  return raf(cb)
}

Result: Test timed out in 20000ms, with the test object as the only stack frame, on a test that does no work.

Expected behaviour

page.viewport() should resolve, or reject with a diagnosable error, within a bounded time. A test should not be able to hang indefinitely because a browser chose not to composite a background page.

Suggested fix

Bound that single wait — run the queued callback if no frame arrives within a short deadline. Nothing is lost: the resize is already applied synchronously, and the frame only lets it settle.

We are running exactly that as a consumer-side workaround, patching requestAnimationFrame in the orchestrator page with a 250ms fallback and keeping cancelAnimationFrame consistent. With it, a page that never composites at all still passes — including tests asserting window.innerWidth === 320, so the viewport really is applied rather than skipped.

It works, but it is a monkey-patch of internals in every consumer's config, which is why we are reporting it rather than keeping it.

Residual risk worth noting in any fix

If the browser applies intensive timer throttling to the orchestrator page, a setTimeout-based fallback is itself delayed. Playwright launches with --disable-background-timer-throttling and the orchestrator holds an open WebSocket, both of which normally prevent that — but a fix inside Vitest may want something sturdier than a timer.

Possibly related

#7871 ("Inexplicable and flaky userEvent action timeouts") may share this root cause. The symptom there is different — Playwright locator/hover timeouts with full call logs, rather than a test dying before it renders — but the reporter says it happens only in CI and cannot be reproduced locally, which is the signature of a page that is not being composited. CI typically runs more workers than a laptop, so more orchestrator pages end up backgrounded.

If Playwright's actionability checks ("waiting for element to be visible and stable") also depend on the page painting, a non-compositing page would stall those too. Offered as a lead, not a claim — I have not verified it.

Validation

Our numbers, for whatever they are worth as corroboration:

  • Before the workaround: intermittent, roughly 2 failures in 19 full-suite runs.
  • After: 15 consecutive full-suite runs clean, 1141/1141, zero timeouts.
  • The deterministic before/after above is the stronger evidence; at that base rate, 15 clean runs would happen by chance about 19% of the time even with no fix.

Metadata

Metadata

Assignees

Labels

feat: browserIssues and PRs related to the browser runnerp3-minor-bugAn edge case that only affects very specific usage (priority)

Type

Projects

Status
Approved

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions