Skip to content

fix(i18n): make locale sticky across client navigations#1407

Open
james-elicx wants to merge 4 commits into
mainfrom
fix/issue-1336-i18n-sticky-locale
Open

fix(i18n): make locale sticky across client navigations#1407
james-elicx wants to merge 4 commits into
mainfrom
fix/issue-1336-i18n-sticky-locale

Conversation

@james-elicx
Copy link
Copy Markdown
Member

Refs #1336 (item 2).

Summary

Locale stickiness for the Pages Router client runtime. Three behaviours that previously diverged from Next.js:

  1. History state was empty ({}). Next.js writes { url, as, options, __N: true, key } on every pushState/replaceState (see Router.changeState around L1916). Without this shape, popstate handlers can't tell our entries apart from third-party history writes and can't recover the active locale on back/forward.

  2. Popstate ignored stale state filtering. Next.js drops a popstate event when it's the first one observed and its state.options.locale + state.as match the current page (Safari tab-restore / BFCache replay). vinext's handler always re-fetched.

  3. Popstate re-detected locale from window.__VINEXT_LOCALE__ rather than the locale recorded in the history entry, so back/forward across locale changes used the wrong fetch URL.

This PR:

  • Updates updateHistory() in packages/vinext/src/shims/router.ts to write the Next.js-shaped state, populated with the active locale resolved via the existing resolveTransitionLocale helper.
  • Adds an isFirstPopStateEvent flag and an isNextRouterState guard in the popstate handler. Foreign state (no __N: true) is ignored. A first-event replay with matching locale+as is ignored. state.options.locale is preferred over the window global when computing the popstate fetch URL.
  • Carries the key field through replaces (preserving the prior key) and mints a new key on each push.

Next.js parity references

Local clone at .nextjs-ref:

  • packages/next/src/shared/lib/router/router.tsonPopState (L920–999) and Router.changeState (L1900–1931).
  • test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts and without-i18n.test.ts — exercise the stale-state filter, including the "Don't ignore event with different locale" branch.
  • test/e2e/i18n-preferred-locale-detection/i18n-preferred-locale-detection.test.ts — covers the client-side stickiness behaviour we're trying to match (Link to / from a non-locale-prefixed page must not flip the locale to the browser preference).

Tests added

tests/pages-router-i18n-sticky-locale.test.ts — 7 tests across three describe blocks:

  • Pages Router history state shape (3 tests): asserts Router.push writes { __N: true, options: { locale } } and that the locale slot is populated from the active locale, explicit locale: 'fr', or defaultLocale when locale: false.
  • Pages Router popstate stale-state filter (i18n parity) (3 tests): first popstate matching current locale+as is ignored; first popstate with a different locale is processed; foreign state (no __N) is ignored.
  • Pages Router locale stickiness on programmatic navigation (1 test): Router.push with no explicit locale carries the current locale in history state.

I also relaxed 5 existing assertions in tests/shims.test.ts (in the "Pages Router router helpers" and "Pages Router concurrent navigation" blocks) that pinned pushState to {} — they now use expect.objectContaining({ __N: true }) so the assertions stay focused on what they're actually testing (URL normalisation / locale-qualified fetch) without depending on the empty-state shape.

Scope boundaries with parallel PRs

  • Item 3 (API-route locale stripping): nothing touched. No changes under packages/vinext/src/server/api* or route matching.
  • Item 4 (default-locale prefix normalisation): nothing touched. packages/vinext/src/config/config-matchers.ts, pages-i18n.ts, dev-server.ts, and prod-server.ts are unchanged.
  • NEXT_LOCALE cookie write on server-side detection: explicitly out of scope. The existing parseCookieLocaleFromHeader already gives the cookie priority over Accept-Language. Whether vinext should automatically Set-Cookie: NEXT_LOCALE on initial detection is a separate question — Next.js itself doesn't do this (cookie is user-set, e.g. by a language switcher), and moving it would overlap with item 4's redirect work. Documented as a follow-up.

