fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps - #22392
Conversation
…and stop double taps Frame pacing. The pane decodes each frame on a hidden layer and flips to it on onLoad. While one frame decoded, every newer frame re-pointed that same hidden layer, which cancels the in-flight load. On a phone that decodes a frame slower than frames arrive (~10/s during page loads, menus, spinners), onLoad never fired for any of them and the pane sat on an old frame until the page went still. A decoding layer is now never re-pointed: only the newest frame is held, and it takes the layer once the decode settles. A 1.5s watchdog frees a layer whose decode never reports, and a frame the hidden layer already holds (a blinking caret alternating two frames) flips at once, since an unchanged source reloads nothing. Double taps. When browser.mouseClick failed, the pane replayed the tap as move/down/up. On a timeout the click is still queued on the host, so the replay landed a second tap on whatever the first one opened. The replay now runs only when the click definitely did not reach the host.
The corpus certified the move/down/up replay after a transport-rejected browser.mouseClick, which the commit before removes. Scoped like #22179: baseline bumped by editing that one line, then --record. 788 files. Every changed line classified: - `baseline`: 787 files (786 goldens + pilot-scenarios.json), nothing else. - matrix-browser.pointer-click-browser.mouseclick-1.json: the transport-rejection and transport-rejection-no-message partitions of browser-pointer-click-fallback now send only browser.mouseClick#1. The refused partitions still replay, unchanged.
f1f8a8c to
16eba0a
Compare
…tatus modules #22452 changed only src/shared, so its CI never ran the page-closure suite; main now measures 4220 modules (1034 local) against a pin of 4218. This branch adds nothing to the closure: its own count matches main's.
…asured on Android
There was a problem hiding this comment.
ℹ️ Minor suggestions only — one interaction between the new fast path and a pane re-render, plus a stale comment.
Reviewed changes
- Frame pacing (
mobile/src/browser/use-mobile-browser-frame-apply.ts) — a hidden layer that is decoding is no longer re-pointed; the newest frame is held in the pacer slot and takes the layer once the decode settles, with a 1.5 sBROWSER_FRAME_DECODE_WATCHDOG_MSfor a decode that never reports. - Same-source fast path — a frame the target layer already holds settles without a decode, via the new
layerUrisRefper-layer URI tracking. - Drain wiring —
drainQueuedFrameis returned fromuse-mobile-browser-stream.ts, passed asonFrameLayerSettledinMobileBrowserPane.tsx, and called from the pane's image load/error handlers and the arm callbacks. - Double-tap fix (
use-mobile-browser-commands.ts) — themouseMove/mouseDown/mouseUpreplay is skipped whenbrowser.mouseClickfailed with a delivery-unknown error, since a timed-out click may still run on the host. - Tests and corpus — new
use-mobile-browser-commands-click-fallback.test.tsx; reworkeduse-mobile-browser-frame-apply.web.test.tsx; a replay-free re-record of the twotransport-rejectionpointer-click partitions, and a session-closure pin bump for main's #22452.
The double-tap fix is correct as far as I can trace it: bindDeferredRpcOperation.request forwards client.sendRequest unchanged, so the timeout's markRpcDeliveryUnknown mark survives on the same Error identity down to isRpcDeliveryUnknown. The frame-pacing rewrite reads cleanly, and the held-frame and watchdog tests can actually fail against the old gating.
ℹ️ Nitpicks
config/scripts/mobile-web-app-browser-pane-render.test.mjs:322still saysapplyFramewrites the next frame's URI onto the hidden layer "as soon as the frame lands". Under the new pacing it only writes whendrainQueuedFramereachesapplyFrame— after a settled/abandoned decode and the pacer interval — so the sentence no longer describes the path it is explaining.mobile/src/browser/use-mobile-browser-pane-layers.ts:66,86settle/abandon on nativeonLoad/onErrorwithout theframeUriRefidentity guard the arm callbacks use, so the claim inuse-mobile-browser-frame-apply.ts:64that "both arms … answer for the frame they were armed with" only holds on the web path. Pre-existing root and low probability (the author measured late native loads at 0.4–0.7 s, under the 1.5 s watchdog), but the watchdog's layer reuse widens the window slightly.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…owner The frame pacing, decode watchdog, layer flip and reset were spread over three hooks and a helper module, wired back through the stream hook and the pane. They now live in one plain pacer (browser-frame-pacer.ts) with one timer, and the pane binds each layer's View/Image straight to it. Behaviour fixed on the way, each with a failing test first: - A slow last frame with nothing newer queued was abandoned by the watchdog and never shown. The decode deadline now only applies when a newer frame waits. - Any pane re-render re-pointed both layers at the newest frame behind the pacer's back, so a blinking caret froze. The Image source prop is now only the mount-time frame; every later source write is the pacer's. - A frame that failed to decode left its layer marked as holding it, so an identical frame flipped to an undecoded layer. Giving up on a decode now clears the layer's source. - A native onLoad for a source the layer has since moved off could flip early. The flip now checks nativeEvent.source.uri, which Android and iOS Fabric both report as the raw source string; RN Web's own load event has none, so the web flips only through its decode probe. The session closure pin drops by the two modules this removes.
…gh a flag The delivery-unknown check was a mutable flag set inside the request callback. The click now calls browser.mouseClick itself in a try/catch: a delivered click returns, a delivery-unknown failure returns without replaying, and a refusal or null result still replays as move/down/up. Same wire traffic; the corpus certifies it unchanged.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Unified the double buffer under one pacer —
browser-frame-pacer.tsreplacesuse-mobile-browser-frame-apply.ts,use-mobile-browser-pane-layers.tsandbrowser-frame-layer-flip.tswith a single closure owning the timer, decode watchdog, flip and reset, and the pane binds each layer's<View>/<Image>straight to it. - Fixed the re-render clobber —
MobileBrowserPane.tsxnow derivesrenderedFrameSourcefrom theframeUristate (first frame andreplaceonly), so an unrelated render no longer re-sources both layers behind the pacer; this retires the prior review's finding. - Made the decode deadline apply only when a newer frame waits — a slow last frame now lands however long it takes instead of being abandoned at 1.5 s and never shown.
- Guarded the native load flip by source —
onLoadnow checksevent.nativeEvent.source?.uri, so a late load for a source the layer has moved off cannot flip early. - Simplified the tap fallback —
sendPointerClickcallsbrowserPointerClick.requestdirectly with the 5 s timeout rather than throughsendBrowserRequest, preserving the delivery-unknown skip for timed-out clicks. - Reworked the tests and pin — added
browser-frame-pacer.web.test.tsandmobile-browser-pane-frame-layers.test.tsx, and dropped the session-closure pin back to 4218 for the two removed modules.
I traced the pacer's timer lifecycle and found no reachable state where a queued frame is left without a timer or where a newer frame is stranded behind a decode; both the watchdog-only-when-queued change and the reset() behavior are sound. The clobber fix checks out against RN 0.83's deep-diff of the source prop and React DOM's per-property style diffing on RN Web, so an unchanged URI commits nothing and the pacer's setNativeProps write survives. The prior review's open thread and both nitpicks are addressed.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
The 1.5 s decode watchdog abandoned a slow decode whenever a newer frame was queued and re-pointed its layer. On an Android emulator under load that is the original freeze again: noise frames decode in 2-10 s, every abandon starts a decode the next abandon cuts, Fresco reports the superseded loads (30 stale onLoads in one run) and the pane showed 11 of 41 applied frames. Without it, the same run flips every applied frame (16/16, no stale load), and a slow last frame is shown in every cycle. Nothing else needs it: with the layer never re-pointed mid-decode, native answers every load with onLoad or onError, and the web probe's decode() always settles. The pacer keeps one timer, for the interval.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental review of the one commit since the prior pullfrog review (806134b7 → 2dbc509): the frame pacer no longer runs a decode watchdog.
- Removed the 1.5 s decode watchdog and its abandonment path —
browser-frame-pacer.tsdropsBROWSER_FRAME_DECODE_WATCHDOG_MSand theabandonDecode()call indrain();drain()now returns while a decode is in flight and resumes when that decode'sonLoad/onError(native) ordecode()probe (web) settles, instead of cutting a slow decode short and re-pointing its layer. - Rewrote the pacer tests for the new pacing — the watchdog/give-up cases are replaced by tests that a slow decode is waited out and then both the frame it held and the newest queued frame are shown.
Removing the watchdog is sound: a layer is never re-pointed mid-decode, so a native load always terminates in onLoad (whose source.uri matches the layer's tracked source) or onError, and the web probe's decode() promise always settles; reset()/replace() remain the only paths that drop a decode. I confirmed the iOS Fabric implementation emits onLoad with an ImageSource carrying uri (RCTImageComponentView.didReceiveImage), and no dangling BROWSER_FRAME_DECODE_WATCHDOG_MS reference remains.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…siveness # Conflicts: # config/scripts/mobile-web-app-session-terminal-closure.test.mjs
…oes unanswered The pacer cleared a layer's source when it gave up on a decode (a reset mid-decode, or a failed decode) while the native Image still held it. The next identical frame was then written again, which is a native no-op on Android (ReactImageView.setSource returns on equal sources) and iOS (ImageShadowNode skips equal requests): no onLoad, no onError, and the pane stayed frozen until the stream restarted. Returning from the background to an unchanged page is enough to trigger it. Each layer now records the source its Image holds and whether that source has answered (loading, ready, failed). A layer is written only when it is not loading and only with a different source, so every write gets exactly one answer. A reset no longer abandons anything; the load under way still answers for its layer. A frame the hidden layer already holds flips at once if it decoded and is skipped if it failed. The pane test's native model now treats a same-source write as a no-op and answers each change once with the layer's current source; both new cases (reset mid-decode then the same frame, failed decode then the same frame) freeze on the previous head. Also, per review: the pacer no longer touches busy or metadata. The stream hook creates it and receives each frame as it goes on screen, so metadata (and with it touch mapping) now follows the visible frame rather than one still decoding.
A fulfilled browser.mouseClick ran on the host, but a null result still
replayed it as move/down/up. The native bridge always answers { clicked },
while the external-Chromium provider returns agent-browser's `data` as is,
which can be null, so a right-click there was a double tap. Only a refusal
that is not delivery-unknown now replays.
…me its checkpoint The commit before stops replaying a tap the host answered with a null result. The seed's checkpoint was named clicked-by-fallback, which several partitions no longer do, so it is renamed tap-settled in the same record. Baseline bumped to 2c2b84c by editing that line, then --record. 788 files, 841 changed lines each side. Every one classified: - `baseline`: 787 goldens + pilot-scenarios.json. - the checkpoint rename: pilot-scenarios.json (1), its id in browser-pointer-click-fallback.json (1) and the eleven partition ids in each of the four matrix-browser.pointer-click-*-1.json (44), plus `scenarioSha256` in those five goldens. - behaviour, one checkpoint: the result-null partition in matrix-browser.pointer-click-browser.mouseclick-1.json now sends only browser.mouseClick#1 (sender and payloads drop move/down/up). The refused, method-not-found and result-absent partitions still replay, unchanged.
There was a problem hiding this comment.
ℹ️ No critical issues — one documentation-accuracy nit inline.
Reviewed changes
Incremental review of the four commits since the prior pullfrog review (e859aa14f8 → 4e618a816a).
- Reworked the pacer's double buffer around a per-layer
LayerState(uri+loading/ready/failed), atargetframe andmountedUri, replacinglayerUris/decoding/paint. - Moved pacer construction into
use-mobile-browser-stream(returned asframeLayers); the pane no longer creates it. - Made
settle(layer, uri)the sole flip signal, matchingevent.nativeEvent.source?.uri, and restored a layer's held source when its<Image>remounts. - Changed the tap fallback so any answered click counts as delivered (including a null result), replaying only on a non-delivery-unknown refusal with no modifiers.
- Re-recorded the RPC corpus against pin
2c2b84ce9eand renamed theclicked-by-fallbackpartition totap-settled, plus the pane/pacer/click tests.
I read the full source and test delta end to end. The pacer's target/settle/fail lifecycle has no reachable state that strands a queued frame — show only reaches write when drain's hidden-layer decode guard is clear, and every superseding path (reset/replace/attachImage) re-arms a decode. The answered-tap rule matches what require-result-or-throw-message actually throws on, so a decoded null or {ok:false} reply no longer replays. The three changed test files (19 tests) pass locally.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Nothing tested the remount path: a fresh Image loads the source it mounted with, so the pacer re-arms that load and puts back what the layer held. The pane test's native model now mounts each host fresh (an Image loads its mount source), and a new case remounts both Images mid-decode through a zero-size layout: the visible layer keeps its frame and the stream goes on. Deleting either the re-arm or the put-back fails it. Also per review: drop the dead mountedUri guard in drain, return early from attachImage when nothing is mounted, say that replace writes over a loading layer, unexport the unused pacer types, cover the modifier bail in the tap's comment, and move the frameUri state into the stream hook, which now returns the source the layers mount with.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Incremental review of the one commit since the prior pullfrog review (4e618a81 → cb8fbb10): a small pane/pacer refactor, a docstring fix that retires the prior review's open thread, and a new remount test.
- Moved the pane's
frameUristate into the stream hook —use-mobile-browser-streamnow ownsframeUriand returnsrenderedFrameSource;MobileBrowserPanedestructures it instead of keeping its own copy and passingsetFrameUridown. - Trimmed the pacer's guards —
drain's decode check dropped themountedUri !== null &&half, andattachImage's inner null check folded into its early return. Both are behavior-preserving: a layer only reachesloadingthroughwrite/attachImage, which already requiremountedUrinon-null. - Rewrote the pacer docstring to attribute the not-loading rule to the stream and document
replacewriting over a loading layer, addressing the prior review's inline note. - Clarified the tap-fallback comment — the move/down/up replay drops pointer modifiers, which is why a modifier tap also returns instead of replaying.
- Added a remount test — both
<Image>layers unmount (zero-size layout) and remount mid-decode, keeping the visible frame and the stream; the test's native model now loads the source a fresh<Image>mounts with.
I traced the state move and the two guard simplifications and found no behavioral difference, confirmed the dropped exports have no external references, and ran the three browser test files (20 tests) green locally. The prior review's open thread is resolved.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…#22392 (#22702) * test(mobile): repin the RPC recording corpus to main after #22392 #22392 pinned baseline to a branch commit (2c2b84c) that the squash left unreachable, so the recording-pin ancestry job failed on main and every PR. Repin to main's tip 80e0bee and re-record the whole corpus: all 787 goldens change only their baseline header, so no recorded behaviour moved. * test(mobile): re-measure the session route closure on main after #22392 Main reads 4219, not 4217: #22301 added two src/shared modules to the route without touching mobile/, so main was already two over when #22392 measured its -2 against a branch base that lacked them.

ELI5
The phone shows a browser tab by drawing a stream of screenshots. It loads each new picture in a hidden slot and swaps it in when it's ready. When pictures arrived faster than a slow phone could load them, each new one replaced the one still loading, so the swap never happened and the tab looked frozen while the page was busy. Now the phone finishes loading one picture before it starts the next. Separately, a tap that timed out could be sent a second time. The phone no longer re-sends a tap the computer may already have run.
What Changed
Frame pacing (freeze during page loads, menus, refresh).
<Image>layer and flipped layers ononLoad. While that decode ran, every newer frame re-pointed the same hidden layer, and the replaced load'sonLoadwas usually lost. If a decode took longer than the gap between frames (~100 ms), noonLoadarrived and the pane stayed on an old frame until the page went still. A pane re-render (pinch zoom, a toolbar toggle) also re-pointed both layers at the newest frame behind the frame code, which could freeze a blinking caret.browser-frame-pacer.ts, owns the double buffer: pacing, decode, flip and reset, with one timer (the 100 ms interval).<Image>holds and whether that source has answered (loading, ready, failed). A layer is written only when it is not loading and only with a different source, so every write gets exactly one native answer: an unchanged source reloads nothing on Android (ReactImageView.setSource) or iOS (ImageShadowNode), so the pacer never waits on one.onLoadsettles a layer only when its reportedsource.uriis the layer's current source. Android reports the view's current source at dispatch, so this drops loads dispatched before a newer write landed; it is not a general guard against every late load.<Image>sourceprop is only the frame the layers mount with; every later source write is the pacer's, so a re-render changes nothing on screen.Double taps.
browser.mouseClickfailed for any reason, including the 5 s timeout,sendPointerClickreplayed it asmouseMove/mouseDown/mouseUp. A timed-out click is still queued on the host, so the replay could land a second tap on whatever the first tap opened, such as a dropdown option.null), and a delivery-unknown error (isRpcDeliveryUnknown) is not replayed. Only a refusal replays.Why
onLoadoronError, and the web probe'sdecode()always settles, so nothing is left for a watchdog to catch.This is phone-only, with no wire change. The host-side cause of slow taps (a ~2 s wait on every browser command) was fixed separately in #22528, #22534 and #22526, which are already on main.
Linked Issue
STA-8024 (Linear). Related: STA-4128 (#16122), STA-7002.
Visual Proof
N/A: measured on an emulator rather than recorded (see Testing).
Testing
browser-frame-pacer.web.test.ts(the pacer against the web paint writers, asserting what each layer shows and which is visible): holds the newest frame while a layer decodes; shows a slow frame however long it takes, including a last frame with nothing newer; flips straight to a layer already holding the frame; skips a frame sent again after it failed and keeps streaming; settles a layer only for the source it holds; after a reset mid-decode, flips to the same frame when it is sent again, whether the decode settles before or after; keeps streaming after a layer remounts; paces to one frame per interval; reports each frame shown once.mobile-browser-pane-frame-layers.test.tsxmounts the real pane with a native model where a prop is whatever was written last, a same-source write loads nothing, and each change answers once with the layer's current source: a caret blinking across a re-render keeps blinking; a frame decoding when the app goes to the background is shown when sent again on return; a frame that failed and is sent again does not stop the stream (these two freeze on e859aa1); a remount of both Images mid-decode keeps the visible frame and the stream (fails with the remount re-arm or put-back deleted). Mutating the pacer's rules (decode gate, source check, interval, same-source flip, failed skip, remount re-arm and put-back) each fails at least one pacer or pane case.use-mobile-browser-commands-click-fallback.test.tsx: a delivery-unknown click is not replayed, an answered click with a null result is not replayed, and a refused click still is.Recording corpus re-recorded once (788 files):
baselinein every golden, the seed checkpoint renamedclicked-by-fallback→tap-settled(it no longer clicks by fallback in most partitions), and one behaviour change: the result-null partition ofmatrix-browser.pointer-click-browser.mouseclick-1sends only the click.Full mobile suite (876 files), mobile
tsc, tests-typecheck ratchet,oxfmt --check, oxlint,check:code-quality:changed, React Doctor changed-lines, the page-closure suites (session pin drops two modules, 4219 → 4217 after merging main: three modules replaced by one) and the real-page pane render suite (flips the double buffer in Chromium).Android emulator A/B (headless API 36 arm64, Pixel 7 profile, 4 cores, CPU throttled with in-emulator busy loops, host streaming ~10 frames/s, 60 s per run; temporary instrumentation, not committed). This AVD is faster than the first run's, so the freeze needed 24 loops:
Slow last frame: a page streams noise for 2.5 s, then stops on a final frame, which decodes in up to 2.4 s (12 loops) and 7.9 s (24 loops). This PR showed the final frame in 5/5 cycles at each load, every applied frame flipped (16/16) and no stale load. The watchdog revision it replaces, same setting: 41 applied, 11 flipped, 30 stale loads, final frame missed in 1 of 5 cycles.
Background/return on the emulator (24 busy loops; a 2.5 s noise burst ends on a green-centred frame, the app goes to the background while it decodes and returns 6 s later; pass = green 15 s after return): the previous head stayed on a burst frame for 45 s+ in 3 of 14 cycles; this PR in 1 of 14, and 1 of 26 with instrumentation. The instrumented miss logged no reset at all (the background and return never reached the stream effect) and the pacer flipping to the last frame it received, so it is not the path fixed here; its cause is not identified. The native-model test above is what certifies this fix.
Not verified: caret blink across a pinch on a device (screen sampling was too slow to read a 500 ms blink; covered by the pane test above); iOS (the source-uri check reads what RN 0.83's Fabric
ImageEventEmittersends, the prop string, but was not run); whether busy-loop throttling matches a real slow phone.AI Disclosure
Claude (Opus).
Notes
This overlaps with #16122 (STA-4128), which edits
MobileBrowserPane.tsxfor the reconnect case, and will need a rebase onto the pacer.Checklist
pnpm lint,pnpm typecheck,pnpm test,pnpm build(targeted locally; CI will cover the rest)