fix(pages): preserve rewritten URLs during navigation - #2028
Conversation
commit: |
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(pages): preserve rewritten URLs during navigation
I reviewed the diff, checked Next.js parity against the upstream formatUrl source, and ran the targeted suites (tests/shims.test.ts, tests/link.test.ts, vp check). Everything passes and the implementation closely mirrors Next.js. No actionable findings.
CodeQL finding (router.ts:499) — not a bug, this is deliberate Next.js parity
CodeQL flags search.replace("#", "%23") as incomplete string escaping because it replaces only the first #. This is intentional and correct — it is a byte-for-byte match of Next.js's own formatUrl:
// next/src/shared/lib/router/utils/format-url.ts
pathname = pathname.replace(/[?#]/g, encodeURIComponent)
search = search.replace('#', '%23') // <-- single replace, first occurrence onlyvinext aims to replicate Next.js semantics exactly, so diverging here (e.g. using replaceAll) would be a parity regression, not a fix. The new test cases ({ search: "q=a#b" } → ?q=a%23b) confirm the matching behavior. I'd recommend dismissing the CodeQL alert as "won't fix / intended behavior" with a link to the Next.js source, and optionally adding a short comment at the call site noting the parity rationale so the next reader doesn't try to "fix" it.
Verified behaviors
resolveUrlprecedence —searchcorrectly wins overquery(urlObj.search || (query && ...)in Next.js); the{ query: { id: "ignored" }, search: "id=3" }→?id=3test confirms parity.- Path inheritance — query/search/hash-only
UrlObjects withoutpathnameinherit the visiblewindow.location.pathname, which is what makes rewritten URLs survive navigation. Hash-only navigation correctly preserves the currentwindow.location.search. getCurrentHistoryAsPath— guarded by a pathname+search equality check against the browser URL and wrapped in try/catch; falls back cleanly to the priorresolvedPath + search + hashcomputation. Sound.- Locale handling —
replaceInheritedLocaleonly triggers foras === undefined+ explicitoptions.locale+ pathname-lessUrlObject, andremoveNavigationLocalePrefixstrips the inherited prefix before re-applying, avoiding double-prefixing. - Link Router resolution —
window.next?.routervs dynamicimport("next/router")fallback (guarded by"reload" in Router) preserves the previous behavior when the debug global is unavailable.
Test coverage
The parametrized tests/shims.test.ts cases (empty object, empty query, bare ?, encoded space, escaped #, query/search override, hash combinations, locale prefix) provide good coverage of the formatting matrix, and getCurrentHistoryAsPath's raw asPath retention is exercised. E2E coverage for root/cross-segment push/replace/Link looks appropriate.
LGTM.
|
Review posted to PR #2028. SummaryI reviewed the PR's source changes, verified Next.js parity against the upstream Outcome: No actionable findings. LGTM. Key points:
|
|
/bigbonk review for issues |
|
/bigbonk review for issues |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
There was a problem hiding this comment.
Review: fix(pages): preserve rewritten URLs during navigation
Reviewed the diff against Next.js source (format-url.ts, resolve-href.js, router.js from the pinned next@16.2.7 in node_modules), traced the formatting/locale/asPath edge cases, and ran the targeted suites: tests/shims.test.ts (1120), tests/link.test.ts (120), tests/routing.test.ts (112), plus vp check on the three changed source files. All green. No actionable findings — LGTM.
Parity verification
resolveUrlsearch/#formatting matchesformatUrlbyte-for-byte:?-prefix happens before the single.replace('#', '%23'), so{ search: "q=a#b#c" }→?q=a%23b#cand{ search: "#foo" }→?%23fooboth line up with Next.js. The CodeQL "incomplete string escaping" alert on router.ts:499 is a deliberate parity match, not a bug —format-url.js:54uses the same single-occurrencereplace('#', '%23'). Recommend dismissing as intended.searchoverqueryprecedence matches Next'surlObj.search || query && '?'+query({ query: {...}, search: "id=3" }→?id=3).- Query-only / dynamic-rewrite path inheritance is the right scope. Next's
resolve-href.jsusesrouter.asPathfor?-hrefs, switching torouter.pathnameonly when the route is dynamic andasPathmatches the pattern. The fixture (/rewrite-navigation/:id→/rewrite-navigation/[id]/destination) is exactly the rewritten/non-matching case, so inheritingwindow.location.pathnameis correct. The same-segment-rewrite cases the PR scopes out correspond to the documented edge case in Next's own source comment. getCurrentHistoryAsPathis correctly guarded: it only overridesasPathwhenstate.asresolves to the same pathname+search as the visible URL (try/catch falls back cleanly), which is what preserves a raw trailing bare?thatwindow.location.searchcollapses.navState.as = stripHash(resolved)keeps hash out of the stateas, matching the{ hash }test expectations.- Locale handling does not double-prefix: the Pages string path resolves locale once via
resolvePagesLinkNavigationHref/applyLocaleToHref, and the subsequentrouter.push(stringHref, …, { locale })short-circuits inapplyNavigationLocalebecause the prefix is already present (replaceExistingLocaleonly triggers for pathname-lessUrlObjects).resolvePagesRouterQueryOnlyHrefstrips the locale from thewindow.location.pathnamefallback before resolving, sincerouter.asPathis locale-free — confirmed by the basePath+locale test.
Notes (non-blocking)
- In
link.tsx,pagesNavigateHrefis computed for any?-href even whenhasAppNavigationRuntimeis true (it's only consumed on the Pages branch). Harmless, just a tiny bit of wasted work; not worth changing.
Tests
Coverage is strong: the parametrized formatting matrix (empty object, empty query, bare ?, encoded space, escaped #, search-over-query, hash combinations), bare-? asPath retention for both push/replace, the basePath+locale Link resolution, and E2E push/replace/Link/search+hash/bare-query cases. The tests/routing.test.ts adjustment (same-depth static-before-dynamic) is the correct accommodation for the new nested dynamic fixture.
|
Review posted to PR #2028. SummaryI reviewed the source changes against the pinned Next.js v16.2.7 source ( Outcome: No actionable findings. LGTM. Key verifications:
One non-blocking note: Test coverage is thorough across the formatting matrix, asPath retention, locale/basePath resolution, and E2E navigation cases. The routing test adjustment correctly accommodates the new nested dynamic fixture. |
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: fix(pages): preserve rewritten URLs during navigation
Reviewed all three changed source files against the pinned next@16.2.7 source (format-url.js, resolve-href.js), traced the formatting/locale/asPath edge cases by hand, and ran the targeted suites: tests/routing.test.ts + tests/route-sorting.test.ts (159), tests/shims.test.ts + tests/link.test.ts (1240), plus vp check on the three changed source files. All green. No actionable findings — LGTM.
Parity verification
resolveUrlformatting matchesformatUrlbyte-for-byte. The?-prefix is applied before the single.replace('#', '%23'),%23is applied only tosearch(never tohash, which is appended afterward), and the bare?survives (new URL("?", base)preserves the trailing delimiter — verified directly). The CodeQL "incomplete string escaping" alert at router.ts:499 is a deliberate parity match withformat-url.js:54's single-occurrencereplace('#', '%23'); diverging (e.g.replaceAll) would be a regression. Recommend dismissing as intended.searchoverqueryprecedence matches Next'surlObj.search || query && '?'+query—hasSearchshort-circuits thequerybranch, andsearch: ""correctly falls through toquery(matching the falsyurlObj.searchsemantics).- Query-only path inheritance is the correctly-scoped subset. Next's
resolve-href.jsusesrouter.asPathfor?-hrefs, switching torouter.pathnameonly when the route is dynamic andasPathmatches the pattern. The fixture (/rewrite-navigation/:id->/rewrite-navigation/[id]/destination) is exactly the rewritten/non-matching case, so inheriting the visible pathname is correct. The same-segment cases scoped out of this PR map to Next's own documented edge-case comment (resolve-href.js:49-54). getCurrentHistoryAsPathis properly guarded: it only overridesasPathwhenstate.asresolves to the same pathname+search as the visible URL (try/catch falls back cleanly), preserving a raw trailing bare?thatwindow.location.searchcollapses.navState.as = stripHash(resolved)keeps hash out of the state, and the hash is re-applied fromwindow.location.hash— consistent with the{ hash }tests.- Locale handling does not double-prefix. The Pages string path resolves locale once (
resolvePagesLinkNavigationHref->applyLocaleToHref), and the subsequentrouter.push(stringHref, …, { locale })short-circuits becausereplaceInheritedLocalerequirestypeof url !== "string".removeNavigationLocalePrefixstrips the inherited prefix before re-applying for the pathname-less UrlObject path.locale: falsecorrectly resolves to the default locale and drops the prefix (basePath +locale:falsetest confirms/docs/rewrite-navigation/0?id=2).
Notes (non-blocking)
- In
link.tsx,pagesNavigateHrefis computed for any?-href even whenhasAppNavigationRuntimeis true (it's only consumed on the Pages branch via thehasAppNavigationRuntime ? navigateHref : pagesNavigateHrefternary). Harmless wasted work; not worth changing. - String
#hash-only Link hrefs on rewritten pages don't go through the new rewrite-aware resolver (only?-prefixed strings do), whereas Next'sresolve-hrefusesrouter.asPathfor#hrefs too. This is pre-existing behavior and outside this PR's stated scope (query/search/hash-only UrlObjects).
Tests
Coverage is strong: the parametrized formatting matrix (empty object, empty query, bare ?, encoded space, escaped #, search-over-query, hash combinations), bare-? asPath retention for both push/replace, basePath+locale resolution including locale: false, and E2E push/replace/Link/search+hash/bare-query cases. The tests/routing.test.ts adjustment (same-depth static-before-dynamic) is the correct accommodation for the new nested dynamic fixture (/rewrite-navigation/[id]/destination), which legitimately breaks the prior global "all static before all dynamic" invariant.
|
Review posted to PR #2028. SummaryI reviewed all three changed source files ( Key verifications:
Two non-blocking notes: |
Summary
asPath, including a trailing bare?UrlObjectempty/query/search/hash formatting with Next.jsNext.js parity
Fixes six assertions in
test/e2e/use-router-with-rewrites/use-router-with-rewrites.test.tscovering root and cross-segment push/replace/Link navigation. Three same-segment rewrite cases remain separate follow-up scope.Validation
vp checkand vinext build passed