Skip to content

spinner-waiter: a document that commits mid-check gets its own quick check - #46

Merged
jonastemplestein merged 1 commit into
mainfrom
spinner-redirect-race
Oct 7, 2026
Merged

jonastemplestein merged 1 commit into
mainfrom
spinner-redirect-race

Conversation

@jonastemplestein

@jonastemplestein jonastemplestein commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

The bug: two documents in one decision

After the 1 s quick check fails, spinnerWaiter asks one question: is anything loading? loadingVisible answers with document.readyState and a spinner count. Both describe whatever document the page shows at that moment, and a navigation can swap the document between them.

A click that sets off a redirect chain (OAuth consent → callback → app page) is the common case. pageIsNavigating reads readyState from the page the click left: "complete". The spinner count is held until the navigation commits and then runs on the new page, which has not drawn its spinner yet: 0. "Complete" plus "no spinner" reads as "nothing loading", and the action fails with Timeout 1ms exceeded while the app is mid-redirect. The later loop has a 3 s grace period against spinner flicker; this first decision had none.

What makes the hold happen is Chromium, not Playwright. Chromium's DevTools suspends every renderer-bound message for a frame from DidStartNavigation to DidFinishNavigation (content/browser/devtools/render_frame_devtools_agent_host.cc). So page.evaluate, isVisible() and count() issued during a navigation wait for it to commit and then run on the new document. This has a second consequence: the quick check itself straddles the commit. It spends its second polling the old page, the navigation commits, and the loading check runs on a new page that has had no time to draw anything. Either way the fast-fail hits a document that never got its second.

This is the failure iterate/private hit in CI in test/playwright/dash/integrations.spec.ts ("a project connects X…"): trace with readyState "complete", a spinner count that returned 1.6 s later on the returned page with 0, the 1 ms timeout, and a failure screenshot showing the page's spinner. Any click into a redirect chain is exposed. The spec there polls page.url() as a workaround; this is the root fix.

The test

