Skip to content

spinner-waiter: an in-flight navigation counts as loading - #39

Open
mmkal wants to merge 4 commits into
mainfrom
spinner-waiter-navigation
Open

spinner-waiter: an in-flight navigation counts as loading#39
mmkal wants to merge 4 commits into
mainfrom
spinner-waiter-navigation

Conversation

@mmkal

@mmkal mmkal commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Spinner-waiter's rule is "if the app is visibly loading, wait longer". Right after a cross-server hop (an OAuth popup landing on a cold auth page), the document has committed but hasn't fired load — it's still fetching and rendering its UI client-side. The app can't draw a spinner yet, but the browser's tab spinner is on. Today that reads as "no spinner" and fast-fails in 1ms, which is why ../iterate's OAuth-popup specs still carry { timeout: 15_000 } on popup actions even after popups auto-wrap.

After this, a document that hasn't finished loading (document.readyState !== "complete", or no execution context yet while a navigation commits) counts as loading UI — bounded by the same grace period and spinnerTimeout as an app spinner, and considered by the "loading finished without the element" bail-out too:

const popup = await popupPromise;            // auto-wrapped
await popup.getByTestId("email-login-button").click();
// before: TimeoutError 1ms — the auth page had committed but was still rendering
// after: waits through the load like it would a spinner, then clicks
await popup.getByRole("button", { name: "Allow access" }).click(); // no timeout needed

Two findings from instrumenting, recorded in tasks/complete/2026-08-18-spinner-waiter-navigation-loading.md:

  • Playwright's locator queries (isVisible, count) already block until a pending navigation commits, so the "waiting for a slow server response" phase needs nothing from us. A first cut tracked pending main-frame requests; it never fired and is gone. The reachable window is post-commit, pre-load — the spec for it fast-fails at 1ms when the check is disabled, so it's genuinely load-bearing.
  • Known limitation, out of scope: an action that initiates a slow navigation (clicking a link to a cold server) is bounded by its own actionTimeout — Playwright waits for the navigation inside the action, after spinner-waiter has let it through.

Not touched: page.waitForEvent("popup", { timeout }) in iterate — an event wait middlewright never sees; the fix there is product loading UI on the Sign-in button.

🤖 Generated with Claude Code

Session: b7f6f792-6606-44be-9ec3-207eb762c4b6


Note

Medium Risk
Changes core spinner-waiter timing and loading detection for all wrapped locator actions; behavior is well-specified but affects a sensitive test path (timeouts and OAuth popups).

Overview
spinnerWaiter now extends the “visibly loading” rule beyond app spinners: a document that has committed but not finished loading (readyState !== "complete"), or a navigation still committing (no execution context), is treated like a spinner—same grace period and spinnerTimeout, and the same early bail-out when loading ends without the target element.

That fixes OAuth-style popups that arrive already navigating to a cold auth page: there is no app spinner yet, but the tab is still loading; actions used to hit the 1ms no-spinner fast-fail instead of waiting through load.

Implementation adds loadingVisible / pageIsNavigating and wires them into the initial loading check and waitForReadyWhileSpinning. README documents the behavior; two specs cover success while the document is still loading and fast-fail once navigation is complete without the expected control.

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

Found via ../iterate mobile specs, which still need explicit timeouts on
popup actions because the popup is mid-navigation to the auth worker -
no document, so no app spinner, so spinner-waiter fast-fails. The
browser itself is visibly loading; count it.

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

pkg-pr-new Bot commented Aug 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: c5feb42

A document that hasn't fired load (readyState !== "complete"), or has no
execution context yet while a navigation commits, is loading UI the app
cannot draw itself - the browser's tab spinner is on. spinner-waiter now
waits through it like an app spinner, bounded by the same grace period
and spinnerTimeout, and the loading-finished bail-out considers it too.

