Skip to content

fix(router): leave back/forward scroll to the browser when it owns restoration - #923

Merged
ivogt merged 1 commit into
mainfrom
fix/client-urls-scroll-restore
Sep 28, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/client-urls-scroll-restore

Conversation

@ivogt

@ivogt ivogt commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

In apps whose own document doesn't render <Html.ScrollRestoration> (the default document renders it since #948), going back to a long page could land at the top instead of where the user was. The browser restores back/forward scroll itself in that setup (history.scrollRestoration === "auto"), but the router also called scrollTo(0, 0) after every popstate, and the two raced. Desktop Chromium and WebKit apply their restore after the router's call, so the page ends up in the right place there. When the browser restores first, the router's call wins and the page jumps to the top. Reported on a production storefront in mobile Safari (PLP -> PDP -> back lands at the top of the PLP); the instrumented live page shows the router's scrollTo(0,0) right after popstate in both desktop engines.

Two related paths brought the same scroll-to-top back:

  • bfcache. The bridge's pageshow handler set history.scrollRestoration = "manual" after every bfcache restore, including in apps without <Html.ScrollRestoration>. After a user left the site and came back with Back (a payment redirect, say), native restoration was off and the router scrolled to the top again.
  • Entries created before <Html.ScrollRestoration> mounted. Those stay "auto", so the browser restores them. With restoration initialized but no saved position for the entry, the router still fell through to the top.

Before / after

Same app, same navigation: scroll a long list to Y=1500, open a detail page, press back. No <Html.ScrollRestoration>.

Before After
Router scroll calls after popstate scrollTo(0, 0) none
Final position the browser's restore or the top, whichever lands last the browser's restore (1500)
After a bfcache round trip scrollRestoration forced to "manual": no native restore, router scrolls to top stays "auto": the browser restores

