From 65daf11be590cda977697efd676d521630a9d9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 20:40:23 +0200 Subject: [PATCH] fix(ui): reset scroll to top on forward navigation app-wide The authenticated shell keeps a single
mounted across every route change, so its scroll offset persisted between routes: scrolling down in one section (e.g. Settings) and navigating to another opened the next view already scrolled. Reset the scroll container to the top on each pathname change, with a window.scrollTo fallback for the body-scrolled shells. Back/forward navigation skips the reset so the browser's own scroll restoration stands: popstate arms a one-shot skip cleared on a macrotask so it can never stick. --- src/components/layout/auth-shell.tsx | 37 +++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 7197b3e21..bd31f3475 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -2,7 +2,7 @@ import { Loader2 } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier"; import { MaintainershipBanner } from "@/components/i18n/maintainership-banner"; import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; @@ -91,6 +91,41 @@ export function AuthShell({ }; }, [isBodyScrolled]); + // v1.25.1 — the authenticated shell keeps `
` + // mounted across every route change, so its scroll offset survives + // navigation: scroll down in one section (e.g. Settings → Sources + // Priority), hop to another, and the new view opens already scrolled. + // Reset the scroll container to the top on every forward navigation. + // A back/forward navigation skips the reset so the browser's own scroll + // restoration stands: `popstate` arms a one-shot skip for the single + // pathname change it triggers, and a macrotask clears the flag so it can + // never get stuck on a later forward navigation. + const skipNextScrollReset = useRef(false); + useEffect(() => { + const armSkip = () => { + skipNextScrollReset.current = true; + setTimeout(() => { + skipNextScrollReset.current = false; + }, 0); + }; + window.addEventListener("popstate", armSkip); + return () => window.removeEventListener("popstate", armSkip); + }, []); + + useEffect(() => { + if (skipNextScrollReset.current) { + skipNextScrollReset.current = false; + return; + } + const main = document.getElementById("main-content"); + if (main) { + main.scrollTop = 0; + } + // Fallback for the body-scrolled shells (login / onboarding / standalone + // legal pages) where the document, not `
`, owns the scroll. + window.scrollTo(0, 0); + }, [pathname]); + useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { // Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe