Skip to content

dogfood: nav scroll restoration animates under scroll-behavior: smooth #601

@vivek7405

Description

@vivek7405

Problem

When an app sets html { scroll-behavior: smooth } (a reasonable choice for in-page anchor links), the client router's navigation scroll-restoration becomes visibly animated instead of instant. The user watches the page scroll.

Reproduced on https://example-blog.webjs.dev :

  1. Scroll down the home page, click the "Hello World!" post link, and the new page visibly scrolls to the top instead of starting at the top.
  2. Click the browser Back button, and the home page visibly scrolls from the top down to the prior link position instead of restoring instantly.

Root cause: the router's programmatic scroll calls use the 2-argument window.scrollTo(x, y) form, which respects the CSS scroll-behavior. Native full-page navigation does not show this (the browser restores scroll before first paint), but the SPA router scrolls AFTER swapping the DOM, so the smooth rule animates it. Measured on the live site: a scrollTo(0,1500) climbs roughly 482, 675, 987, 1135, up to 1320 over about 400ms instead of jumping.

Design / approach

Force an explicit instant behavior at the nav scroll-restoration call sites so the router overrides the page's scroll-behavior and matches native navigation. Verified on the live site that the options form jumps instantly even with html{scroll-behavior:smooth} active:

window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
window.scrollTo({ top: cached.scrollX, left: cached.scrollY, behavior: 'instant' });

Leave the explicit hash-anchor scroll (t.scrollIntoView() at ~L1625) ALONE. A #section link is exactly the case where smooth scrolling is desired, and native browsers animate it too.

Implementation notes (for the implementing agent)

  • Where to edit: packages/core/src/router-client.js, three scroll-restoration call sites.
    • ~L805 popstate cache-hit restore: window.scrollTo(cached.scrollX, cached.scrollY) becomes the options form with behavior: 'instant'. Note the existing args: it passes scrollX as top and scrollY as left. Preserve that mapping exactly, do not "fix" it as part of this change. If it is actually transposed, that is a separate issue.
    • ~L821 cache-miss popstate scroll-to-top: window.scrollTo(0, 0).
    • ~L1626 / ~L1628 forward-nav scroll-to-top (inside fetchAndApply, the recordHistory block, both the no-hash branch and the hash-miss else): window.scrollTo(0, 0).
  • Do NOT change the t.scrollIntoView() hash-anchor branch at ~L1625.
  • Landmine: behavior: 'instant' (not 'auto') is required. 'auto' resolves to the computed CSS scroll-behavior, so it would still animate under a smooth stylesheet. 'instant' forces a jump regardless. Confirmed on the live blog.
  • Landmine: the 2-arg scrollTo(x, y) and the 1-arg options scrollTo({top, left, behavior}) are different overloads. Move to the options object, do not pass a third positional arg.
  • Invariant: packages/ is buildless plain JS plus JSDoc (root AGENTS.md). No .ts in core src.

Acceptance criteria

  • Forward nav and Back/Forward (popstate) restore scroll instantly even when the page sets html { scroll-behavior: smooth }
  • In-page hash-anchor navigation still scrolls (smooth when the app opts into it), not regressed
  • Browser test in packages/core/test/routing/browser/ drives a navigation under a scroll-behavior: smooth stylesheet and asserts scroll position settles within one frame (no multi-frame animation ramp)
  • Counterfactual: the test fails against the current 2-arg scrollTo and passes after the fix
  • Docs / AGENTS.md updated only if a public surface changed (none expected, internal behavior fix)

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions