Skip to content

feat(router): the default document restores scroll on back/forward - #948

Merged
ivogt merged 1 commit into
mainfrom
feat/default-document-scroll-restoration
Sep 28, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/default-document-scroll-restoration

Conversation

@ivogt

@ivogt ivogt commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

An app that passes no document to createRouter gets the router's default document. After #937 it rendered Html.Meta and Html.Scripts, but not Html.ScrollRestoration. So the zero-config app, and every scaffold that relies on the default, left back/forward scrolling to the browser. That means no saved position per history entry, and no restore after a cache-miss refetch (#943). An app had to write a custom document just to get scroll restoration.

The default document now renders every Html member, matching the create-rango templates (rangojs/templates#1).

Before / after

// before: components/DefaultDocument.tsx (what `document`-less apps render)
<html lang="en" suppressHydrationWarning>
  <head>
    <MetaTags />
    <Scripts />
  </head>
  <body>
    <Scripts position="body" />
    {children}
  </body>
</html>
// after
<html lang="en" suppressHydrationWarning>
  <head>
    <MetaTags />
    <Scripts />
  </head>
  <body>
    <Scripts position="body" />
    {children}
    <ScrollRestoration />
  </body>
</html>

(MetaTags / Scripts / ScrollRestoration are the components behind Html.Meta / Html.Scripts / Html.ScrollRestoration; the default document imports them directly.)

End-to-end example

// src/router.tsx: no `document` option
import { createRouter, urls } from "@rangojs/router";
import { Link } from "@rangojs/router/client";

export const router = createRouter({
  urls: urls(({ path }) => [
    path("/", () => <Link to="/blog">Blog</Link>, { name: "home" }),
    path("/blog", () => <h1>Blog</h1>, { name: "blog" }),
  ]),
});

Scroll down on /, open /blog (lands at the top), press back: / returns to where it was left. Before this change the browser decided, and after a cache-miss refetch it usually landed at the top.

To keep the browser's own behavior, or to pass getKey, provide a document:

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

const byPathname = (location: { pathname: string }) => location.pathname;

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

Semantics

App setup Before After
No document browser-owned back/forward scroll (history.scrollRestoration = "auto") router restores per history entry ("manual", positions in sessionStorage)
No document, plus <Html.ScrollRestoration /> in a layout router restores unchanged (the layout's instance mounts first and owns getKey), plus a [Scroll] Already initialized warning from the default document's instance. The docs now point these apps to a custom document.
Custom document with Html.ScrollRestoration router restores unchanged
Custom document without it browser-owned unchanged
New navigation / hash target scroll to top / hash unchanged

Code change

  • src/components/DefaultDocument.tsx: renders <ScrollRestoration /> after {children}, imported directly like the other two members; JSDoc says how to opt out or set getKey.
  • e2e/mini/src/router.tsx: drops the layout's <Html.ScrollRestoration /> and the Html import. mini uses the default document, so its own instance would now be a second mount. mini's scroll e2e now runs against the default document's instance. client.tsx comment and mini README updated.
  • src/router/router-options.ts: the document JSDoc describes the default document's content.
  • Docs: the canonical Document snippet gains <Html.ScrollRestoration /> wherever it says "the default is exactly this" (getting-started, createRouter reference, tutorial project setup, README, router-setup skill), with how to opt out or set getKey. The "render Html.ScrollRestoration in the root layout" guidance (client-urls guide, useScrollRestoration reference, docs/client-urls.md) now says it renders once in the document, which the default document already does.
  • docs/internal/feature-map.md: the Html note records that DefaultDocument renders all three members.
  • CHANGELOG.md: Unreleased entry.
  • tools/check-bundle-guards.mjs: router-chunk ratchet 44 KB -> 45 KB (see Notes).

Test plan

  • Unit (userland, renderRoute) src/components/__tests__/default-document.test.tsx "turns on scroll restoration out of the box": rendering DefaultDocument sets history.scrollRestoration to "manual", and unmounting restores "auto".
    • Red check: without the change, Expected: "manual" Received: "auto".
  • E2e e2e/default-document-scroll.test.ts, test-app (no document), dev + production: manual mode after hydration, scroll to 1200, router-navigate to /blog (0), back (1200), forward (0).
    • Red check (dev): without the change, Expected: "manual" Received: "auto".
  • mini scroll e2e (dev + production), now served by the default document's instance: manual mode, evicted-entry back keeps state and scroll, forward restores.
  • Full router e2e, dev + production (test-app, mini, e2e-basic, prerender-onerror and the rest): 2317 passed, 0 failed, 0 flaky (14.5m).
  • no-typescript e2e (default document): 21 passed.
  • typecheck, test:unit:all, lint, format, check:docs-api, check:skills, check:skills-catalog, check:e2e-bucketing, check:e2e-parity --strict.
  • cloudflare-basic passes its own document, so the default document does not apply there; test-app is this change's app-level coverage.

Notes

  • fix(router): leave back/forward scroll to the browser when it owns restoration #923 overlap: fix(router): leave back/forward scroll to the browser when it owns restoration #923's e2e/back-forward-scroll.test.ts ("back/forward scroll without ScrollRestoration") runs against test-app, which now has scroll restoration from the default document. Whichever PR merges second moves that router-suite case to an app with a custom document that does not render Html.ScrollRestoration. Its cloudflare-basic case is unaffected. Both PRs also touch hooks.mdx and CHANGELOG.md.
  • Behavior change for zero-config consumers: apps on the default document switch from browser-owned to router-owned back/forward scrolling. That fixes the lost position after a cache-miss refetch, but it is a change they will see.
  • Coverage shift: mini's server layout was the only e2e where a server module rendered an Html member. That case is still pinned at unit level by src/testing/__tests__/html-namespace.rsc-test.tsx.
  • Bundle (every app): +346 B gzip / +1.14 kB raw on the client router chunk, measured in CI on cloudflare-basic (45,002 B on main -> 45,348 B). It applies to apps with their own document too: router.ts imports DefaultDocument as the document fallback, so it is in every app's client build, and its ScrollRestoration import keeps initScrollRestoration in the eager router chunk. The bundle-guards ratchet is raised 44 KB -> 45 KB (tools/check-bundle-guards.mjs, reasoning recorded next to the earlier re-baselines). The alternative, lazy-loading the init out of browser/scroll-restoration.ts (whose module state the navigation bridge shares), was judged not worth a chunk request for ~0.35 KB.

@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@948

commit: ac77314

An app that passes no `document` to createRouter renders DefaultDocument,
which rendered Html.Meta and Html.Scripts but not Html.ScrollRestoration, so
zero-config apps left back/forward scrolling to the browser: no saved
position per history entry, and none after a cache-miss refetch. It now
renders <ScrollRestoration /> after {children}, like the create-rango
templates. An app keeps browser-owned scrolling, or sets getKey, by passing
its own document.

mini (default document) drops its layout's own Html.ScrollRestoration,
which would now be a second mount; its scroll e2e runs against the default
document's instance.

Tests: default-document.test.tsx (renderRoute) pins manual mode on mount and
auto on unmount (fails without the change: "auto"); new test-app e2e
default-document-scroll.test.ts, dev + production, pins back/forward
restore with no custom document (fails without the change in dev).

Docs: the canonical Document snippet gains <Html.ScrollRestoration />
wherever it states the default's structure (getting-started, createRouter
reference, tutorial, README, router-setup skill), with how to opt out or set
getKey; "render it in the root layout" guidance now says it renders once, in
the document. router-options JSDoc, feature-map, CHANGELOG.
@ivogt
ivogt force-pushed the feat/default-document-scroll-restoration branch from df4ebcf to ac77314 Compare September 28, 2026 16:07
@ivogt
ivogt merged commit afced80 into main Sep 28, 2026
43 checks passed
@ivogt
ivogt deleted the feat/default-document-scroll-restoration branch September 28, 2026 16:18
ivogt added a commit that referenced this pull request Sep 28, 2026
Unreleased -> 0.17.0 (2026-09-28) in CHANGELOG.md, and the #948 entry gains its PR link.
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