Skip to content

webview: sample click(selector) stability from distinct rAF callbacks - #35173

Open
robobun wants to merge 5 commits into
mainfrom
farm/29592ceb/webview-click-stable-raf
Open

robobun wants to merge 5 commits into
mainfrom
farm/29592ceb/webview-click-stable-raf

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes the recurring flake in test/js/bun/webview/webview-chrome.test.ts "chrome: click(selector) waits for animation to stop" (builds 76361, 76546, 76555, 76765, 77013, 77055, 77097, 77119, 77769, 77839, 78003 among others). The test and the behaviour were introduced in #28185.

Cause

The actionability poll behind click(selector) took its first bounding-box sample synchronously, then one more after a single await requestAnimationFrame, and accepted "stable" if they matched:

for (;;) {
  const r = el.getBoundingClientRect();          // iter 0: synchronous
  if (last && rectsEqual(last, r)) return [cx, cy];
  last = r;
  await new Promise(f => requestAnimationFrame(f));
}

Under headless Chrome, the Runtime.evaluate task that runs this IIFE can be scheduled inside the same rendering update as the first rAF callback, so the synchronous sample and the post-rAF sample read the identical animation state. For an element whose CSS animation just had its style applied, that state is the from-keyframe (left: 0), the loop returns immediately, and the click is dispatched before the slide has moved. The CI failure values (0, 20.828125) are the element's position in the first two frames of the 80ms animation.

Instrumented trace from a failing run:

i=0: left=20.828125, document.timeline.currentTime=17.966
i=1: left=20.828125, document.timeline.currentTime=17.966  // same frame
→ returned at 20.828125

Fix

Take every sample from inside a rAF callback and only accept a match when the rAF timestamp advanced, so "stable" means the same bounding box across two distinct rendering frames. The rAF wait races a setTimeout bound to the remaining deadline so the documented timeout option still rejects when the renderer is not producing frames (headless WKWebView without a display driver, where the loop previously relied on the first querySelector running before rAF was ever reached):

for (;;) {
  const t = await new Promise(f => {
    const id = setTimeout(f, Math.max(0, deadline - performance.now()));
    requestAnimationFrame(t => { clearTimeout(id); f(t); });
  });
  if (performance.now() > deadline) throw ...;
  const r = el.getBoundingClientRect();
  if (last && t !== lastT && rectsEqual(last, r)) return [cx, cy];
  last = r; lastT = t;
}

This is the same shape as Playwright's injected _checkElementIsStable (rAF first, drop frames where the clock hasn't advanced, outer deadline race). Both the Chrome CDP and the WKWebView actionability scripts are updated. The extra leading rAF adds one frame (~16 ms) of latency to click(selector) on a static element.

Test

Both backend tests now apply the animation via evaluate() immediately before each click(). Because the style change leaves the animation play-pending until the next rendering update, the pre-fix loop sees left=0 twice and clicks at clientX=30 on essentially every iteration, while the fixed loop waits for the slide to settle at clientX=130. Asserting on the body-level event.clientX captures the coordinate the actionability check chose regardless of whether the element has already moved past it.

without fix: 5/5 iterations land at clientX=30 (fail)
with fix:    5/5 iterations land at clientX=130 (pass)

no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/webview/webview-chrome.test.ts test/js/bun/webview/webview.test.ts

The actionability poll behind click(selector)/scrollTo(selector) took its
first bounding-box sample synchronously and the second one after a single
requestAnimationFrame. When the page-side evaluate task lands inside the
same rendering frame as that first rAF callback (which headless Chrome
lets happen between 'update animations' and rAF dispatch), both samples
read the animation's from-keyframe, the element looks stable, and the
click is dispatched before the animation has moved.

Move the rAF to the top of the loop so every sample is taken from inside
a rAF callback, and only accept a match when the rAF timestamp advanced,
matching Playwright's _checkElementIsStable. Applies to both Chrome CDP
and WKWebView backends.

The animation test is reworked to restart the animation via evaluate()
immediately before each click(), which reproduces the race on every
iteration instead of the ~2% it previously hit in CI.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Animation-frame actionability

Layer / File(s) Summary
Frame-synchronized actionability predicate
src/runtime/webview/ChromeBackend.h, src/runtime/webview/WebViewHost.cpp
Click predicates require matching geometry across distinct animation frames while retaining timeout and center hit-testing checks.
Repeated animation click coverage
test/js/bun/webview/webview-chrome.test.ts, test/js/bun/webview/webview.test.ts
Tests restart the animation before five clicks and verify identical settled client coordinates.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the main change to click(selector) stability sampling.
Description check ✅ Passed The description covers the bug, cause, fix, and verification, though it does not use the exact template headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:13 PM PT - Jul 22nd, 2026

❌ @robobun, your commit 921a5f7 has 2 failures in Build #78135 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35173

That installs a local version of the PR into your bun-35173 executable, so you can run:

bun-35173 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — well-diagnosed flake fix that mirrors Playwright's _checkElementIsStable pattern; the inline test-duration nit is worth a follow-up but non-blocking.

Extended reasoning...

Overview

The PR fixes a recurring CI flake in click(selector)'s actionability check. The embedded JS polling loop in both ChromeBackend.h (CDP) and WebViewHost.cpp (WKWebView) is changed so every bounding-box sample is taken after an await requestAnimationFrame, and a match only counts when the rAF timestamp advanced (t !== lastT). Previously the first sample was synchronous and could land in the same rendering frame as the first rAF callback, making a just-started animation look stable at its from-keyframe. The Chrome test is rewritten to deterministically reproduce the pre-fix failure by restarting the animation via evaluate() immediately before each click().

Security risks

None. The change is confined to a page-side polling IIFE that reads getBoundingClientRect() and elementFromPoint; no new inputs, no escaping/serialization changes, no allocation or lifetime changes. The selector still goes through appendQuotedJSONString / NSDictionary args as before.

Level of scrutiny

Moderate. It's a small, focused logic change (~10 LOC across two embedded scripts) to a user-facing API's actionability predicate, backed by an instrumented trace, a cited reference implementation (Playwright), and a deterministic before/after test. The root cause is clearly explained and the fix is the minimal shape that addresses it. The one behavioral cost — one extra rAF frame (~16ms) of latency on static elements — is disclosed and acceptable for correctness.

Other factors

  • Both sibling implementations (Chrome CDP and WKWebView) are updated identically, satisfying the "fix the whole class" rule.
  • I traced the loop invariants: on iteration 0, last is undefined so the match can't fire; lastT is set unconditionally each iteration; the deadline check moved to after the rAF but still runs every iteration; last is still reset to undefined when the element disappears or leaves the viewport, so the stability window correctly restarts.
  • The only finding is a test-speed nit (10×200ms iterations ≈ 2.5–3s vs the ~1s budget). It's a fair point per REVIEW.md but doesn't affect correctness — CI passes --timeout=90000 and the wall-clock is dominated by Chrome's rAF cadence rather than debug-build slowdown. Worth trimming to ~5×100ms in a follow-up, but not a blocker.
  • No prior reviewer comments to address; no CODEOWNERS on this path.

Comment thread test/js/bun/webview/webview-chrome.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the earlier test-speed concern was addressed in 3153dd1 (5×100ms, ~0.9s, verified still fails 10/10 without the src/ change). The remaining inline nit about porting the regression-proof test shape to webview.test.ts:553 is worth a follow-up but doesn't block: the WKWebView hunk is defensive symmetry with no observed flake, and the existing test still exercises the path.

Extended reasoning...

Overview

Fixes a recurring CI flake in click(selector)'s actionability check by moving every bounding-box sample inside a rAF callback and gating "stable" on t !== lastT so the two samples are provably from distinct rendering frames. Applied symmetrically to kActionabilityIIFE (ChromeBackend.h) and kActionabilityJS (WebViewHost.cpp). The Chrome test is rewritten to restart the animation via evaluate() immediately before each click(), which makes the pre-fix same-frame race deterministic per iteration.

Security risks

None. The changed code is a page-side injected JS string literal; the loop structure changed but no new user input is interpolated (selector still goes through appendQuotedJSONString / NSDictionary args). kScrollToIIFE/kScrollToJS are correctly left alone since they have no stability comparison.

Level of scrutiny

Medium. This is user-facing runtime behavior in shipped C++, but the diff is a tight rearrangement of an existing 15-line loop with a well-documented root cause (instrumented trace showing identical document.timeline.currentTime across two samples), and it mirrors Playwright's _checkElementIsStable. I traced the loop invariants: last is reset to undefined on element-missing / out-of-viewport, lastT is unconditionally updated each iteration, the deadline check still runs once per rAF, and the acknowledged ~16ms added latency on static elements is the only behavior change on the happy path.

Other factors

  • My previous review's test-speed concern (10×200ms ≈ 2.5s) was addressed exactly as suggested; the author confirmed the trimmed test still fails 10/10 without the fix, satisfying the fails-for-the-right-reason requirement.
  • The one open nit (WKWebView sibling test at webview.test.ts:553 still uses the old animation-in-initial-HTML shape) is a coverage gap on a defensive-symmetry change — the PR cites only Chrome flakes, and WKWebView's callAsyncJavaScript: XPC scheduling differs from CDP Runtime.evaluate. Worth doing, not blocking.
  • No third sibling: webview-chrome-ws.test.ts has no animation test, and the kScrollTo* scripts don't compare across frames.

Comment thread src/runtime/webview/WebViewHost.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webview/ChromeBackend.h`:
- Around line 280-290: Shorten the comments describing the stable-element
predicate and regression tests to three lines or fewer while retaining only the
distinct-rAF invariant and essential behavior. Update ChromeBackend.h lines
280-290, WebViewHost.cpp lines 354-367, webview-chrome.test.ts lines 735-743,
and webview.test.ts lines 567-573; remove scheduling rationale and defer it to
PR documentation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9c79966b-76b2-47a5-8425-dad0a8fa7206

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and 0f7e19f.

📒 Files selected for processing (4)
  • src/runtime/webview/ChromeBackend.h
  • src/runtime/webview/WebViewHost.cpp
  • test/js/bun/webview/webview-chrome.test.ts
  • test/js/bun/webview/webview.test.ts

Comment thread src/runtime/webview/ChromeBackend.h Outdated
The leading await rAF meant click(selector) could never reject if the
renderer was not producing frames (headless WKWebView without a display
driver), regressing the WK injection-safe test which previously threw
from querySelector before ever reaching rAF. Race the rAF wait against
a setTimeout bound to the remaining deadline so the documented timeout
option holds regardless of frame delivery, and move the deadline check
ahead of querySelector so the first thing reached after the race is the
throw. Comments tightened per review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — both earlier review points (test wall-clock, WKWebView test parity) are addressed in 3153dd1 and 0f7e19f.

What was reviewed:

  • rAF-first + t !== lastT loop in both kActionabilityIIFE and kActionabilityJS — lastT is set unconditionally per iteration, last still resets on not-found/out-of-viewport, so the match predicate is strictly tighter than before.
  • Deadline check moved after the leading rAF — still one check per iteration; only effect is the documented +1 frame latency on static elements.
  • Chrome test at 5×100ms (~0.9s) and the ported itRendering WKWebView test — both use the evaluate()-driven restart the author verified fails deterministically without the src/ change.
Extended reasoning...

Overview

Fixes a recurring CI flake in webview-chrome.test.ts "click(selector) waits for animation to stop" by changing the page-injected actionability poll (in both src/runtime/webview/ChromeBackend.h and src/runtime/webview/WebViewHost.cpp) to take every bounding-box sample from inside a rAF callback and to require the rAF timestamp to have advanced before accepting a match. The two sibling tests (webview-chrome.test.ts and webview.test.ts) are rewritten to restart the animation via evaluate() immediately before each click(), which the author demonstrated fails 10/10 without the fix.

Security risks

None. The change is to two static JS string constants that run inside the page's JS context; no new user input flows in, and selector escaping is unchanged (still via appendQuotedJSONString / NSDictionary arguments).

Level of scrutiny

Moderate. This is a targeted flake fix with a clearly stated and instrumented root cause, modeled on Playwright's _checkElementIsStable. The loop restructuring is small: the rAF await moves from the bottom to the top of the loop body, a timestamp guard is added, and the deadline check stays once-per-iteration. I traced the state transitions for last/lastT across the not-found, out-of-viewport, hit-test-fail, and match branches — the new predicate is a strict tightening of the old one (adds t !== lastT), so no previously-rejected state can now pass. The only behavioural cost is one extra frame of latency for already-stable elements, which the PR calls out.

Other factors

I raised two concerns on earlier revisions: (1) the 10×200ms test exceeded the ~1s budget, and (2) the WKWebView test hadn't been ported to the regression-proof shape. Both were addressed (3153dd1 → 5×100ms ≈ 0.9s; 0f7e19f → webview.test.ts now uses the same restart-via-evaluate loop under itRendering, so a local macOS run guards the WebViewHost.cpp hunk). The bug-hunting pass on the final revision found nothing. The change is self-contained to the webview subsystem, applied identically to both sibling entry points, and the tests are structured to fail deterministically without the fix.

Comment thread src/runtime/webview/ChromeBackend.h
Same setTimeout race as the actionability loop, applied to the sibling
kScrollToIIFE/kScrollToJS so scrollTo(selector, {timeout}) also rejects
when the renderer is not producing frames. Pre-existing behaviour, not a
regression from this PR, but the two loops share the pattern.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all prior feedback (test wall-clock, WKWebView sibling test, scrollTo rAF guard) is addressed and the bug-hunt pass found nothing new.

What was reviewed:

  • Traced the rAF/setTimeout race in both actionability and scrollTo scripts — double-resolve of f is safe (Promise resolve is idempotent), clearTimeout on a fired timer is a no-op, and when the timer wins t=undefined so t !== lastT stays false and can't produce a spurious "stable".
  • Confirmed lastT = t runs on every loop path (including the last = undefined branches) so the distinct-frame gate can't be bypassed by an intermediate not-actionable frame.
  • Verified all four requestAnimationFrame sites in src/runtime/webview/ now use the deadline-bound race; no remaining bare-rAF sibling.
  • Checked the test's clientX=130 arithmetic (left=100 + width/2=30, margin:0) and that the body-level event.clientX capture asserts the dispatched coordinate independently of where #mover is when the event lands.
Extended reasoning...

Overview

The PR rewrites the four embedded page-side JS poll loops (kActionabilityIIFE/kActionabilityJS for click(selector) and kScrollToIIFE/kScrollToJS for scrollTo(selector)) in src/runtime/webview/ChromeBackend.h and src/runtime/webview/WebViewHost.cpp, plus rewrites the two "waits for animation to stop" tests in webview-chrome.test.ts and webview.test.ts. The C++/header changes are string-constant-content-only; no native control flow, allocation, or FFI surface changes.

Security risks

None. Selector escaping is unchanged (still appendQuotedJSONString on the Chrome side and arguments: NSDictionary on the WKWebView side); the injected scripts touch only performance.now, setTimeout, requestAnimationFrame, querySelector, getBoundingClientRect, and elementFromPoint — all pre-existing.

Level of scrutiny

Medium. This is a user-facing runtime API (Bun.WebView#click(selector)) whose timing semantics change: every selector click now waits at least one animation frame before its first sample, and "stable" now requires distinct rAF timestamps. That's a deliberate ~16 ms latency add on static elements, explicitly called out in the PR body and matching Playwright's _checkElementIsStable. I've reviewed the loop line-by-line: the setTimeout-vs-rAF race resolves the promise at most once meaningfully (second f() is a no-op), the timer path yields t=undefined so two consecutive timer-wins compare undefined !== undefined → false and cannot false-accept, and the deadline check now sits immediately after the wait so the documented timeout contract holds even when the renderer produces no frames.

Other factors

This is my fourth pass. The three earlier findings (test budget, WKWebView sibling test coverage, scrollTo sibling guard) were each fixed in dedicated commits and the threads are resolved. The one remaining thread is CodeRabbit's mechanical "comments must be ≤3 lines" nit, which robobun declined citing local file convention — the surrounding ChromeBackend.h/WebViewHost.cpp carry multi-paragraph design comments, and REVIEW.md's "match the exact file's local conventions" applies. The 4-line comments carry the load-bearing invariant (why rAF-first + t !== lastT, why the setTimeout race), which is exactly the durable non-obvious content REVIEW.md asks comments to keep. No CODEOWNERS entry for src/runtime/webview/. CI build #78135 is in flight for the head commit; the Chrome test is the one that flaked across the 11 cited builds, so a green run there is the real proof.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green: webview-chrome.test.ts passed on every lane across builds 78068, 78086, 78113 and 78135. The remaining reds are test-net-connect-memleak.js (pre-existing main break, unrelated to webview) and retry-pass flakes in node fs/http/install tests. Ready for review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant