Skip to content

fix(i18n): preserve reading context across locale changes - #82

Merged
devrodri-com merged 3 commits into
mainfrom
feat/devrodri-language-context-preservation
Aug 7, 2026
Merged

fix(i18n): preserve reading context across locale changes#82
devrodri-com merged 3 commits into
mainfrom
feat/devrodri-language-context-preservation

Conversation

@devrodri-com

Copy link
Copy Markdown
Owner

Summary

Fixes DevRodri UX-LANG-01: switching ES↔EN mid-page (Home Portfolio/FAQ, Services hub, LEM-BOX case) reset scroll to the top instead of staying in the equivalent section. Also hardens the pre-existing SPA_LANGUAGE_SWITCH_STALE_HEAD backlog item defensively.

Root cause (confirmed): NavigationFocusManager in src/App.tsx forced window.scrollTo(top:0) + focus(main) on every pathname change with no hash — it had no way to distinguish a locale switch from a normal page navigation, since a locale switch is a normal PUSH navigation to a different pathname with the same (usually empty) hash.

Fix:

  • RoutedLanguageProvider captures a section-relative scroll context (centered section id + progress within it, page-ratio fallback, top-anchor flag) from stable, locale-invariant DOM ids that already exist (portfolio, faq, services-directory, lem-box-ecosystem, etc.) before navigating, and tags the navigation with { localeSwitch: true, scrollContext } state.
  • NavigationFocusManager skips its forced top-scroll/focus only for that tagged state; normal navigation is untouched.
  • A new LocaleSwitchScrollRestoration component re-aligns the same section (double requestAnimationFrame, no smooth scroll) once the equivalent-locale page renders. An existing hash target still wins over section-relative restoration. Focus stays on the language toggle instead of jumping to main.
  • SeoHead now sets <Helmet defer={false}> to force synchronous head commits, closing a theoretical one-paint window where title/canonical could lag the new locale during an SPA switch.

No visible copy, design, routing, URLs, CSP, or dependencies changed.

Test plan

  • npm run typecheck / npm run lint — clean
  • npm run test:run — 32/32 suites, 343/343 tests (331 baseline + 12 new)
  • npm run build (client + SSR + prerender + verify-prerender + verify-http) — all pass
  • New src/lib/localeScroll.ts unit tests: section capture/restore math, top-anchor short-circuit, page-ratio fallback
  • New integration tests: mid-page scroll survives a locale switch (regression guard for the reported bug), top stays top, normal Navbar navigation still scrolls to top, hash target still wins over section restoration, mobile menu closes on switch, html lang/title/canonical/hreflang/OG/JSON-LD reconcile with no duplicates
  • Updated Semantics.test.tsx assertion: locale switch keeps focus on the language toggle (not main)
  • Physical iPhone review on the Preview URL (required before merge, per team process)

🤖 Generated with Claude Code

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.
Cover the new localeScroll capture/restore functions directly, plus
integration coverage through App: mid-page scroll survives a locale
switch, top stays top, normal Navbar navigation still scrolls to top,
an existing hash target still wins over section-relative restoration,
the mobile menu closes on switch, and html lang/title/canonical/OG/
hreflang/JSON-LD reconcile to the target locale with no duplicates.

Updates the pre-existing focus assertion in Semantics.test.tsx: a
locale switch now keeps focus on the language toggle instead of
jumping to main, matching the new contract.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
minisitio-rodrigo Ready Ready Preview Aug 7, 2026 5:03pm

Request Review

The DevRodri brand link is a plain react-router Link to the localized
home path. Clicking it from another route already worked (pathname
changes, so NavigationFocusManager's existing effect fires and scrolls
to top). But clicking it while already on the clean localized Home
produced no pathname or hash change at all, so that same effect
short-circuited and never scrolled - while React Router still silently
pushed a redundant history entry to the identical URL.

Navbar now intercepts the brand click only for that exact case
(current pathname/search/hash already equal the target Home) and
scrolls to top directly, with no navigation and no history push.
Every other click (different route, or a modified click for
new-tab/window) still falls through to the normal Link navigation
unchanged. The mobile menu is closed on every brand click. No
navigation state is attached, so a brand click is never treated as a
locale switch and never consumes a pending section-scroll snapshot
from a prior language switch.
@devrodri-com
devrodri-com marked this pull request as ready for review August 7, 2026 18:35
@cursor

cursor Bot commented Aug 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@devrodri-com
devrodri-com merged commit 93d4ed5 into main Aug 7, 2026
7 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bcddfc58cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/localeScroll.ts
Comment on lines +98 to +100
const el = document.getElementById(key);
if (el === null) continue;
const rect = el.getBoundingClientRect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Measure section containers instead of heading IDs

On the services, business-websites, custom-software, and LEM-BOX pages, the configured keys identify <h2> elements rather than their enclosing sections (for example, services-choose in ServicesHubPage.tsx). When the reader is below a heading, this calculation clamps sectionProgress to 1, and restoration centers the heading's bottom instead of preserving the position within the section, producing a potentially large jump toward the section start. Resolve each key to its enclosing section or place the IDs on the section containers before calculating progress.

Useful? React with 👍 / 👎.

Comment thread src/lib/localeScroll.ts
Comment on lines +65 to +67
function getNavbarHeight(): number {
const navbar = document.querySelector<HTMLElement>("[data-nojs-navbar]");
return navbar?.getBoundingClientRect().height ?? 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the open mobile panel from navbar height

During a mobile language switch, captureLocaleScrollContext runs while the dropdown is still open because the language handler calls setLanguage before closeMenu. The selected navbar element therefore includes the entire expanded panel, while restoration measures the collapsed navbar; the two viewport centers can differ by hundreds of pixels and shift the reader after switching languages. Measure only the persistent top bar or capture after the mobile panel has closed.

AGENTS.md reference: AGENTS.md:L4-L11

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant