Skip to content

chore: add guarded on-device levers to A/B the iOS back-swipe blank - #1430

Draft
vivek7405 wants to merge 3 commits into
mainfrom
fix/ios-back-swipe-snapshot-ab
Draft

chore: add guarded on-device levers to A/B the iOS back-swipe blank#1430
vivek7405 wants to merge 3 commits into
mainfrom
fix/ios-back-swipe-snapshot-ab

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Re #1428. Deliberately NOT Closes: see "What this PR does not do" below.

The iOS edge back-swipe still previews a blank page on webjs.dev /blog and
/compare, and #1410 is not the explanation. The live site serves core 0.7.51,
8b90f95b is an ancestor of the release commit b15bbb40, and the fix is
visible in the served bundle as the one-shot thunk handed into applySwap
rather than a push after it. Both reported routes take the replace tier from a
scrolled index into a shorter child, which is the exact shape #1406 measured,
and website/ has no loading.{js,ts}, so the one documented partial-coverage
gap does not apply either.

What #1410 never had is the on-device check. Its own body says so: "Not
verified, and it cannot be from here: the on-device iOS check ... the acceptance
criterion asking for it is still open." Everything green on it ran in Chromium,
Firefox, and headless WebKit, none of which renders a gesture preview. So the
report is the first real verification of that fix, and it says correct ordering
was necessary and is not sufficient.

The assumption still untested

#1406 assumed WebKit binds the gesture snapshot synchronously at the
pushState call. The competing hypothesis is that it does not:
didSameDocumentNavigationForFrame posts to the UI process, which then captures
the current compositing surface, and that IPC lands after the whole
push -> swap -> scrollTo(0,0) task. Under that hypothesis the surface captured
is the destination at offset 0, moving the push a few statements earlier changes
nothing a device can observe, and the fix needs a composited frame rather than a
reorder.

I am not implementing against that hypothesis. #1109 cost a PR and three issues
by building against an unmeasured mechanism, and #610 cost five failed
header-CSS attempts and a GPU-promotion attempt the same way. So this ships the
candidate timings behind default-off levers and lets the device decide, which is
the method that finally isolated #610.

What ships

Three levers, each a separate query param so a run moves one variable:

  • ?raf waits one animation frame between the push and the DOM mutation.
  • ?raf2 waits two, since a single rAF can land before the commit WebKit needs.
  • ?scrolllast defers the scroll-to-top past the frame, isolating the clamp
    from the swap.

website/app/layout.ts captures them into window.__webjsDiag once on load,
inline and in the head so it lands before the router boots. Captured once
because the param is gone from the URL the moment the first soft nav replaces
it.

Why the yield is in the caller, not at the four commit points

#1428 asks for the yield at all four swap.js commit points. applySwap is
synchronous and its return value is consumed synchronously, so it cannot await,
and making it async to host a diagnostic would be a far larger change than the
thing being diagnosed.

The one-shot thunk makes that unnecessary. Calling recordHistoryNow() in
fetchAndApply fires the push for whichever commit point the swap goes on to
reach and leaves that call a no-op, so every path gets the same ordering rather
than the subset I remembered to edit. It also keeps the yield off the background
paths (a revalidation, a refresh, the popstate restore), which record no entry
and so have no snapshot to compose.

Test plan

Run in a linked worktree with packages/core/dist REBUILT there, not symlinked
from the primary, so the e2e and dist consumers exercise this branch's bundle.
The lever is present in the built browser bundle, so a deployed run really gets
it.

  • Unit, whole repo: 4466 of 4473 pass. Four new in
    packages/core/test/routing/router-client.test.js covering the inert
    default, one lever reading true without its siblings, the null return when
    there is no rAF (the unit runner's shim, which is the case the call sites
    guard), and the double-frame count.
  • Browser, whole suite: 77 files, green on all three engines, twice. New file
    packages/core/test/routing/browser/nav-swipe-ab-levers.test.js carries the
    headline assertion, that a frame boundary falls between the push and the
    mutation under a lever and does NOT without one, plus the ?scrolllast
    deferral and its supersede guard, alongside the fix: record the history entry before the swap, not after #1410 test it must not
    disturb.
  • e2e, blog: 95 of 96 pass.
  • Dogfood, website booted in prod mode: 200 on /, /blog, /compare,
    /docs/client-router, /ui, /ui/button, with no broken modulepreload
    hints (50 on /ui/button) and the lever capture present in every page.
    npx webjs check clean.
  • Bun: N/A. This is browser-only client-router code plus one app layout, and it
    touches no listener, serializer, SSR dispatch, stream, or node:* surface.

The six failures, none of them from this branch