This covers the window after a cross-server hop commits and before the
cold page finishes rendering client-side - why ../iterate's OAuth-popup
specs still carried explicit timeouts on popup actions. Playwright's own
locator queries already block until a pending navigation commits, so
that earlier phase needs nothing from us (a first cut tracked pending
requests; instrumenting showed it never fired, so it's gone).

Known limitation, recorded in the task file: an action that initiates a
slow navigation (clicking a link to a cold server) is bounded by its own
actionTimeout - Playwright waits for the navigation inside the action,
after spinner-waiter has let it through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mmkal added a commit that referenced this pull request Aug 26, 2026
…s main CI) (#40)

Main CI has been red since #38 (`a65cda0`): `reveals an expanding
textarea one line at a time at its final geometry` fails 2/2 there with
frames 0-1 showing the *filled* text before the reveal — the render
window starts after the fill. It never reproduces locally (3/3 green),
which fits the cause:

#38 calibrates the render window from the calibration cover. On a slow
runner the screencast trails the paints it maps by a few frames, and the
cover-based offset inherits that lag — so a selector-driven trim start
(`trimStart: ["selector", ...]`) can land past the first fill. The
existing race guard only clamped a trim start back to a highlight when
it landed within **one frame** after it; CI's lag is bigger.

Nothing acts on the app before it's ready, so *any* trim start after a
recorded highlight is measurement error, and starting at the highlight
is always harmless (the video opens on the action instead of a beat
before it). The guard now clamps unconditionally:

```ts
// before: a start ≤ 1 frame after a highlight moved back to it; further = kept → opens post-action
// after:  a start after any highlight moves back to the earliest such highlight
```

No new spec: the failure is runner-timing-specific and this PR's CI run
is the verification. Unrelated: `uses a normal pointer tail after text
cursor holds` is a pre-existing local flake on main (1/3 fails with
`--repeat-each=3`), untouched here.

Blocks #39's CI (inherited the same red).

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

Session: `b7f6f792-6606-44be-9ec3-207eb762c4b6`

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes render trim boundaries for all selector-driven starts when
highlights precede the computed start; behavior is intentional but
affects every video-mode render path that sets `sourceRange.start`.
> 
> **Overview**
> Fixes rendered videos that could open **after** the first recorded
action when `trimStart` uses a selector or cover-based calibration lags
on slow CI runners.
> 
> During finalization, if `sourceRange.start` is set but sits past one
or more highlight timestamps, the render window start is pulled back to
the **earliest** such highlight. The previous guard only did this when
the gap was within **one frame**; multi-frame calibration/screencast lag
is now covered too.
> 
> Comments in `video-mode.ts` spell out the rationale: a trim start
after a highlight is treated as timing error, and clamping to the
highlight is safe because it keeps the opening frame on the action
instead of post-fill footage.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c737189. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@mmkal mmkal closed this Aug 26, 2026
@mmkal mmkal reopened this Aug 26, 2026
mmkal added a commit that referenced this pull request Aug 28, 2026
…ghlight (main CI flake) (#41)

Follow-up to #40, which fixed the *after* case. Main still flakes on
`reveals an expanding textarea one line at a time at its final geometry`
(1/2 on main's latest run, 3/3 on #39) with the mirror case.

Diagnosed from the CI run's own artifacts (`gh run download`): the
selector trim start lands a sliver **before** the first highlight (36ms
/ 92ms / 54ms across the three attempts), so the render opens with ~1
frame of raw footage before the waitFor hold. On a slow runner the
screencast starts so late that its very first frames already show the
fill's result — the CI raw video is 2.0s long with text present at
t=0.04s — so that opening frame is the *filled, expanded* textarea, one
frame before the reveal's empty still:

First six rendered frames from the failing CI run — frame 0 is the
filled, expanded textarea; frames 1-5 are the hold with the empty
textarea:


![ci-textarea-first-frames.png](https://github.com/user-attachments/assets/ef479360-88d6-4f55-bc01-1be92891e6ec)

A lead-in shorter than the existing fill stabilization window (`max(0,
timelineOffset) + 3 frames`) isn't worth keeping and can't be trusted on
a lagging recorder, so the start now snaps forward to the first
highlight. Longer lead-ins are untouched; #40's after-case clamp stays.

Runner-timing-specific like #40 — this PR's CI run is the verification.
Blocks #39.

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

Session: `b7f6f792-6606-44be-9ec3-207eb762c4b6`

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes rendered video trim boundaries for selector-based starts on
slow runners; scoped to short lead-ins but affects pixel-accurate video
assertions if they depend on exact first frames.
> 
> **Overview**
> Extends **selector trim start** correction in `video-mode` render
finalization: after the existing clamp when trim start lands *after* a
highlight (#40), it now handles the mirror case when trim start sits a
**short sliver before** the first highlight.
> 
> If `sourceRange.start` is within the fill stabilization window
(`max(0, timelineOffset) + 3 frame durations`), it **snaps forward** to
`firstHighlightStart` instead of keeping ~1 frame of raw screencast.
That avoids opening on lagging-recorder frames that already show
post-fill UI (e.g. expanded textarea) before a fill-reveal hold.
> 
> Longer lead-ins are unchanged; only sub-tolerance gaps are adjusted.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
52054c5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@mmkal
mmkal marked this pull request as ready for review August 28, 2026 20:30
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