Verification

vp test run tests/pages-router-i18n-sticky-locale.test.ts   # 7/7 pass
vp test run tests/shims.test.ts                              # 944/944 pass
vp test run tests/link.test.ts tests/link-navigation.test.ts \
            tests/pages-router.test.ts tests/pages-router-concurrency.test.ts \
            tests/pages-i18n.test.ts tests/pages-i18n-prod.test.ts \
            tests/router-javascript-urls.test.ts tests/features.test.ts
# 1036/1036 pass

vp check packages/vinext/src/shims/router.ts \
         tests/pages-router-i18n-sticky-locale.test.ts \
         tests/shims.test.ts
# clean

Refs #1336 (item 2).

Writes a Next.js-compatible history state shape on every router
push/replace, captures the active locale in `state.options.locale`, and
teaches the popstate handler to:

1. Ignore history entries written by third-party code (no `__N: true`).
2. Drop the first popstate when it carries the same locale + `as` as the
   current page (Safari tab-restore / BFCache replay parity).
3. Honour the locale recorded in history state when re-fetching the page
   on back/forward, so default-locale roots still go through their
   locale-qualified HTML endpoint.

This matches the Next.js behaviour exercised by
`test/e2e/ignore-invalid-popstateevent` and the client half of
`test/e2e/i18n-preferred-locale-detection`.

Scope boundary: the server-side `Set-Cookie: NEXT_LOCALE=...` on initial
Accept-Language detection is intentionally NOT modified here. The
existing `parseCookieLocaleFromHeader` already gives the cookie priority
over Accept-Language, and changing where the cookie is written would
overlap with #1336 item 4 (default-locale prefix normalisation).
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 21, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1407

commit: c7434c7

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

…hape

The previous commit attached `__N: true` to router-owned history entries,
but two regressions slipped in:

1. `saveScrollPosition` merges scroll fields onto the *current* entry
   before a push. When the current entry was the initial document load
   (`state === null`), the merge produced a state object without `__N`,
   which the popstate foreign-state filter then dropped — breaking
   browser back/forward.

   Fix: when the existing state is null, mint a router-shaped state
   (`{ __N: true, url, as, options, key }`) before merging the scroll
   fields, so every entry the router touches carries `__N`.

2. The Safari-replay filter compared `state.as` against `window.location`,
   but `window.location` has already changed by the time popstate fires
   on a real back/forward. That made every genuine back navigation look
   like a replay and get ignored.

   Fix: compare against `_lastPathnameAndSearch` (the URL the router
   last actively navigated to). After a real back, that tracker still
   points at the entry we were on, so a genuine navigation differs and
   passes the filter — matching Next.js's `state.as === this.asPath`
   semantics.

Verified locally:
- tests/pages-router-i18n-sticky-locale.test.ts: 7/7 pass
- tests/shims.test.ts: 944/944 pass
- tests/e2e/pages-router/{navigation,before-pop-state,hydration,router-events}.spec.ts:
  28/28 pass (these were the CI failures from the previous commit).
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Clean, well-scoped PR. The three behavioral gaps (empty history state, missing stale-popstate filter, popstate locale from window global instead of history entry) are correctly identified and fixed. The Next.js parity references are solid, and the test coverage is thorough.

A few notes on things I checked:

History state shapeupdateHistory() now writes { url, as, options, __N: true, key } which matches Router.changeState in Next.js's router.ts around L1916. The key handling is correct: new key on push, preserved from previousState on replace (with fallback mint if there's no prior state). Good.

saveScrollPosition() minting router state for the initial document entry — This is a subtle but important fix. Without it, the first pushState (from performNavigation) calls saveScrollPosition which replaces the initial null state entry. When the user later navigates back, popstate fires with null/{} state and the foreign-state filter would either misclassify it or the Safari-replay filter couldn't operate. Minting __N: true on the initial entry closes this gap.

Popstate stale-state filter — The isFirstPopStateEvent + isNextRouterState guard correctly mirrors Next.js's onPopState (around L920-999). The comparison withBasePathPrefix(state.as, __basePath) === _lastPathnameAndSearch is the right approach — state.as is app-relative (no basePath), and _lastPathnameAndSearch includes basePath, so recomposing before comparison is correct.

Foreign state handling — The null/undefined passthrough preserving legacy behavior while only filtering out object-state-without-__N is a pragmatic choice that avoids breaking existing popstate tests. Matches the documented Next.js if (!state.__N) return early-exit.

Popstate locale recoveryeffectiveLocale = stateLocale ?? window.__VINEXT_LOCALE__ correctly prefers the history entry's locale over the window global, which is the core fix for back/forward across locale changes.

Test relaxation in shims.test.ts — The 5 assertions changed from {} to expect.objectContaining({ __N: true }) are exactly right. They were testing URL normalization / locale-qualified fetch behavior, not history state shape. The relaxed assertions stay focused on their actual concern.

New test file — Good coverage across the three behavioral areas. The installRuntime helper properly exercises the real popstate handler path through installPagesRouterRuntime(). The test for "first popstate with different locale is processed" correctly simulates the URL having already changed (as browsers do before firing popstate).

One minor observation for future work: _isFirstPopStateEvent and _historyKeyCounter are module-level mutable state that doesn't reset between vi.resetModules() calls within the same process. This is fine for the current test structure (each test re-imports the module to get a fresh instance), but worth noting if tests ever share a module instance across multiple popstate scenarios in sequence.

Ship it.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 21, 2026

Review posted. Approved PR #1407 with a detailed review covering:

  • History state shape: updateHistory() correctly writes the Next.js-compatible { url, as, options, __N: true, key } state, with proper key minting on push vs preservation on replace.
  • saveScrollPosition() fix: Correctly mints router state for the initial document entry so back-navigation popstate carries __N: true.
  • Popstate stale-state filter: The isFirstPopStateEvent + isNextRouterState guard mirrors Next.js's onPopState correctly, including the basePath recomposition for the as comparison.
  • Foreign state handling: Null/undefined passthrough preserves legacy behavior while filtering object-state-without-__N.
  • Popstate locale recovery: Correctly prefers history entry locale over window global.
  • Test changes: The 5 relaxed assertions in shims.test.ts and 7 new tests in the dedicated file are well-structured and focused.

github run

@james-elicx james-elicx marked this pull request as ready for review May 22, 2026 12:56
Closes the remaining gap in the popstate locale-recovery path: the
initial document entry has `state: null` until the first push, so a
back-navigation to it would fall back to the live `window.__VINEXT_LOCALE__`
— which an intervening locale-changing push may have flipped. Stamping
router-shaped state at install captures the SSR-resolved locale into
`options.locale` and lets the popstate handler restore the correct locale.

Also:
- Strip the hash from `state.as` so the Safari-replay filter's
  `withBasePath(state.as) === _lastPathnameAndSearch` comparison works for
  entries created from a hash-bearing URL (the tracker never carries a hash).
- Drop the unnecessary `withBasePath as withBasePathPrefix` import alias.

Adds 3 tests: install-time stamping, no-clobber of pre-existing state, and
the back-to-initial-entry locale-recovery regression.
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Clean, well-structured PR. The three behavioral gaps are correctly identified and the fixes match the Next.js reference implementation. I verified the key parity points against .nextjs-ref/packages/next/src/shared/lib/router/router.ts:

History state shapeupdateHistory() now writes { url, as, options, __N: true, key }, matching Router.changeState (L1916–1929). Key handling is correct: Next.js does method !== 'pushState' ? this._key : createKey() and vinext mirrors this with mode === "push" ? createHistoryKey() : (previousState?.key ?? createHistoryKey()). The fallback createHistoryKey() on replace when there's no previous key is a pragmatic addition since vinext doesn't have a persistent this._key instance field — correct behavior.

