Skip to content

Repository files navigation

Next.js App Router scroll-restoration repro

A minimal Next.js 16 app that isolates why same-app navigation sometimes does not scroll to the top of the new route, even though the documented default for router.push / <Link> is { scroll: true }. The repo contains both a bug reproduction and a working fix, side by side, so you can compare them directly.

The repro pins the root cause to two independent Next.js App Router behaviors that compound on the same navigation, and rules out react-aria-components, react-compiler, static-vs-dynamic rendering, link element type, scroll anchoring, and disappearing-element focus loss as causes.

Upstream: vercel/next.js#49427.


Five route trees side by side

Subtree [[...slug]]/layout.tsx? Where the click target lives Behavior
/bug/... yes, with focusables layout AND page bug fires; scroll lands at click position OR at first focusable in page segment (depending on click source)
/bug-alt/... yes, markup only — no focusables page only bug fires; scroll preserved exactly, activeEl=body (focus lost)
/bug-alt-alt/... yes, with focusables layout only (page.tsx returns null) bug fires; scroll preserved exactly. Clicked element persists in the reused layout, focus stays on it.
/solution/... yes layout AND page <ScrollReset> mounted in solution/layout.tsx; scroll resets to 0 on every forward nav within /solution/*
/solution-alt/... no — page.tsx only page only (incl. in-page footer) scroll resets to 0 — no reused layout segment for Phenomenon A's bail to trigger on

The three /bug* subtrees together prove the bug fires regardless of where the click target sits (layout, page, or both) — the only structural property that matters is whether [[...slug]]/layout.tsx exists at all.

Routes in each subtree:

  • /bug/werbung, /bug/advertising — served by app/bug/[[...slug]]/{layout,page}.tsx (layout has focusables)
  • /bug/autoren/david-scheider, /bug/autoren/sven-wagenknecht — regular [slug] control case
  • /bug-alt/werbung, /bug-alt/advertising — layout has spacers + decorative section, zero focusables; page has the only link
  • /bug-alt-alt/werbung, /bug-alt-alt/advertising — layout has everything including the link; page returns null
  • /solution/... — same shape as /bug/..., plus <ScrollReset>
  • /solution-alt/... — no [[...slug]]/layout.tsx, everything in page.tsx

The footer (in the root layout, always visible) renders all routes in 5 columns labeled "BUG", "BUG-ALT", "BUG-ALT-ALT", "SOLUTION", "SOLUTION-ALT".


TL;DR — the two phenomena

Every scroll-related observation in this repro is explained by exactly two App Router behaviors, both running on every client-side navigation:

Phenomenon A — scrollRef bail on catch-all cacheNode reuse (the bug)

When two URLs are served by the same [[...slug]] (or [...slug]) catch-all, the segment cache reuses the existing cacheNode rather than creating a new one. No new segment is created → no scrollRef is pushed onto activeScrollRef. The scroll handler at layout-router.tsx:553 then early-returns: if (scrollRef === null || !scrollRef.current) return;. Result: Next's scroll-to-top does not fire.

Catch-all only. Regular dynamic [slug] (e.g. autoren/[slug]) builds a fresh cacheNode per param value → scrollRef is populated → scroll-to-top fires normally.

Phenomenon B — focusAndScroll after every navigation

After commit, App Router runs accessibility-related focus management: it moves focus to the first focusable descendant of the new segment's content (so screen readers announce the new route). element.focus() with default options triggers an implicit scrollIntoView(block: "start"), which sets scrollY = elementTop.

Two sub-cases for whether this is visible to the user:

  • Focus persists on the clicked element (the clicked element is reconciled into the new render with the same id, e.g. #layout-next exists on both /werbung and /advertising): no focusin event fires, focus management is a no-op, scrollY does not change due to focus.
  • Focus must move (the previously-focused element is not in the new segment — e.g. clicking a link in the root-layout footer): focus jumps to the new segment's first focusable element, browser scrolls that element to top of viewport, scrollY = elTopY of whatever was picked.

B is gated by the same scrollRef state as A. The /bug-alt experiment confirmed this: with no focusables in layout.tsx, clicking the page.tsx link preserves scrollY exactly AND leaves activeEl=body (focus lost, not moved to the next focusable). Neither A nor B fires. So when A bails on the catch-all reuse, B bails too — the "lands at #page-next y-position" cases in /bug happen only because the layout also had focusable elements that interact with B's selection logic differently.

How A and B combine

Click pattern A bails? B moves focus? Net scrollY after commit
#layout-next on /bug/werbung/bug/advertising (same [[...slug]], focused element persists, layout HAS focusables) yes no preserved at click position
#footer-next-* on /bug/werbung/bug/advertising (same [[...slug]], focused element outside slug segment, layout HAS focusables) yes yes → #page-next scrollY ≈ #page-next y-position (≈ 1670 in this repro)
#page-next on /bug-alt/werbung/bug-alt/advertising (same [[...slug]], layout has NO focusables, page has link) yes no (B bails alongside A) preserved at click position; activeEl=body (focus lost when page segment re-renders)
#layout-next on /bug-alt-alt/werbung/bug-alt-alt/advertising (same [[...slug]], layout has link, page returns null) yes no (B bails alongside A) preserved at click position; activeEl=a#layout-next (focus persists — layout cacheNode is reused so the DOM node is reused)
#author-next on /bug/autoren/david/bug/autoren/sven (regular [slug], focused element persists) no, scrolls to 0 no 0
Any same-subtree link under /solution/... (irrelevant — <ScrollReset> overrides) (irrelevant) 0
Any same-segment link under /solution-alt/... (incl. in-page footer) no — there's no reused [[...slug]]/layout segment for the bail to trigger on no — click target persists in same id 0

Caveats

Cached-cacheNode reuse on revisit. If you've already visited a route in the current tab, its cacheNode sits in the segment cache. Navigating back to it (even from a different segment tree) may reuse the cached node and trigger the same A-bail. Hard-reload to test the bug subtree cleanly. (Solution subtree is unaffected for within-/solution/* navigation — ScrollReset runs on pathname change. But cross-subtree entry into /solution/* may still inherit a bailed scroll — see "Tradeoff" in the ScrollReset section below.)

The cause is the layout segment, not its contents. It's tempting to think "removing the focusable links from layout.tsx will fix the bug" — it won't. The /bug-alt subtree confirms this empirically: layout has spacers and decorative markup with zero focusables, and the bug still fires (scrollY preserved exactly, focus lost to body). The only structural fix is to remove layout.tsx from the catch-all segment entirely (see /solution-alt). If the layout file is doing real work (async data fetches, providers, etc.), you'd need to migrate that work into page.tsx or a parent layout outside the catch-all.


The /bug-alt and /bug-alt-alt experiments

Two probes designed to localize the bug:

/bug-alt: layout has markup but no focusables; page has the link

app/bug-alt/[[...slug]]/layout.tsx is the BUG layout with all focusables removed — no <a>, no <button>, no tabIndex. Just <h1>, gradient spacers, and a decorative <section> containing <h2> and <p>. The page.tsx has the only focusable in the entire slug subtree (the in-page link).

Logged result of clicking the page.tsx link near the bottom:

[focusin] el=a#page-next elTopY=3269 scrollY=2565
[click]   id=page-next scrollY=2565 scrollHeight=3925
[nav-commit] pathname=/bug-alt/werbung scrollY=2565 activeEl=body
[+raf]    scrollY=2565 activeEl=body
[+100ms]  scrollY=2565 activeEl=body
[+500ms]  scrollY=2565 activeEl=body
  • scrollY=2565 preserved exactly — Phenomenon A bails.
  • activeEl=body — Phenomenon B did NOT move focus to #page-next. Focus was lost when the page segment re-rendered (the clicked link's DOM node was re-created), and B didn't replace it.

/bug-alt-alt: layout has the link; page returns null

app/bug-alt-alt/[[...slug]]/layout.tsx renders everything including the click target. page.tsx is just return null.

Predicted result (same A-bail mechanism):

  • scrollY preserved exactly.
  • activeEl=a#layout-next — focus persists on the clicked element, because the layout cacheNode is reused and React reuses the DOM node directly. No focusin event between click and commit.

What the three /bug* subtrees together prove

Subtree Layout has focusables? Layout has the click target? Page renders content? A bails? Focus after commit
/bug yes yes yes yes persists on clicked layout element
/bug-alt no no yes (only link) yes lost to body
/bug-alt-alt yes yes no (null) yes persists on clicked layout element

The bug fires in all three. The variable that changes — focusables in layout, click target location, page content — has no effect on whether A bails. The bail is caused by the [[...slug]]/layout.tsx segment existing and being reused. That's the only structural variable left, and it's exactly what /solution-alt removes to fix the bug.


The alternative strategy: solution-alt

/solution-alt/... demonstrates a fix that uses no scroll restoration shim — the bug is eliminated structurally. Two structural choices:

  1. No [[...slug]]/layout.tsx. The catch-all has only page.tsx. With no layout segment above the page in the slug subtree, Phenomenon A's bail has nothing to bail on — Next walks past the (reused) /solution-alt/layout.tsx segment, finds a populated scrollRef on the page segment, and scrolls to top normally.
  2. Every clickable element lives inside page.tsx — internal nav, hash links, and an in-page footer with cross-route links. The global root-layout <Footer> hides itself on /solution-alt/* via usePathname().

Why both choices: #1 alone fixes the catch-all bail. #2 ensures the same id exists on the destination page (same page.tsx, different params), so React reconciles the click target into the same DOM node, focus persists, and Phenomenon B's focusIntoView doesn't fire either. Together, scroll goes to 0 cleanly with no shim and no extra effects.

Tradeoff: you cannot have any persistent [[...slug]]/layout.tsx for the catch-all — the moment you reintroduce it, the bail returns. For BTC-Echo's structure (where catch-all layouts do meaningful work, e.g. running Sanity fetches that wrap the page), removing them is a bigger refactor than mounting <ScrollReset>. The <ScrollReset> shim is the lower-risk fix; solution-alt is the cleaner one if you can restructure.


The primary fix: <ScrollReset>

Located at src/app/scroll-reset.tsx. Mounted in src/app/solution/layout.tsx so its effect is scoped to /solution/*.

"use client";

import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";

export function ScrollReset() {
    const pathname = usePathname();
    const isFirstRender = useRef(true);
    const isPopstate = useRef(false);

    useEffect(() => {
        const onPop = () => { isPopstate.current = true; };
        window.addEventListener("popstate", onPop);
        return () => window.removeEventListener("popstate", onPop);
    }, []);

    useEffect(() => {
        if (isFirstRender.current) { isFirstRender.current = false; return; }
        if (isPopstate.current)    { isPopstate.current = false; return; }
        if (window.location.hash)  return; // let browser scroll to #anchor
        window.scrollTo({ top: 0, left: 0 });
    }, [pathname]);

    return null;
}

Behavior summary:

  • Forward navigation within /solution/* (e.g. /solution/werbung → /solution/advertising): ScrollReset stays mounted, the effect fires on the pathname change, window.scrollTo(0, 0).
  • Back / forward (popstate): no-op — let the browser restore the prior scroll position.
  • Reload of any /solution/* page: the isFirstRender skip means we DO NOT override the browser's scroll restoration on mount. Reloading at scrollY=2000 stays at scrollY=2000 (browser default).
  • Cold load to /solution/werbung: same as reload — first render skipped, browser handles initial scroll (which is 0 for fresh cold loads).
  • Hash navigation across routes (e.g. /foo → /bar#section): no-op — let the browser scroll to the anchor.
  • Same-route hash navigation (e.g. /foo → /foo#section): no-op by construction — usePathname doesn't change, the effect doesn't run.

Tradeoff: cross-subtree entry into /solution/* is not covered

When the user navigates from /bug/* or /solution-alt/* into /solution/*, <ScrollReset> mounts fresh. The isFirstRender skip then prevents the reset from firing on that entry — because the same skip protects reload/cold-load behavior, and we can't distinguish "cold mount on reload" from "mount due to cross-subtree click" with this design.

So cross-subtree entries into /solution/* may inherit whatever scroll position Phenomenon A leaves them at if the target's catch-all cacheNode is already cached. This is the price of correct reload behavior.

The footer's emoji rule reflects this conservatively: all 🚨 markings reflect navigations that definitely exhibit the bug in a typical session. Cross-subtree entries into /solution/* are not marked 🚨 because they often work (fresh cacheNode → Next scrolls to 0); they're a known edge case in the README rather than a flagged failure.

This single shim covers both next/link and react-aria <Link> clicks, because both end up calling router.push and both update the pathname. It also masks Phenomenon B's visible scroll: even if focusAndScroll moves focus to #page-next and the browser scrolls it into view, our scrollTo(0, 0) runs immediately after and overrides it.


Reproduction guide

Setup

pnpm install
pnpm build
pnpm start

Open http://localhost:3000/ for the landing page with links to both subtrees.

Comparison test sequence

Open DevTools console first — ScrollLogger will log click, focusin, and nav-commit events.

  1. Bug — same-[[...slug]] in-segment click. Hard-reload /bug/werbung. Scroll to the mid-page section. Click #layout-next (→ /bug/advertising).
    • Expected: scrollY preserved at click position. No [focusin]. No [scroll-reset].
  2. Bug — same-[[...slug]] footer click. Hard-reload /bug/werbung. Scroll to footer. Click #footer-next-bug-advertising.
    • Expected: [focusin] el=a#page-next, then [nav-commit] scrollY≈1670. No [scroll-reset].
  3. Bug — regular [slug] click. Hard-reload /bug/autoren/david-scheider. Scroll to mid-page section. Click #author-next.
    • Expected: scrollY=0 at nav-commit. Regular [slug] works correctly even without the shim.
  4. Solution — same-[[...slug]] in-segment click. Hard-reload /solution/werbung. Scroll to mid-page section. Click #layout-next (→ /solution/advertising).
    • Expected: [scroll-reset] scrolling to 0, scrollY=0 at nav-commit. Bug fixed.
  5. Solution — same-[[...slug]] footer click. Hard-reload /solution/werbung. Scroll to footer. Click #footer-next-solution-advertising.
    • Expected: [focusin] el=a#page-next still fires (Phenomenon B still active under the hood), but the [scroll-reset] runs immediately after and nav-commit shows scrollY=0.

The visual contrast between steps 1+2 vs steps 4+5 is the single-line story for the upstream issue: same content, same navigation paths, single <ScrollReset> component makes the difference.


Mechanism (Next.js 16.2.6 source pointers)

  • segment-cache/navigation.ts:894-895activeScrollRef becomes null when no new segments are created during the navigation. (Phenomenon A.)
  • layout-router.tsx:553-554if (scrollRef === null || !scrollRef.current) return; — bails before any scroll attempt. (Phenomenon A.)
  • focusAndScroll in layout-router.tsx — moves focus to the new segment's content after commit; element.focus() triggers implicit scrollIntoView. (Phenomenon B.)

For /bug/werbung → /bug/advertising, the segment-cache walk concludes that the [[...slug]] slot "already exists" — different param value, same slot — and reuses the cacheNode. For /bug/autoren/david → /bug/autoren/sven, the same walk concludes the [slug] slot needs a fresh cacheNode (one node per resolved param). Different outcome from the same cache, same scroll handler.


What is NOT the cause

Ruled out empirically through this repro:

  • react-aria-components. Both <Link> from next/link and react-aria's <Link> (via <RouterProvider navigate={router.push}>) bail identically.
  • React Compiler. Disabled reactCompiler: true; same behavior.
  • force-dynamic vs static prerender. Same behavior either way.
  • Where the link is rendered (layout vs page vs footer). All three trigger Phenomenon A for same-[[...slug]] navigation. The visible outcome (preserved vs lands-at-1670) differs only because of Phenomenon B's focus-persists-vs-must-move sub-case.
  • The <RouterProvider> integration pattern. Removed; same behavior with plain <Link>.
  • Chrome scroll anchoring. Set overflow-anchor: none globally; the scrollY=1670 landing was unchanged. Not anchoring.
  • Disappearing clicked element. Removed the footer's usePathname filter so the clicked link persists on the destination; focus still moved to #page-next and scroll still landed at 1670. The focus move is Next's own focusAndScroll, not browser fallback after unmount.

File map

src/app/
├── layout.tsx                       root: Providers + ScrollLogger + main + Footer
├── page.tsx                         landing page with links to /bug and /solution
├── providers.tsx                    react-aria <RouterProvider navigate={router.push}>
├── scroll-logger.tsx                client: logs click, focusin, nav-commit, +raf/+100ms/+500ms
├── scroll-reset.tsx                 the fix — pathname-effect scroll reset (popstate- and hash-aware)
├── footer.tsx                       client footer with BUG + SOLUTION column groups (always all 8 routes)
├── aria-link.tsx                    wrapper around react-aria <Link>
├── bug/
│   ├── [[...slug]]/
│   │   ├── layout.tsx               same shape as live-btcecho infos layout (has focusables)
│   │   └── page.tsx                 first focusable inside the slug segment
│   └── autoren/[slug]/page.tsx      regular [slug] (control case, works correctly)
├── bug-alt/
│   └── [[...slug]]/
│       ├── layout.tsx               same shape but NO focusables — proves the bug is the layout segment, not its content
│       └── page.tsx                 the only focusable link in this subtree
├── bug-alt-alt/
│   └── [[...slug]]/
│       ├── layout.tsx               everything (incl. the click target) lives here
│       └── page.tsx                 returns null
├── solution/
│   ├── layout.tsx                   mounts <ScrollReset> — the only difference from /bug
│   ├── [[...slug]]/
│   │   ├── layout.tsx               identical content to bug version, different label / link targets
│   │   └── page.tsx
│   └── autoren/[slug]/page.tsx
└── solution-alt/
    ├── layout.tsx                   no shim, just a wrapper
    ├── [[...slug]]/page.tsx         everything (incl. in-page footer) inside page.tsx — NO layout.tsx in this segment
    └── autoren/[slug]/page.tsx      everything inside page.tsx

Pinned to next@16.2.6, react@19.2.4, react-aria-components@^1.13.


Where to apply this in live-btcecho-v1

apps/infos/src/app/layout.tsx and apps/authors/src/app/layout.tsx — mount <ScrollReset> near the top of the root layout (inside <RouteProvider> is fine). Remove the pendingScrollReset flag logic from packages/ui/src/providers/router-provider.tsx<ScrollReset> supersedes it and also covers next/link clicks, which the flag-based approach did not.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages