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.
| 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 byapp/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 returnsnull/solution/...— same shape as/bug/..., plus<ScrollReset>/solution-alt/...— no[[...slug]]/layout.tsx, everything inpage.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".
Every scroll-related observation in this repro is explained by exactly two App Router behaviors, both running on every client-side navigation:
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.
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-nextexists on both/werbungand/advertising): nofocusinevent 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 = elTopYof 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.
| 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 |
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.
Two probes designed to localize the bug:
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=2565preserved 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.
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):
scrollYpreserved exactly.activeEl=a#layout-next— focus persists on the clicked element, because the layout cacheNode is reused and React reuses the DOM node directly. Nofocusinevent between click and commit.
| 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.
/solution-alt/... demonstrates a fix that uses no scroll restoration shim — the bug is eliminated structurally. Two structural choices:
- No
[[...slug]]/layout.tsx. The catch-all has onlypage.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.tsxsegment, finds a populatedscrollRefon the page segment, and scrolls to top normally. - 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/*viausePathname().
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.
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):ScrollResetstays 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: theisFirstRenderskip 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 —usePathnamedoesn't change, the effect doesn't run.
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.
pnpm install
pnpm build
pnpm startOpen http://localhost:3000/ for the landing page with links to both subtrees.
Open DevTools console first — ScrollLogger will log click, focusin, and nav-commit events.
- Bug — same-
[[...slug]]in-segment click. Hard-reload/bug/werbung. Scroll to the mid-page section. Click#layout-next(→/bug/advertising).- Expected:
scrollYpreserved at click position. No[focusin]. No[scroll-reset].
- Expected:
- 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].
- Expected:
- Bug — regular
[slug]click. Hard-reload/bug/autoren/david-scheider. Scroll to mid-page section. Click#author-next.- Expected:
scrollY=0at nav-commit. Regular[slug]works correctly even without the shim.
- Expected:
- 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=0at nav-commit. Bug fixed.
- Expected:
- Solution — same-
[[...slug]]footer click. Hard-reload/solution/werbung. Scroll to footer. Click#footer-next-solution-advertising.- Expected:
[focusin] el=a#page-nextstill fires (Phenomenon B still active under the hood), but the[scroll-reset]runs immediately after andnav-commitshowsscrollY=0.
- Expected:
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.
segment-cache/navigation.ts:894-895—activeScrollRefbecomesnullwhen no new segments are created during the navigation. (Phenomenon A.)layout-router.tsx:553-554—if (scrollRef === null || !scrollRef.current) return;— bails before any scroll attempt. (Phenomenon A.)focusAndScrollinlayout-router.tsx— moves focus to the new segment's content after commit;element.focus()triggers implicitscrollIntoView. (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.
Ruled out empirically through this repro:
- react-aria-components. Both
<Link>fromnext/linkand react-aria's<Link>(via<RouterProvider navigate={router.push}>) bail identically. - React Compiler. Disabled
reactCompiler: true; same behavior. force-dynamicvs 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: noneglobally; thescrollY=1670landing was unchanged. Not anchoring. - Disappearing clicked element. Removed the footer's
usePathnamefilter so the clicked link persists on the destination; focus still moved to#page-nextand scroll still landed at 1670. The focus move is Next's ownfocusAndScroll, not browser fallback after unmount.
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.
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.