navState.as semanticsstate.as is set to resolvedNoHash (app-relative, no basePath, no hash), and the popstate comparison correctly recomposes basePath via withBasePath(state.as, __basePath) === _lastPathnameAndSearch. This is the right approach since _lastPathnameAndSearch tracks window.location.pathname + window.location.search (includes basePath, excludes hash). In Next.js, the comparison is state.as === this.asPath where both sides are basePath-free — vinext gets the same result by adding basePath to both sides. Good.

Popstate stale-state filter — The isFirstPopStateEvent + isNextRouterState guard faithfully mirrors Next.js's onPopState (L900–942). The filter structure is:

  1. !state → Next.js does replaceState and returns; vinext falls through to legacy behavior (null/undefined passthrough). This is a deliberate compat choice documented in the comment — fine.
  2. !state.__N → both return early.
  3. First + matching locale + matching as → both return early.

One subtle difference: Next.js also checks state.__NA (app-router history entries) and does window.location.reload(). vinext doesn't have this because it's pages-router-only code and __NA entries can't exist. Correct omission.

Foreign state handling — The null/undefined passthrough preserving legacy behavior is the right call. Next.js's if (!state) branch does a replaceState + return which means null-state popstates don't trigger navigation either. vinext's approach of allowing null through to the existing hash-only / full-navigation path is slightly different but behaviorally equivalent for the common cases (initial entry, hash changes). The explicit carve-out for object-state-without-__N is the important fix.

Initial entry stampingstampInitialHistoryState() in installPagesRouterRuntime() is a smart addition. The guard if (window.history.state !== null && window.history.state !== undefined) return prevents clobbering pre-existing state (e.g., server-rendered state or third-party code). The buildInitialRouterState() fallback in saveScrollPosition() is a good defensive measure for the edge case where Router.push fires before installPagesRouterRuntime().

Popstate locale recoveryeffectiveLocale = stateLocale ?? window.__VINEXT_LOCALE__ correctly prefers the history entry's locale. This is the core fix for the "back across locale switch fetches wrong URL" bug.

Test changes — The 5 relaxed assertions in shims.test.ts are well-scoped. The new test file covers all three behavioral areas with good scenario design, especially the "back-nav to initial entry" test which exercises the full locale-switch-then-back flow.

One minor observation (not blocking): For hash-only navigations (line 1012), navState.as ends up as stripHash(resolved) which for a #foo input becomes "". This is harmless since hash-only popstates are detected before the stale-state filter runs, but it means the as field in history state for hash-only entries is semantically empty. If this ever matters for debugging, the initial entry stamp already has the correct as, and the hash-only entry just adds noise to the history stack. No action needed.

Ship it.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 22, 2026

Review posted. Approved PR #1407 with a detailed review covering:

  • History state shape: updateHistory() correctly mirrors Next.js's Router.changeState with the { url, as, options, __N: true, key } shape, including proper key minting semantics.
  • navState.as semantics: The basePath recomposition in the popstate comparison (withBasePath(state.as, __basePath) === _lastPathnameAndSearch) is equivalent to Next.js's basePath-free state.as === this.asPath comparison.
  • Popstate stale-state filter: Faithfully mirrors Next.js's onPopState (L900–942) with correct omission of __NA check (pages-router only context).
  • Foreign state handling: The null/undefined passthrough for legacy compat with explicit object-without-__N filtering is well-reasoned.
  • Initial entry stamping: Defensive buildInitialRouterState() fallback in saveScrollPosition() covers the edge case where Router.push fires before runtime install.
  • Locale recovery: Core fix correctly prefers history entry locale over window global.
  • Tests: Well-scoped relaxation of existing assertions and thorough new coverage across all three behavioral areas.

github run

…ticky-locale

# Conflicts:
#	packages/vinext/src/shims/router.ts
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