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:
@vitest/browser/dist/context.js — page.viewport() posts a BroadcastChannel message and returns a promise that settles only on viewport:done. No timeout.
@vitest/browser/dist/client/__vitest_browser__/orchestrator-*.js — the handler calls setIframeViewport, which resizes synchronously, then awaits one animation frame before posting viewport:done.
- 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.
Describe the bug
page.viewport()can hang forever. The wait bottoms out in the orchestrator'ssetIframeViewport, which applies the new size synchronously and then awaits a single animation frame before replyingviewport:done: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 theviewport:donereply — so the test sits until the suite'stestTimeoutkills it.The three links, read from installed dist sources at
@vitest/browser@4.1.1:@vitest/browser/dist/context.js—page.viewport()posts aBroadcastChannelmessage and returns a promise that settles only onviewport:done. No timeout.@vitest/browser/dist/client/__vitest_browser__/orchestrator-*.js— the handler callssetIframeViewport, which resizes synchronously, then awaits one animation frame before postingviewport:done.What makes this hard to diagnose
The failure has a distinctive and misleading fingerprint:
requestAnimationFrameinside 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-vitestmakes this reachable on every test: itstestStoryawaitssetViewport(...)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.orchestratorScriptsreproduces it exactly, absent inner stack frame included: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
requestAnimationFramein the orchestrator page with a 250ms fallback and keepingcancelAnimationFrameconsistent. With it, a page that never composites at all still passes — including tests assertingwindow.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-throttlingand 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: