fix(router): ignore stale initial popstate with i18n#2230
Conversation
|
/bigbonk review for issues |
commit: |
|
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
There was a problem hiding this comment.
/bigbonk review — PR #2230
Reviewed the popstate i18n stale-state fix. No actionable issues found. The change is correct, well-scoped, and consistent with the existing tracking pattern. Notes below.
Correctness — i18n guard normalization ✅
getTrackedPagesRouterAsPath() (router.ts:2867) now derives the comparison value the same way Next.js does — strip basePath, then strip the active locale prefix — so it compares apples to apples with state.as (which is app-relative, no basePath, no locale). The old code compared withBasePath(state.as, ...) against lastPathnameAndSearch, which still carried the locale prefix, so the stale-event guard never matched for i18n routes. Tracing the fixture: goto("/sv/static") → tracked /sv/static → strip basePath (none) → strip sv → /static, which matches state.as: "/static". Guard fires only when locale + asPath both match, matching the upstream isFirstPopStateEvent && this.locale === state.options.locale && state.as === this.asPath check.
Correctness — separate lastHash tracking ✅
The new isHashOnly condition:
browserUrl === lastPathnameAndSearch && (currentHash !== lastHash || currentHash !== "")The only behavioral change vs. main is the no-hash↔no-hash case: previously a same-path popstate with no hash was misclassified as hash-only (skipping the page fetch); now ("" !== "" || "" !== "") → false, so it correctly falls through to a full navigation. This is exactly what makes the second popstate in the fixture reach the [dynamic] page after the first stale event is ignored. The hash↔no-hash and non-empty-hash cases all still resolve to hash-only as before — no regression.
Tracker parity ✅
lastHash is updated alongside lastPathnameAndSearch at every write site the diff touches: createPagesRouterRuntimeState (init), navigateClientData, navigateClientHtml, navigateClient (redirect), updateHistory (incl. the hash-only push path at router.ts:2592), and the popstate tracker update at router.ts:2985. I checked the hash-only push path specifically — it routes through updateHistory, so lastHash stays in sync, meaning a later goBack from /page#section → /page is still correctly detected as hash-only.
Tests / config ✅
- New Playwright project
pages-router-invalid-popstate-i18nfollows the existing fixture pattern (symlinkednode_modules, unique port 4191, auto-registers inactiveProjects). - The spec covers the three upstream cases (no query, query, different locale), mirroring
test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts. vp checkpasses onrouter.ts; fulltests/shims.test.ts(1142 tests) passes.
Minor / non-blocking
- The PR description claims "3 nearby focused unit assertions covering popstate/hash-only behavior" but no unit-test changes appear in the diff — only the e2e spec is added. The popstate/hash-only logic lives in a browser-only handler, so e2e is the right level; just flagging the description/diff mismatch in case dedicated unit coverage was intended.
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
|
Review posted to PR #2230. Summary: No actionable issues found. The fix is correct and well-scoped. What I verified:
One non-blocking note: the PR description mentions "3 nearby focused unit assertions" but the diff only adds the e2e spec — flagged the description/diff mismatch, though e2e is the appropriate level for browser-only popstate logic. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
/bigbonk review — PR #2230
Independent deep review of the popstate i18n stale-state fix. No actionable issues found. Verified correctness against the pinned Next.js reference (ee6e79b) including the exact onlyAHashChange truth table, and confirmed full tracker parity.
isHashOnly matches Next.js onlyAHashChange exactly ✅
The new condition browserUrl === lastPathnameAndSearch && (currentHash !== lastHash || currentHash !== "") produces a truth table identical to upstream onlyAHashChange (router.ts L2301) for the same-path case:
currentHash |
lastHash |
vinext result | Next.js onlyAHashChange |
|---|---|---|---|
"" |
"" |
full nav | oldHash !== newHash → false → full nav ✅ |
"#a" |
"" |
hash-only | oldHash !== newHash → true → hash-only ✅ |
"" |
"#a" |
hash-only | oldHash !== newHash → true → hash-only ✅ |
"#a" |
"#a" |
hash-only | newHash && oldUrlNoHash===newUrlNoHash && oldHash===newHash → true → hash-only ✅ |
"#a" |
"#b" |
hash-only | oldHash !== newHash → true → hash-only ✅ |
The only behavioral change vs. main is the no-hash↔no-hash row, which previously misclassified a same-path popstate as hash-only (skipping the page fetch). This is precisely what now lets the second stale popstate in the fixture reach the [dynamic] page. The non-empty-hash identity case (#a→#a) staying hash-only also matches upstream's explicit cond-1 branch — not a regression.
i18n guard normalization is correct ✅
getTrackedPagesRouterAsPath() (router.ts:2867) reconstructs the upstream this.asPath shape — strip basePath, then removeNavigationLocalePrefix — so it compares apples-to-apples with state.as (app-relative, no basePath/locale). Mirrors upstream isFirstPopStateEvent && this.locale === state.options.locale && state.as === this.asPath (router.ts:928-935). The old code compared withBasePath(state.as, ...) against lastPathnameAndSearch, which retained the locale prefix, so the guard never matched for i18n routes. Tracing the fixture: /sv/static tracked → strip basePath (none) → strip sv → /static === state.as: "/static" → guard fires. removeNavigationLocalePrefix reuses the established getLocalePathPrefix helper.
Tracker parity verified ✅
All 5 lastPathnameAndSearch = ... write sites (1701, 2024, 2118, 2309, 2984) have a paired lastHash write (1702, 2025, 2120, 2310, 2985), plus the init in createPagesRouterRuntimeState. The hash-only push path (router.ts:2592) routes through updateHistory, which writes both, so a later goBack from /page#section → /page stays correctly classified. No write site updates one without the other.
vinext-specific !routerDidNavigate guard is sound ✅
The added && !routerRuntimeState.routerDidNavigate condition (beyond upstream) is safe for the upstream test cases: the first (ignored) popstate returns early before routerDidNavigate is set, and isFirstPopStateEvent is cleared at the top, so the second popstate skips the guard and navigates. The early return correctly leaves trackers unchanged for a true replay.
Tests / config ✅
- New Playwright project
pages-router-invalid-popstate-i18n: unique port 4191, follows the existing symlink-node_modulespattern, auto-registers viaprojectServers/activeProjects. - Spec mirrors the three upstream cases (no query, query, different locale) from
test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts. - Fixture
next.config.mjsi18n format matchespages-basic. vp check packages/vinext/src/shims/router.tspasses;tests/shims.test.tspopstate + hash subsets (38 tests) pass.
Minor / non-blocking
- Stale comment phrasing: the comment block at router.ts:2898-2900 still describes the tracker as "browser-shaped, with basePath" immediately before the new note (L2905-2907) about normalizing it. It's technically consistent (the tracker is browser-shaped; we normalize a copy for comparison), but the two paragraphs read as mildly contradictory on a quick scan. Optional cleanup.
- PR description mismatch: the description claims "3 nearby focused unit assertions" but the diff only adds the e2e spec. e2e is the right level for browser-only popstate logic, so this is just a description/diff discrepancy worth noting.
|
Review posted to PR #2230. SummaryNo actionable issues found. I independently verified the fix against the pinned Next.js reference ( Key findings:
Two non-blocking notes: a mildly contradictory comment block at router.ts:2898-2907, and the PR description's "3 unit assertions" claim doesn't match the diff (only the e2e spec is added — which is the appropriate level for browser-only popstate logic). |
What this changes
asPathshape before applying the first-popstate stale-state guard.Why
Next.js ignores only the first stale
popstateevent when its locale matches the active locale and itsstate.asmatches the current app-relativeasPath. vinext compared that app-relative value with a browser pathname that still contained the locale prefix, so the stale event was not ignored for i18n routes.Next.js references
Pinned Next.js reference commit:
ee6e79b1792a4d401ddf2480f40a83549fe8e722Targeted local validation
Completed before review, with no full suite or raw Next.js E2E run:
tests/e2e/pages-router-invalid-popstate-i18n/invalid-popstate.spec.ts, one worker.