Repository navigation
spinner-waiter: a document that commits mid-check gets its own quick check - #46
Conversation
…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>
PR SummaryMedium Risk Overview Implementation tracks 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. |
commit: |
… 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>
The bug: two documents in one decision
After the 1 s quick check fails,
spinnerWaiterasks one question: is anything loading?loadingVisibleanswers withdocument.readyStateand 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.
pageIsNavigatingreadsreadyStatefrom 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 withTimeout 1ms exceededwhile 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
DidStartNavigationtoDidFinishNavigation(content/browser/devtools/render_frame_devtools_agent_host.cc). Sopage.evaluate,isVisible()andcount()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 withreadyState"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 pollspage.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 realnode:httpserver (Chromium does not pass a route-fulfilled 302's next hop back throughpage.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 drawsaria-label="Loading"850 ms after it commits and the result 2.5 s after. The test clicks withnoWaitAfterand waits for the result.Measured from the click with
spinnerWaiter({ log })andframenavigated, before and after:origin/mainwaitForstarts, quick check begins on the old pageTimeout 1ms exceededwaitForpassesorigin/main,--repeat-each 20locator.waitFor: Timeout 1ms exceededwith the add-a-spinner hint--repeat-each 20A 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: 3001and checks that the action fails within the budget with the "navigation was still in flight" message.The fix
Count main-frame
framenavigatedevents per page (aWeakMapand 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,spinnerTimeoutminus 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
WeakMapreads. 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, becauseframenavigateddoes 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
readyStateafter 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 effortmax, 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:spinnerTimeoutand the spinner wait then started anotherspinnerTimeout - 2000. One deadline now covers both.falseand0. Comments, README and task note say so now. The counter ismainFrameNavigations, becauseframenavigatedcounts same-document navigations too.error; shutdown callsclose()beforecloseAllConnections().noWaitAftercomment. Playwright 1.60's click still passeswaitAfter: !noWaitAfter, so the option is not a no-op; the comment states the intent only.WeakMapreads, not one.Not taken: a deterministic spec for the straddled decision. It needs a navigation to start in the few milliseconds between the
readyStateread and the spinner count, which no real page behaviour pins down; the counter check afterloadingVisiblecovers 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; theWeakMapdoes not root the page). It flagged two pre-existing limits this PR leaves alone:isVisible()andcount()carry no deadline, so a navigation that never finishes holds them pastspinnerTimeout; and the 3 s grace period inwaitForReadyWhileSpinningstarts once and does not restart on a later navigation.Risk map
spinnerWaiteris untouched.spinnerTimeoutbudget. Same-document navigations (pushState, hash changes) count too.framenavigatedlistener per page for the life of the page; the counter lives in aWeakMapkeyed by the PlaywrightPage.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 coreffmpegno longer ships theassfilter, so plainpnpm testcannot render locally on that build). This PR does not change recording or rendering.mw-todo-app-video-rendered.webm
🤖 Generated with Claude Code