Skip to content

fix(routing): re-escape '#' and '?' left over from localePath's path normalization - #4099

Open
dargmuesli wants to merge 2 commits into
nuxt-modules:mainfrom
dargmuesli:fix/locale-path-hash-encoding
Open

fix(routing): re-escape '#' and '?' left over from localePath's path normalization#4099
dargmuesli wants to merge 2 commits into
nuxt-modules:mainfrom
dargmuesli:fix/locale-path-hash-encoding

Conversation

@dargmuesli

@dargmuesli dargmuesli commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Resolves #4098

Summary

resolveRoute() (used by localePath, localeRoute, and switchLocalePath) resolves an already-resolved route object a second time to attach the localized route name (ctx.router.resolve(ctx.resolveLocalizedRouteObject(normalized, locale))). For the prefix_except_default/prefix_and_default strategies, resolveLocalizedRouteObject's path-based branch has already gone through one router.resolve() call (createLocalizedRouteByPathResolver), which decodes percent-encoded characters — kept intentionally, for readable URLs (%20 → literal space, already covered by an existing test in test/kit.test.ts).

The second router.resolve() call re-parses that decoded path as a fresh location string. A literal # or ? left over from decoding is read as an actual fragment/query delimiter rather than path text — confirmed directly against vue-router alone, no i18n module involved:

const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/blog/:slug(.*)*', name: 'blog', component: {} }] })
const first = router.resolve('/blog/my%20post%20%231')
console.log(first.fullPath)                  // /blog/my%20post%20%231  (correct)
console.log(router.resolve(first).fullPath)  // /blog/my post #1        (decoded, '#' now a literal fragment delimiter)

query/hash are populated separately during resolution and are unaffected by this — verified they stay structurally correct ({"foo":"bar"}, not swallowed into the corrupted path) even while path/fullPath are wrong. Any caller that uses the localePath()/switchLocalePath() output directly as a navigation target — navigateTo(...) (SSR redirects), router.replace({ path }), <NuxtLink :to> — ends up navigating to a silently truncated path with a bogus fragment attached.

This is the same underlying double-resolve pattern #4079 fixed for switchLocalePath's routeCopy (by omitting path so the second resolve takes the name+params branch, which does correctly re-encode via encodeParam). That approach doesn't work here without regressing the "readable URL" behavior test/kit.test.ts already covers (localePath('/path/as%20a%20test...') decoding to /path/as a test...) — using name+params always fully re-encodes via encodeURI, including turning spaces into %20. So rather than avoiding the second resolve, this re-escapes only the two characters that are genuinely unsafe to leave literal in a path string (#%23, ?%3F) on the already-resolved object, after both resolves, right before the caller gets it.

Fix

Added sanitizeResolvedPath() in src/runtime/routing/routing.ts, applied at both return points of resolveRoute() (the shared function localePath/localeRoute/switchLocalePath all funnel through). No-ops when path doesn't contain #/? (the common case).

Test plan

  • Added a regression case to the existing localePath test in test/kit.test.ts, covering all four strategies, alongside the existing "readable URL" assertion it must not regress
  • pnpm lint — clean
  • tsc --noEmit — clean
  • pnpm test:unit — 407 passed, 1 skipped (unchanged from main + the 1 new assertion)
  • pnpm build — clean
  • pnpm test:e2e — 201 passed (33 files)

pnpm test:types's specs/fixtures/typed_routes sub-check fails in my local sandbox with unrelated Cannot find module '#app'/__I18N_*__ errors — reproduces identically on a clean, unmodified main checkout (verified via git stash), so it's a pre-existing local environment issue, not something this change introduces.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed localized route resolution to keep percent-encoded reserved delimiters inside path segments (including %23 and %3F) from being treated as fragment/query separators on subsequent resolutions.
    • Ensured fullPath/href are correctly recomputed to preserve the expected escaping behavior.
  • Tests
    • Added regression tests verifying %23 and %3F remain encoded for both path-based and route-based localization resolution.

…normalization

resolveRoute() resolves an already-resolved route object a second time to
attach the localized route name. Vue Router decodes percent-encoded
characters in `path` on that second resolve (kept for readable URLs, e.g.
`%20` becomes a literal space), but a literal '#' or '?' surviving that
decode is read as an actual fragment/query delimiter when the resulting
`fullPath` is later used as a real navigation target (navigateTo,
router.replace({ path }), <NuxtLink :to>), silently truncating the path.

`query`/`hash` are resolved separately and unaffected — only `path` (and
the `fullPath` string built from it) needs the two characters re-escaped.

Fixes nuxt-modules#4098
@dargmuesli
dargmuesli requested a review from BobbieGoede as a code owner July 28, 2026 03:56
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 319a0f24-eef2-43ad-a3a4-a6b0b6a43cc9

📥 Commits

Reviewing files that changed from the base of the PR and between 4b43523 and 31e506b.

📒 Files selected for processing (2)
  • src/runtime/routing/routing.ts
  • test/kit.test.ts

Walkthrough

Adds resolved-route sanitization that percent-encodes literal hash and query delimiters in path values while preserving corresponding fullPath suffixes and recomputing href. Applies sanitization to localized and fallback route resolution results, with regression coverage for localePath and localeRoute.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main routing fix by re-escaping reserved delimiters left over from locale path normalization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/routing/routing.ts`:
- Around line 68-75: Update sanitizeResolvedPath to also refresh resolved.href
after encoding literal # and ? characters, using createHref(resolved.fullPath)
or the equivalent normalized value. Preserve the existing path and fullPath
updates, and add a regression assertion covering localeRoute(...).href for
decoded routes containing these characters.

In `@test/kit.test.ts`:
- Around line 165-170: Extend the localePath test case around the existing
encoded “#” assertion to use an encoded “?” within the path, such as
`/path/as%20a%20test%3Fone?foo=bar`. Assert that `%3F` remains encoded in the
result for each of the four normalization strategies, while preserving the
actual `?foo=bar` query delimiter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ce2f2b8-eae7-4a05-b704-d5d6b0b0350a

📥 Commits

Reviewing files that changed from the base of the PR and between 9574cc2 and 4b43523.

📒 Files selected for processing (2)
  • src/runtime/routing/routing.ts
  • test/kit.test.ts

Comment thread src/runtime/routing/routing.ts Outdated
Comment thread test/kit.test.ts
- sanitizeResolvedPath() now also recomputes href via
  router.options.history.createHref(fullPath), matching how vue-router
  itself derives href from fullPath — it was left stale (still showing
  the decoded, unsafe characters) after the previous commit only patched
  path/fullPath.
- Extend the localePath regression case to cover an encoded '?' within
  the path (distinct from a real trailing '?query' delimiter), and add
  a localeRoute(...).href assertion.
@BobbieGoede

Copy link
Copy Markdown
Member

Thanks! I'm a bit cautious with changes like these, ideally we leave encoding/decoding up to vue-router as much as possible as we have ran into issues trying to fix this in the past. Going to explore some other approaches as well.

@dargmuesli

Copy link
Copy Markdown
Collaborator Author

Yes, I'd expect stuff like this to be vue (extension) logic instead of nuxt (extension) logic too of course. I just stumbled upon this and want to point to the finding :)

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.

String paths passed to localePath() decodes percent-encoded characters in dynamic path segments

2 participants