All six are linked-worktree artifacts. I checked each by restoring
packages/core/src/router-client/ and website/app/layout.ts to origin/main
in this same worktree, against the same built dist, and every one still fails
there: three differential-elision assertions,
test/bun/listener.test.mjs, the Bun listener-overhead assertion, and one
asset() prod-handler assertion. The e2e failure is the same story, prefetch: hovering a FRAME link warms the subtree (#1407), which also fails at
origin/main here.

One that WAS from this branch, and what it cost to find

Three full browser runs reported a single Firefox failure in an unrelated file,
a Back restore survives late layout growth (#1310) > the window closes once the restore is over. It passed 23 of 23 in isolation, and my first read was
that it was load-dependent flake, on the strength of those isolated runs and of
#1410 having recorded unreproduced Firefox failures in the same suite.

That read was wrong, and the isolated runs are exactly why it was wrong. A
baseline run of the whole browser suite at origin/main in this worktree is
GREEN, which is the check that settles it, and the two isolated-run signals
could not have. Bisecting the branch against that baseline put it in the new
test FILE rather than the source: the supersede assertion drove its second
navigation with a fetch that never settled, so a navigation stayed in flight
for the rest of the page's life holding the router's token and its own frame
state, and under full-suite concurrency that leak reached another file. Fixed
in cf5519c0 by rejecting
with an AbortError, the shape the router already treats as a superseded
navigation. Suite green twice after.

Worth keeping the sequence in view: the two files pass when run TOGETHER, so
nothing short of the full concurrent suite reproduces it, and "passes in
isolation" was never evidence of anything.

What this PR does not do

It does not fix the bug, and it must not be read as fixing it. The deliverable
in #1428 is the on-device verdict, which needs a real iPhone, so the issue stays
open after this merges and the reference above is a plain #1428 rather than a
Closes. Merging a diagnostic under a Closes is how #1410 came to be recorded
as resolving a symptom it had never been tested against.

The levers are temporary. They come out, or one of them is promoted to real
behaviour, in the follow-up once the device has answered.

#1410 moved the history push ahead of the DOM mutation and merged with its iOS
acceptance criterion openly unmet, because the gesture preview exists only on a
real iPhone. The blank survived there, so the assumption behind that fix is
still untested: that WebKit binds the back-forward snapshot synchronously at
the pushState call. If it instead captures the compositing surface when the
didSameDocumentNavigation IPC lands in the UI process, that happens after the
whole push-swap-scroll task, and reordering inside the task changes nothing the
device can see.

Rather than guess again, ship the two candidate timings behind default-off
levers and let the device choose, which is the method that finally isolated
#610. ?raf and ?raf2 hand WebKit one or two frames to paint the outgoing page
between the push and the swap; ?scrolllast defers the scroll-to-top past the
frame to isolate the clamp from the swap.

The yield sits in fetchAndApply rather than at the four commit points inside
applySwap, because applySwap is synchronous and cannot await. The thunk is
one-shot, so firing it in the caller covers whichever commit point the swap
reaches and leaves that call a no-op, which is the same ordering at every one
of them rather than at a chosen few.
@vivek7405 vivek7405 self-assigned this Aug 18, 2026
The ?scrolllast lever defers the scroll-to-top by a frame, which puts it
outside the navigation's own task. A newer navigation can start in that frame,
and the deferred callback would then scroll ITS page: worst on the hash branch,
where scrollIntoView hunts the old URL's anchor in the new document and lands
somewhere arbitrary if that id happens to exist.

The synchronous path cannot do this, so the lever was adding a failure mode
rather than isolating one, and a diagnostic that exists to measure scroll
behaviour must not write scroll into a page it has nothing to do with.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Went through the whole diff against the router's own invariants. The shape holds up: the levers are inert unless an app writes window.__webjsDiag, the yield is tied to recordHistoryNow being non-null so no background path picks up a frame of latency, and putting the yield in the caller rather than at the four swap.js commit points is the right call given applySwap is synchronous and its return value is consumed synchronously. Making it async to host a diagnostic would be a bigger change than the thing being diagnosed.

One real problem, on the ?scrolllast path, commented inline.

Two things I checked and am satisfied with. The early return after the yield sits inside the try whose finally calls clearFrameBusy, so it cannot leak a busy frame, and it matches the supersede guard directly above it. And the claim in the comment that a discard can never see a non-null thunk holds: revalidating: true is passed at exactly one call site, navigator.js:583, which passes recordHistory: false.

Worth restating what this does NOT do, because it is the part that is easy to misread from the diff: it fixes nothing. The levers exist so the device can tell us whether the snapshot is bound at the push or at a later composited frame, and until that run happens we do not know which. That is why the body references #1428 without a Closes.

Comment thread packages/core/src/router-client/fetch-apply.js
The supersede assertion drove its second navigation with a fetch that never
settled, which left a navigation in flight for the rest of the page's life
holding the router's token and its own frame state. Under the full browser
suite that leak reded an unrelated file, the #1310 back-restore residue
assertion, on Firefox, while both files passed in isolation and while the
branch's own file passed everywhere.

Rejecting with an AbortError settles the navigation down the path the router
already takes for a superseded one, so the assertion observes the same thing
with nothing left running. Full browser suite green twice at this commit,
against a baseline that was green before the file was added.
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