spec/spinner-waiter.spec.ts › "waits through a redirect chain whose landing page draws its spinner after it commits". A real node:http server (Chromium does not pass a route-fulfilled 302's next hop back through page.route) serves a Connect button that POSTs for 400 ms, then follows consent (302, 150 ms) → callback (302, 150 ms) → project page. The project page draws aria-label="Loading" 850 ms after it commits and the result 2.5 s after. The test clicks with noWaitAfter and waits for the result.

Measured from the click with spinnerWaiter({ log }) and framenavigated, before and after:

ms origin/main this branch
26 waitFor starts, quick check begins on the old page same
429 → 741 navigation requests: consent, callback, project same
745 project page commits same
1070 "not ready, nothing loading, failing fast" → Timeout 1ms exceeded "a new document committed while checking, giving it its own quick check"
2127 spinner seen: "waiting up to 28000ms"
3406 target ready, waitFor passes
result
origin/main, --repeat-each 20 20/20 failed, locator.waitFor: Timeout 1ms exceeded with the add-a-spinner hint
this branch, --repeat-each 20 20/20 passed (~3.3 s each)

A second spec, "gives up on a page that keeps navigating and blames the navigation", serves a page that reloads itself every 300 ms with spinnerTimeout: 3001 and checks that the action fails within the budget with the "navigation was still in flight" message.

The fix

Count main-frame framenavigated events per page (a WeakMap and one lazily attached listener). The quick check and the loading check run in a loop: if they conclude "not loading" but the main frame navigated since the quick check began, the document that is there now gets its own quick check. One deadline, spinnerTimeout minus the action's last second, covers the quick checks and the spinner wait together, so the total cannot exceed the configured budget. A page still navigating at the deadline counts as loading and fails with the existing "navigation was still in flight" message rather than the no-spinner hint.

Cost: the common fast-fail path (no navigation during the action) is unchanged apart from two WeakMap reads. A fast-fail after a navigation costs one more second per committed document: the existing spec "fails fast once a navigation has fully loaded without the expected element" takes 4.2 s with this change and 3.2 s without (3 runs each, --workers 1). Same-document navigations (pushState) count as commits, because framenavigated does not tell them apart; a route change during an action therefore also buys the new route a second before the fast-fail.

Not taken, and why: tracking pending navigation requests (Chromium hides the pre-commit window from every probe anyway, and it does nothing for the post-commit, pre-spinner window the spec reproduces); reading readyState after the count so both see one document (fixes only the straddled decision, not the spent quick check); a stable "not loading" window before every fast-fail (slows the common path, still misses a spinner drawn late in the new document's first second).

Codex review

Codex (gpt-6-astra, reasoning effort max, read-only sandbox) reviewed the diff against the Playwright v1.60.0 and Chromium 148 sources. Verdict: keep the navigation-counter design, with changes. Taken:

  • Budget accounting. Repeated quick checks could consume close to spinnerTimeout and the spinner wait then started another spinnerTimeout - 2000. One deadline now covers both.
  • Navigation-aware exhaustion. Budget exhaustion fell into the 1 ms fast-fail with the add-a-spinner hint. A page still navigating at the deadline now counts as loading and fails with the "navigation was still in flight" message. The new reload-loop spec covers it.
  • Claims softened. A held query does not run on the new document: Playwright maps its destroyed-context error to false and 0. Comments, README and task note say so now. The counter is mainFrameNavigations, because framenavigated counts same-document navigations too.
  • Test server. Startup rejects on error; shutdown calls close() before closeAllConnections().
  • noWaitAfter comment. Playwright 1.60's click still passes waitAfter: !noWaitAfter, so the option is not a no-op; the comment states the intent only.
  • Common-path cost is two WeakMap reads, not one.

Not taken: a deterministic spec for the straddled decision. It needs a navigation to start in the few milliseconds between the readyState read and the spinner count, which no real page behaviour pins down; the counter check after loadingVisible covers that ordering, and the real-server spec covers the spent quick check. Codex confirmed the edge cases: popups (wrapped synchronously, the first check gets a fresh budget), iframes (child-frame commits do not count), waitForURL, failed navigations (no event; an error-page commit counts), page.close(), and listener lifetime (one per page; the WeakMap does not root the page). It flagged two pre-existing limits this PR leaves alone: isVisible() and count() carry no deadline, so a navigation that never finishes holds them past spinnerTimeout; and the 3 s grace period in waitForReadyWhileSpinning starts once and does not restart on a later navigation.

Risk map

  • Changed: the decision after a failed quick check, only when a main-frame navigation commits during it. Everything else in spinnerWaiter is untouched.
  • Slower: fast-fails after a navigation, by one second per observed navigation, within the same spinnerTimeout budget. Same-document navigations (pushState, hash changes) count too.
  • Listener: one framenavigated listener per page for the life of the page; the counter lives in a WeakMap keyed by the Playwright Page.
  • Not covered: a spinner drawn more than a second after its document commits still fast-fails, as it does for in-page actions today. The two pre-existing limits Codex flagged (undeadlined probes, the one-shot grace period in the spinner wait) are unchanged.
  • Verified: pnpm test, pnpm typecheck, pnpm lint, pnpm build; the new spec 20/20 before and after.

Pull request video

Rendered spec/todo-app.spec.ts (ffmpeg-full 9.0.2 with libass; Homebrew's core ffmpeg no longer ships the ass filter, so plain pnpm test cannot render locally on that build). This PR does not change recording or rendering.

mw-todo-app-video-rendered.webm

🤖 Generated with Claude Code

…check

Spinner-waiter decides "is anything loading?" after a 1s quick check. Both
describe whatever document the page shows at the time. A click that sets
off a redirect chain (OAuth consent → callback → app page) can swap the
document in between: the quick check spends its second on the page the
click left, or the loading check reads `readyState` from the old document
and counts spinners once the new one has committed. Chromium holds
evaluations and locator queries while a navigation is in flight (DevTools
suspends renderer-bound commands from DidStartNavigation to
DidFinishNavigation); a query that loses its document reports "not visible"
or 0. So the decision reads "complete, no spinner" before the new page has
drawn anything, and the action fast-fails at 1ms mid-flow.

Count main-frame `framenavigated` events per page. When the main frame
navigated during the quick check or the loading check, run the quick check
again on the document that is there now. All of it is charged against one
deadline, `spinnerTimeout` minus the action's last second, which the spinner
wait respects too; a page still navigating at the deadline counts as loading
and fails with the "navigation was still in flight" message. The common
fast-fail path only adds two WeakMap reads. A fast-fail after a navigation
costs one more second per observed navigation.

Two specs. A real `node:http` redirect chain (Playwright continues
redirected requests itself, so a route-fulfilled 302's next hop never
reaches page.route) whose landing page draws its spinner 850ms after it
commits: fails 20/20 on main with `Timeout 1ms exceeded`, passes 20/20
after. And a page that reloads itself every 300ms, which fails within the
budget with the navigation message.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes timing and failure semantics for locator actions during redirects and repeated navigations; common no-navigation fast-fail path only adds WeakMap reads.

Overview
Fixes spinnerWaiter falsely treating mid-redirect pages as idle: when the main frame navigates during the 1s quick check or loading probe, the plugin re-runs the quick check on the new document instead of 1ms fast-failing with the “add a spinner” hint.

Implementation tracks mainFrameNavigations via framenavigated and loops until loading is detected or the navigation count is stable. One deadline (spinnerTimeout minus the action’s last second) now covers repeated quick checks and the spinner wait; hitting it while navigation is still ongoing fails with the “navigation was still in flight” message rather than the no-spinner nudge.

Adds a real HTTP Connect/OAuth redirect spec (spinner appears ~850ms after landing commits) and a reload-loop spec; README documents the behavior.

Reviewed by Cursor Bugbot for commit 8deb002. Bugbot is set up for automated code reviews on this repo. Configure here.

@pkg-pr-new

pkg-pr-new Bot commented Oct 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/middlewright@46

commit: 8deb002

@jonastemplestein
jonastemplestein merged commit d504ec9 into main Oct 7, 2026
3 checks passed
jonastemplestein added a commit that referenced this pull request Oct 7, 2026
… ready by the loading check proceeds (#47)

Ports the branch `fix/input-value-loading` onto main. iterate/private
has run on that branch since before the repo split: it pins
`pkg.pr.new/middlewright@e3f2374`, a build of the branch, not of main.
Without this PR, iterate/private cannot move to a main build with #46's
fix without losing the two changes below.

## What changes

**`inputValue` waits like an action.** It joins the patched `Locator`
methods. A read of an input that has not appeared yet waits while
loading UI is visible, then returns the current value. It does not wait
for a particular value. An explicit timeout still passes through.

```ts
// a composer that shows "Opening chat…" for 2 s
expect(await page.getByLabel("Message").inputValue()).toBe("About my edited note");
```

Before: `locator.inputValue: Timeout 1000ms exceeded`. After: it returns
the value. iterate/private's browser specs call `inputValue()` in 8
places.

**A target that is ready by the loading check proceeds.** Before the
no-spinner fast-fail, the waiter checks the target once more. A target
that became ready while `loadingVisible` ran (the spinner left and the
control enabled between the two observations) now gets its normal action
budget, not a 1 ms timeout. The cost is one `isVisible` (and `isEnabled`
for actions) round trip on the fast-fail path only.

## Tests

Three rows from the branch, unchanged:
- `inputValue waits for a loading composer and returns its value`
- `inputValue preserves an explicit timeout and can read disabled
inputs`
- `a control becoming ready during the loading check keeps its normal
action budget` (a custom selector engine finishes the UI update at the
exact moment the loading check runs)

Local: `pnpm typecheck`, `pnpm lint`, `pnpm build` clean; `pnpm test`
162 passed, 3 skipped (with `ffmpeg-full`).

The branch's CI change (publishing builds of `fix/input-value-loading`
on push) is left out.

## Risk map

- **Riskiest part:** the recheck sits next to #46's new navigation loop.
It runs only after the loop decided "nothing loading", so it can only
turn a fast-fail into a success, never a wait.
- **On merge:** `inputValue` goes through the plugin chain, so other
plugins (video mode, screenshots) see it as an action. This is what
iterate/private already runs.
- **Review order:** `src/plugins/spinner-waiter.ts`,
`src/plugin-system.ts`, the spec, the README.

## todo-app baseline video


https://github.com/user-attachments/assets/0995d1de-5212-43cf-8a6f-802d0604a54c

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant