Skip to content

motion-waiter: wait for a sliding element to settle before clicking it - #42

Open
mmkal wants to merge 8 commits into
mainfrom
motion-waiter
Open

motion-waiter: wait for a sliding element to settle before clicking it#42
mmkal wants to merge 8 commits into
mainfrom
motion-waiter

Conversation

@mmkal

@mmkal mmkal commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Teaches middlewright about motion: a new motionWaiter plugin that holds pointer actions (click/dblclick/hover) until the target stops moving.

Playwright's own stability check only requires the bounding box to match across TWO consecutive frames, so timer-driven JS animation (RN-web Animated's JS driver, setInterval steppers) that steps coarser than the display refresh gets clicked mid-slide. motionWaiter samples the target's bounding box over a longer window and proceeds only after 150ms of observed stillness — technique-agnostic (CSS, WAAPI, rAF and timer steppers all move the box), and deliberately a real window rather than a single confirming sample, because an element often sits parked a frame or two before its animation starts (RN's open → requestAnimationFrame → animate shape, the bug that motivated this in iterate/iterate#2547).

It is off by default — register it, then opt in around the specific interactions whose animations are known to be problematic:

const page = await addPlugins({ page: basePage, testInfo, plugins: [spinnerWaiter(), motionWaiter(), videoMode()] });
await page.getByRole("button", { name: "Open menu" }).click();
// the drawer slides in, stepped by a JS timer…
await motionWaiter.settings.run({ enabled: true }, () =>
  page.getByRole("button", { name: "Notifications" }).click(), // held until the slide settles
);

Before

Vanilla Playwright clicks the menu item when the drawer is ~20% out (translateX(-225px) of 280px, recorded by the demo app at click time) — the drawer "only slides part way" because the click ended the flow there:

drawer-before.mp4

After

Same app, same clicks — the click waits for the slide to settle and lands at translateX(0):

drawer-after.mp4

Cost

Guarded actions deliberately slow down: a static element pays ~150-200ms (the stillness window), and actual motion is waited out up to a 1.5s settleTimeout — perpetual motion (marquees) proceeds at the deadline with a log line, never blocks. Because of that cost the plugin is opt-in (enabled: false by default): enable per block via settings.run, per test via settings.enterWith, or suite-wide with motionWaiter({ enabled: true }). An explicit { timeout } passes straight through — which is also what makes [spinnerWaiter(), motionWaiter()] compose (spinner-waiter's fast-fail injects a 1ms timeout that skips the motion wait).

Risk map

  • The riskiest piece is the sampling loop's stillness heuristic (src/plugins/motion-waiter.ts): step cadences slower than sampleInterval (60ms) can pass the stillness window mid-hold — documented boundary, same blind spot as vanilla Playwright, defeated in practice by the 150ms settledFor quiet requirement.
  • On merge every consumer's pointer actions get slower by the stillness window once they register the plugin; nothing changes for suites that don't register it.
  • Review order: src/plugins/motion-waiter.ts, spec/motion-waiter.spec.ts, spec/motion-drawer-demo.spec.ts; README/exports are mechanical.

video-mode: watchable spans

The demo exposed a video-mode gap: footage during a motion-settle hold got dropped twice over — recorded as pre-action dead air (the hold runs before video-mode's middleware) and skipped entirely when two pointer actions land within one highlight-hold of each other (the overlap-skip). Per the plugin-boundary rule, the fix is neutral middleware context: ActionTiming.watchableSpans, which motion-waiter fills when it actually observed motion, and video-mode both carves out of dead-air compression and feeds into the overlap-skip guard — the same two protections popup enter/exit animations get. A spec asserts the dead-air carve-out; the demo videos above are the skip-guard's proof (the slide plays through).

Shared visual baseline

spec/todo-app.spec.ts rendered on this branch (per AGENTS.md):

todo-app-baseline.mp4

🤖 Generated with Claude Code


Session: 14cf93bf-a678-421e-a4b5-00f4228ba4cc — "PR 2547: un-suppress waitForTimeout sleeps"


Note

Medium Risk
Opt-in pointer actions add ~150ms+ latency and bounded settle loops when enabled; video-mode dead-air/render logic changes affect all recordings using middleware-flagged watchable spans.

Overview
Adds motionWaiter, a new middleware plugin that delays click, dblclick, and hover until the target’s bounding box stays still for ~150ms—addressing mid-slide clicks when timer-stepped JS animation fools Playwright’s two-frame stability check. It is off by default; tests opt in with motionWaiter.settings.run({ enabled: true }, …) or suite-wide enabled: true, and explicit { timeout } bypasses the wait (so it composes with spinnerWaiter).

video-mode gains ActionTiming.watchableSpans: motion-waiter records settle waits as watchable footage, which is carved out of dead-air compression and protected during render overlap-skips (same idea as popup animations). The oxlint require-timeout-comment guidance now points animation waits at motionWaiter; README and demo/unit specs document the drawer before/after behavior.

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

Fleshed-out spec for teaching middlewright about motion: Playwright's
2-frame stability check misses timer-driven JS animation, so clicks land
mid-slide (seen with a RN Animated drawer in iterate). Plan: a bounding-
box-sampling motionWaiter plugin with a 1s settle budget, plus a slow-
drawer demo spec producing before/after videos.

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

pkg-pr-new Bot commented Aug 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 4f004fd

mmkal and others added 3 commits August 29, 2026 17:45
Playwright's stability check compares only two consecutive frames, so
timer-driven JS animation (RN-web Animated, setInterval steppers) that
steps coarser than the display refresh gets clicked mid-slide. The new
plugin samples the target's box before pointer actions: a static element
passes after one confirming sample (~60ms), observed motion demands a
150ms quiet window, and everything is capped by a 1.5s settle budget so
perpetual motion proceeds with a log line instead of blocking.

The drawer demo spec is the proof: the control click lands at
translateX(-240.8px) on a 280px drawer (~14% open); with motionWaiter
the same click lands at 0. Explicit { timeout } passes through, which
also composes with spinner-waiter's fast-fail (its injected 1ms timeout
skips the motion wait).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zero-quiet fast path would sail through an element parked a frame or
two before its animation starts (RN's open → requestAnimationFrame →
animate shape — the motivating bug), so the action now proceeds only
after settledFor (150ms) of observed stillness — ~150-200ms per pointer
action on static elements, the price of catching pause-then-slide.

Demo: the drawer app freezes its slide with a pressed-item flash when a
click lands, so the before video visibly strands the drawer part way
out; both tests caption their videos via page.videoMode.caption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmkal

mmkal commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Maybe let's have a settings system so this can be off by default but opt in in an async local storage callback just for specific parts that we know have problematic animations. Similar to the await page.spinnerWaiter.settings.run({ spinnerTimeout: 12345 }, async () => ...)

Every guarded action pays the stillness window, so motion checking now
defaults to enabled: false. Opt in around known-problematic animations
(motionWaiter.settings.run({ enabled: true }, () => item.click())), or
pass enabled: true at registration to guard a whole suite. The demo's
after-test showcases the per-block opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmkal

mmkal commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Done in c114d58enabled: false by default, same AsyncLocalStorage settings system as spinner-waiter: motionWaiter.settings.run({ enabled: true }, () => item.click()) for a single action, settings.enterWith for the rest of a test, or motionWaiter({ enabled: true }) to guard a whole suite. The demo's after-test now showcases the per-block opt-in, and the off-by-default behavior has its own unit test. PR body updated.

@mmkal

mmkal commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

There's a flash before and after the the animation

The flash at the start and end of the demo videos had two causes:

1. The motion-settle hold runs before video-mode's middleware, so
   video-mode recorded it as pre-action dead air and compression
   fast-forwarded the drawer's slide — frames of the dimmed mid-slide
   state leaked into the rendered intro as a flash. Fix via neutral
   middleware context (AGENTS.md plugin boundaries): ActionTiming gains
   watchableSpans, motion-waiter flags its hold there when it actually
   saw motion, and video-mode carves those spans out of dead air the
   same way popup enter/exit animations are protected. The slide now
   renders at full speed.

2. The demo app popped its backdrop in and out instantly (one-frame
   dim/undim), and navigated while the overlay was mid-fade, so the
   final freeze frame ghosted half-faded menu items. The overlay now
   fades in/out and navigation happens only after the menu is fully
   gone.

New spec proves the carve-out: a slide-settling click must not leave a
dead-air span covering the hold (fails without the video-mode change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmkal

mmkal commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Fixed in d1d1ce5, and it turned out to be two things. The start flash was a real video-mode gap: the motion-settle hold runs before video-mode's middleware, so it got recorded as pre-action dead air and the slide was fast-forwarded — a dimmed mid-slide frame leaked into the rendered intro. Fixed via neutral middleware context per AGENTS.md: ActionTiming.watchableSpans, which motion-waiter fills when it saw motion and video-mode carves out of dead-air compression (same protection popup enter/exit animations get), with a spec that fails without the carve-out. The end flash was the demo app's fault — it popped the backdrop instantly and navigated mid-fade, ghosting half-faded menu items into the final freeze; it now fades the overlay and navigates only once the menu is gone. Fresh videos in the PR body — the slide now plays through at full speed.

mmkal and others added 2 commits August 29, 2026 21:56
The slide vanished from the rendered demo entirely: two pointer actions
within one highlight-hold of each other trigger the overlap-skip, which
jumped straight across the drawer's travel. Watchable spans now feed
renderKeepSpans — the same guard that stops skips leaping over popup
enter/exit animations — so flagged footage survives both dead-air
compression and hold skips.

The remaining end-seam artifacts (a pre-navigate Home frame and a black
frameless slice leaking around the heading waitFor's highlight) go away
by not highlighting the waitFor at all (skipMethods: ["waitFor"]) —
footage runs continuously from the click through the pressed flash and
fade. The demo app now navigates as the fade starts (the new screen is
what the fade reveals), and the tests outlast the fade so the final
hold freezes a settled frame.

Verified frame-by-frame at 25fps: both videos play travel → slide →
pressed flash → fade → settled screen with no flashes, ghosts, or
black frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agents reaching for a sleep or a timeout to let a drawer finish sliding
now get told about the tool built for it, in both require-timeout-comment
messages. The lint-plugin spec pins the hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mmkal
mmkal marked this pull request as ready for review September 2, 2026 05:38
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