From 92f227995ab39f070e6b50f9d36ddf079faa5abc Mon Sep 17 00:00:00 2001 From: Rodrigo Opalo Balog Date: Fri, 7 Aug 2026 11:09:36 -0400 Subject: [PATCH 1/3] feat(i18n): preserve reading context across locale changes NavigationFocusManager forced window.scrollTo(top:0) and focus(main) on every non-hash pathname change, including ES<->EN switches mid-page, because it could not tell a locale switch from a normal navigation. RoutedLanguageProvider now captures a section-relative scroll context (centered section id + progress, page-ratio fallback, top-anchor flag) before navigating to the equivalent-locale route and tags the navigation with locale-switch state. NavigationFocusManager skips its forced top-scroll/focus for that state, and a new LocaleSwitchScrollRestoration component re-aligns the same section after the equivalent page renders (existing hash targets still take priority). Focus is left on the language toggle instead of being moved to main. SeoHead now commits Helmet updates synchronously (defer={false}) to close a theoretical one-paint window where title/canonical could lag the new locale. --- src/App.tsx | 40 ++++++++- src/Components/SeoHead.tsx | 2 +- src/i18n/LanguageProvider.tsx | 7 ++ src/lib/localeScroll.ts | 157 ++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 src/lib/localeScroll.ts diff --git a/src/App.tsx b/src/App.tsx index 5d06118..bd93899 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,10 @@ import { getHashTarget, userPrefersReducedMotion, } from "./lib/navigationFocus"; +import { + isLocaleSwitchNavigationState, + restoreLocaleScrollContext, +} from "./lib/localeScroll"; const PortfolioPage = lazy(() => import("./pages/PortfolioPage")); const LemBoxCasePage = lazy(() => import("./pages/LemBoxCasePage")); @@ -181,6 +185,7 @@ function NavigationFocusManager({ isNotFound }: { isNotFound: boolean }) { }; if (location.hash === "") { + if (isLocaleSwitchNavigationState(location.state)) return; focusElement(main); scrollMainToTop(); return; @@ -225,7 +230,39 @@ function NavigationFocusManager({ isNotFound }: { isNotFound: boolean }) { observer.disconnect(); window.clearTimeout(timeoutId); }; - }, [isNotFound, location.hash, location.pathname, navigationType]); + }, [isNotFound, location.hash, location.pathname, location.state, navigationType]); + + return null; +} + +function LocaleSwitchScrollRestoration() { + const location = useLocation(); + const navigationType = useNavigationType(); + const frameIds = useRef([]); + + useEffect(() => { + frameIds.current.forEach((id) => window.cancelAnimationFrame(id)); + frameIds.current = []; + + if (navigationType === "POP") return; + if (location.hash !== "") return; + if (!isLocaleSwitchNavigationState(location.state)) return; + + const { scrollContext } = location.state; + + const firstFrame = window.requestAnimationFrame(() => { + const secondFrame = window.requestAnimationFrame(() => { + restoreLocaleScrollContext(scrollContext); + }); + frameIds.current.push(secondFrame); + }); + frameIds.current.push(firstFrame); + + return () => { + frameIds.current.forEach((id) => window.cancelAnimationFrame(id)); + frameIds.current = []; + }; + }, [location.hash, location.key, location.state, navigationType]); return null; } @@ -317,6 +354,7 @@ function App({ +