Skip to content

dogfood: iOS back-swipe still blank after #1410; A/B the snapshot timing on-device #1428

Description

@vivek7405

Problem

The iOS edge back-swipe still previews a blank page for the whole gesture on
webjs.dev, reported on /blog and /compare. #1410 (Closes #1406) is
deployed and 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 at
/__webjs/core/dist/webjs-core-browser.js?v=c53faf447633 as the one-shot thunk
handed into applySwap, not a push after it:

let he=!1,Me=n?()=>{he||(he=!0,history.pushState(null,"",h))}:null,Dt=st(L,t,!!c,h,p,y,l,Me)

Both reported routes take the shape #1406 measured as broken. /blog serves
103KB with keyed boundaries wj:children:/blog:/blog, /compare 37KB with
wj:children:/compare:/compare; navigating into /blog/[slug] or
/compare/[slug] changes the route-key at that segment, so the swap is the
replace tier, which is the tier where #1406's instrumentation caught the
clamp (scrollY 1600 to 252 at the push). The website has no loading.{js,ts}
anywhere under website/app/, so the one documented partial-coverage gap in
fetch-apply.js:286-292 (applyOptimisticLoading having already replaced the
range) does NOT apply here. The symptom is the full defect, on the exact tier
the fix targeted, with the fix live.

#1410 was merged with its on-device acceptance criterion openly unmet. Its
PR body states it verbatim: "Not verified, and it cannot be from here: the
on-device iOS check. The gesture preview only exists on a real iPhone, so the
acceptance criterion asking for it is still open." Everything green on that PR
was Chromium / Firefox / headless-WebKit, none of which renders a gesture
preview. So this issue is the first real on-device verification of #1410, and
the result is negative: correct ordering was necessary and is not sufficient.

Design / approach

The premise to test first, before writing any fix. #1406 assumed WebKit
binds the back-forward gesture snapshot to page state synchronously at the
history.pushState call. The competing hypothesis is that it does not:
didSameDocumentNavigationForFrame posts from the web process to the UI
process, which then captures the current compositing surface. That IPC lands
after the current task completes, and the current task is
push -> swap -> scrollTo(0,0) (fetch-apply.js:310 then :353 / :358), so
the surface captured is still the destination document at offset 0. Under this
hypothesis, moving the push a few statements earlier within the same task
changes nothing the device can observe, which is exactly what is being
reported.

If that holds, the fix is a frame yield between the push and the swap so
WebKit composites the outgoing page before the snapshot is taken. That is the
requestAnimationFrame lever from #641, written in #637 and reverted in #640
without ever being isolated against the back-swipe. Note the placement differs
from #637's: that lever waited a rAF before the swap at a time when the push
still ran AFTER it, so re-introducing it verbatim tests a different ordering
than the one that exists today.

This issue's deliverable is the measurement, not the fix. Per
feedback_verify_the_premise_before_building, #1109 cost a PR plus three
issues by implementing against an unmeasured mechanism, and #610 cost five
failed header-CSS attempts plus a GPU-promotion attempt for the same reason.
The A/B decides which lever ships; the fix PR is separate work filed once the
device answers.

The on-device A/B

Reuse the #637 pattern, which is the method that finally cracked #610: guarded
levers defaulting OFF, so the production nav path is byte-identical unless a
query param opts in, driven from a real iPhone in both Safari and Chrome (both
WebKit).

  • ?raf waits one requestAnimationFrame between recordHistoryNow() and the
    DOM mutation at each commit point.
  • ?raf2 waits a double-rAF there (one rAF can fire before the commit WebKit
    needs; the double is the standard "after next paint" idiom).
  • ?scrolllast defers the scrollTo(0,0) to after the snapshot window rather
    than in the same task, isolating the clamp from the swap.
  • Control: no param, current behaviour.

Each lever is a separate param so a run isolates one variable. The exit
condition is a per-lever verdict from the device (blank / not blank) on
/blog scrolled, /compare scrolled, and an unscrolled control, recorded on
this issue.

Implementation notes (for the implementing agent)

Anchors verified at 720cadc8. Re-derive if main has moved; #1365 barrelled
packages/core/src/router-client.js into packages/core/src/router-client/,
so any anchor from a pre-#1365 issue body points at the wrong file.

Where to edit

  • packages/core/src/router-client/fetch-apply.js:301-310 is the one-shot
    recordHistoryNow thunk and the applySwap call it is handed to. The
    tail call that covers the non-committing paths is :333, and the forward
    scroll-to-top block is :349-358.
  • packages/core/src/router-client/swap.js is where the thunk actually fires,
    and there are four commit points, not one: :269, :327, :350, :412,
    each immediately ahead of a runWithTransition at :281, :355, :450. A
    lever that yields at only one of them will read as a partial result on the
    device and waste the round. The recordHistoryNow JSDoc at :49 states what
    the current contract does and does not claim.
  • packages/core/src/router-client/navigator.js for the popstate side:
    scrollRestoration = 'manual' at :110-112 (restored at :140),
    snapshotCurrent(currentPageUrl) at :462, and the cached-restore branch
    from :487. WebJs's own snapshot cache is correct and is why the back
    BUTTON works; do not "fix" it.
  • The diag-flag plumbing to copy: git show c1d388d5 (chore: on-device isolation flags for the #610 iOS nav repaint (diagnostic) #637) has the
    diagFlag(name) helper reading window.__webjsDiag, plus the app-side line
    that reads the query params into it on load. git show 02fbb845 (fix: use position:fixed for the blog header to end the iOS nav flicker (#610) #640) is
    the revert, i.e. exactly what to re-apply. It lived in the pre-split
    router-client.js, so it needs re-homing into the split modules.
  • The A/B needs a deployed surface: website/ is where the reported symptom
    is, so read the params there.

Landmines

Invariants

Tests + docs

Acceptance criteria

  • Guarded, default-OFF levers (?raf, ?raf2, ?scrolllast) fire at ALL
    FOUR swap.js commit points, with production behaviour byte-identical
    when no param is set
  • The levers are live on a deployed surface, verified by grepping the
    served core bundle rather than assumed from a merge
  • On a real iPhone, in Safari AND Chrome, a per-lever verdict recorded on
    this issue for /blog scrolled, /compare scrolled, and an unscrolled
    control
  • The verdict states plainly whether the async-snapshot hypothesis held,
    including a negative result if no lever fixes it
  • Back button, forward nav, and back/forward scroll restoration unchanged
    on-device (dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 / fix: back-button restore survives late layout growth #1313 not regressed)
  • Existing router tests stay green (nav-history-before-swap.test.js,
    router-client.test.js, form-submission-and-race.test.mjs)
  • The levers are removed, or promoted to real behaviour, in the follow-up
    fix PR rather than left in the nav path indefinitely

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions