You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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 A/B needs a deployed surface: website/ is where the reported symptom
is, so read the params there.
Landmines
iOS-WebKit-only and invisible to every harness we have. It does not
reproduce in Chromium, Firefox, headless WebKit, DevTools device emulation,
or Playwright. A green suite says nothing here. This is precisely how fix: record the history entry before the swap, not after #1410
shipped believing it was done.
webjs.dev deploys are gated: per project_railway_blog_watchpaths_skip_framework
the example-blog service excludes packages/*, so a framework-only merge
can leave the live bundle stale and railway redeploy rebuilds the OLD
commit. Verify a lever actually reached the device by grepping the served
core bundle for it before drawing any conclusion from a run.
The ordering contract from fix: record the history entry before the swap, not after #1410 stays: the push fires ahead of the mutation
at every commit point, and the thunk stays one-shot with the fetch-apply.js
tail call intact so non-committing paths behave byte for byte as today.
Tests + docs
packages/core/test/routing/browser/nav-history-before-swap.test.js is the fix: record the history entry before the swap, not after #1410 headline browser assertion (outgoing DOM and outgoing scrollY both
still live at the push). Any lever must keep it green.
packages/core/test/routing/router-client.test.js carries the four unit
tests for the callback firing before the mutation and not firing on
non-committing paths.
Docs only if a lever ships as real behaviour. fix: record the history entry before the swap, not after #1410's own doc-gate lesson
applies: four surfaces enumerate the swap sequence
(website/app/docs/client-router/page.ts, website/app/docs/suspense/page.ts, packages/core/src/router-client/constants.js, blog/client-router-turbo-drive-style.md), and an ordering change that is
observably nothing still contradicts a how-it-works list.
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
Problem
The iOS edge back-swipe still previews a blank page for the whole gesture on
webjs.dev, reported on/blogand/compare. #1410 (Closes #1406) isdeployed and is NOT the explanation: the live site serves core
0.7.51,8b90f95bis an ancestor of the release commitb15bbb40, and the fix isvisible in the served bundle at
/__webjs/core/dist/webjs-core-browser.js?v=c53faf447633as the one-shot thunkhanded into
applySwap, not a push after it:Both reported routes take the shape #1406 measured as broken.
/blogserves103KB with keyed boundaries
wj:children:/blog:/blog,/compare37KB withwj:children:/compare:/compare; navigating into/blog/[slug]or/compare/[slug]changes the route-key at that segment, so the swap is thereplace tier, which is the tier where #1406's instrumentation caught the
clamp (
scrollY1600 to 252 at the push). The website has noloading.{js,ts}anywhere under
website/app/, so the one documented partial-coverage gap infetch-apply.js:286-292(applyOptimisticLoadinghaving already replaced therange) 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.pushStatecall. The competing hypothesis is that it does not:didSameDocumentNavigationForFrameposts from the web process to the UIprocess, 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:310then:353/:358), sothe 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
requestAnimationFramelever from #641, written in #637 and reverted in #640without 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 threeissues 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).
?rafwaits onerequestAnimationFramebetweenrecordHistoryNow()and theDOM mutation at each commit point.
?raf2waits a double-rAF there (one rAF can fire before the commit WebKitneeds; the double is the standard "after next paint" idiom).
?scrolllastdefers thescrollTo(0,0)to after the snapshot window ratherthan in the same task, isolating the clamp from the swap.
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
/blogscrolled,/comparescrolled, and an unscrolled control, recorded onthis issue.
Implementation notes (for the implementing agent)
Anchors verified at
720cadc8. Re-derive ifmainhas moved; #1365 barrelledpackages/core/src/router-client.jsintopackages/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-310is the one-shotrecordHistoryNowthunk and theapplySwapcall it is handed to. Thetail call that covers the non-committing paths is
:333, and the forwardscroll-to-top block is
:349-358.packages/core/src/router-client/swap.jsis where the thunk actually fires,and there are four commit points, not one:
:269,:327,:350,:412,each immediately ahead of a
runWithTransitionat:281,:355,:450. Alever that yields at only one of them will read as a partial result on the
device and waste the round. The
recordHistoryNowJSDoc at:49states whatthe current contract does and does not claim.
packages/core/src/router-client/navigator.jsfor the popstate side:scrollRestoration = 'manual'at:110-112(restored at:140),snapshotCurrent(currentPageUrl)at:462, and the cached-restore branchfrom
:487. WebJs's own snapshot cache is correct and is why the backBUTTON works; do not "fix" it.
git show c1d388d5(chore: on-device isolation flags for the #610 iOS nav repaint (diagnostic) #637) has thediagFlag(name)helper readingwindow.__webjsDiag, plus the app-side linethat 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) isthe 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.website/is where the reported symptomis, so read the params there.
Landmines
reproduce in Chromium, Firefox, headless WebKit, DevTools device emulation,
or Playwright. A green suite says nothing here. This is precisely how fix: record the history entry before the swap, not after #1410
shipped believing it was done.
class of mistake costing a full investigation).
diagnostic to production.
forward nav, already fixed by
position: fixedin fix: use position:fixed for the blog header to end the iOS nav flicker (#610) #640, and its fix does nottouch this.
webjs.devdeploys are gated: perproject_railway_blog_watchpaths_skip_frameworkthe
example-blogservice excludespackages/*, so a framework-only mergecan leave the live bundle stale and
railway redeployrebuilds the OLDcommit. Verify a lever actually reached the device by grepping the served
core bundle for it before drawing any conclusion from a run.
history.scrollRestorationis'manual', and dogfood: iOS back-swipe gesture flashes a blank page (client router) #641 flagged that as asuspect. It is worth a lever only if the three above come back negative;
flipping it regresses dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 / fix: back-button restore survives late layout growth #1313 scroll restoration.
Invariants
packages/is plain.jswith JSDoc, never.ts(AGENTS.md).unchanged. dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 and fix: back-button restore survives late layout growth #1313 are the regression guards.
at every commit point, and the thunk stays one-shot with the
fetch-apply.jstail call intact so non-committing paths behave byte for byte as today.
Tests + docs
packages/core/test/routing/browser/nav-history-before-swap.test.jsis thefix: record the history entry before the swap, not after #1410 headline browser assertion (outgoing DOM and outgoing
scrollYbothstill live at the push). Any lever must keep it green.
packages/core/test/routing/router-client.test.jscarries the four unittests for the callback firing before the mutation and not firing on
non-committing paths.
test/e2e/form-submission-and-race.test.mjsholds the dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 back-buttonrestore guard plus the fix: record the history entry before the swap, not after #1410 sibling.
applies: four surfaces enumerate the swap sequence
(
website/app/docs/client-router/page.ts,website/app/docs/suspense/page.ts,packages/core/src/router-client/constants.js,blog/client-router-turbo-drive-style.md), and an ordering change that isobservably nothing still contradicts a how-it-works list.
Acceptance criteria
?raf,?raf2,?scrolllast) fire at ALLFOUR
swap.jscommit points, with production behaviour byte-identicalwhen no param is set
served core bundle rather than assumed from a merge
this issue for
/blogscrolled,/comparescrolled, and an unscrolledcontrol
including a negative result if no lever fixes it
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)
nav-history-before-swap.test.js,router-client.test.js,form-submission-and-race.test.mjs)fix PR rather than left in the nav path indefinitely