No consumer code changes. Router-managed restoration comes from the default document (#948), or from rendering it in your own document:

// src/document.tsx
"use client";
import type { ReactNode } from "react";
import { Html } from "@rangojs/router/client";

export function Document({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Html.Meta />
        <Html.Scripts />
      </head>
      <body>
        <Html.Scripts position="body" />
        {children}
        <Html.ScrollRestoration />
      </body>
    </html>
  );
}

Semantics

Navigation <Html.ScrollRestoration> Entry mode Scroll behavior
Forward (push/replace) either either unchanged: top, or the hash target; scroll: false keeps position
Back/forward not mounted "auto" changed: the router does nothing; the browser restores
Back/forward not mounted "manual" (set by the app) unchanged: top / hash
Back/forward mounted, saved position any unchanged: restores the saved position
Back/forward mounted, no saved position "auto" (entry predates the mount) changed: the browser restores
Back/forward mounted, no saved position "manual" unchanged: top / hash
bfcache restore (pageshow, persisted) mounted unchanged: back to "manual" (now done by initScrollRestoration)
bfcache restore (pageshow, persisted) not mounted changed: stays "auto"

Code change

  • src/browser/scroll-restoration.ts
    • handleNavigationEnd({ restore: true }): after trying a saved position, it returns without scrolling when the destination entry is "auto". It checks the entry's mode rather than initialized, so an app that sets "manual" itself keeps the old top/hash fallback.
    • initScrollRestoration now registers the pageshow handler that re-takes "manual" after a bfcache restore, next to the pagehide handler that hands scroll to the browser. Only apps that opted in get it.
  • src/browser/navigation-bridge.ts: the pageshow handler only aborts the stale navigation; it no longer touches scrollRestoration.
  • tests/shared-e2e/src/index.ts: expectBackLeavesScrollToBrowser, the shared e2e body. Same precedent as expectStreamedBoundariesAdopted.

Test plan

  • Unit, scroll-restoration.test.ts:
    • handleNavigationEnd in auto mode does not scroll on back/forward.
    • A forward navigation still scrolls to the top.
    • Manual mode without restoration still scrolls to the top.
    • An "auto" entry with restoration initialized is left to the browser.
    • A saved position is restored.
    • bfcache: back to "manual" with <Html.ScrollRestoration>; unchanged without it.
    • The global mode is reset in afterEach.
  • Unit, navigation-bridge.test.ts: pageshow (persisted) aborts the stale navigation and leaves scrollRestoration alone.
  • Red without the source change: 3 unit tests fail (auto entry with restoration initialized, bfcache with <Html.ScrollRestoration>, bridge pageshow).
  • E2e, dev + production, cloudflare-basic (/ -> /about, a custom document without <Html.ScrollRestoration>): back-forward-scroll.test.ts.
    • A spy on scrollTo, scroll, scrollBy and scrollIntoView asserts the router makes no scroll call after popstate.
    • The browser's native restore lands at 1500.
    • Red without the fix: ["scrollTo([0,0])"] recorded after popstate, in all 4 runs (test-app + cloudflare-basic, dev + production, before the rebase).
  • Regression, dev + production, after the rebase onto feat(router): the default document restores scroll on back/forward #948:
    • router suite default-document-scroll + mini (both on the default document's <Html.ScrollRestoration>): 36 dev / 37 production passed.
    • cloudflare-basic back-forward-scroll, navigation, location-state: 14 dev / 14 production passed.
  • typecheck, test:unit:all, lint, format, check:e2e-bucketing, check:e2e-parity --strict.

No userland (@rangojs/router/testing) test: renderRoute does not model scrolling or history traversal. The bfcache path is unit-only: Playwright doesn't give a reliable bfcache round trip.

Notes

@pkg-pr-new

pkg-pr-new Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@923

commit: 67014a4

@ivogt
ivogt force-pushed the fix/client-urls-scroll-restore branch from aa614c8 to f436c83 Compare September 28, 2026 11:32
@ivogt ivogt changed the title fix(router): keep the scroll position on back (no router scroll over native restore; save before a clientUrls() optimistic swap) fix(router): leave back/forward scroll to the browser when it owns restoration Sep 28, 2026
@ivogt
ivogt force-pushed the fix/client-urls-scroll-restore branch from f436c83 to f47615d Compare September 28, 2026 11:58
@ivogt
ivogt force-pushed the fix/client-urls-scroll-restore branch from f47615d to 78c86bc Compare September 28, 2026 15:13
…storation

Without <Html.ScrollRestoration> the browser restores back/forward scroll itself
(history.scrollRestoration "auto"), but handleNavigationEnd({ restore: true })
fell through to scrollToTop() after every popstate. The two raced: desktop
engines restore after the router's scrollTo(0, 0), so the page ended up in
the right place, while a browser that restores first lost to it and landed
at the top (reported in mobile Safari, PLP -> PDP -> back). On back/forward
the router now leaves any "auto" entry to the browser; that also covers
entries created before <Html.ScrollRestoration> mounted.

The bridge's pageshow handler set scrollRestoration = "manual" after every
bfcache restore, even for apps that never mounted <Html.ScrollRestoration>, which
switched native restoration off and brought the scroll-to-top back. Taking
scroll back after bfcache now lives in initScrollRestoration, next to the
pagehide handler that hands it to the browser.

Tests: unit (handleNavigationEnd branches, bfcache mode with and without
<Html.ScrollRestoration>, bridge pageshow), and dev + production e2e in
cloudflare-basic (its own document renders no Html.ScrollRestoration) through
a shared expectBackLeavesScrollToBrowser body (@shared/e2e) that spies on
every scroll API after popstate. No test-app case: since the default document
renders Html.ScrollRestoration, test-app can no longer run without it.
@ivogt
ivogt force-pushed the fix/client-urls-scroll-restore branch from 78c86bc to 67014a4 Compare September 28, 2026 16:19
@ivogt
ivogt merged commit 6003268 into main Sep 28, 2026
43 checks passed
@ivogt
ivogt deleted the fix/client-urls-scroll-restore branch September 28, 2026 16:29
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