Skip to content

Conversation

@thejasonxie
Copy link
Contributor

@thejasonxie thejasonxie commented Jan 26, 2026

Summary
Fixes SSR redirect loop for splat routes with URL-encoded characters (spaces, plus signs, brackets, etc.)
Problem
When a splat route contains URL-encoded characters like %20 (space), %2B (plus), or %5B (bracket):

  1. User navigates to /docs/file%20name.pdf
  2. TanStack Router decodes the _splat param using decodeURIComponentfile name.pdf
  3. When rebuilding the URL for SSR, the router used encodeURI() which doesn't encode spaces
  4. This produces /docs/file name.pdf instead of /docs/file%20name.pdf
  5. URL mismatch triggers a 307 redirect loop
    Solution
    Changed encodeParam() in path.ts to use encodeURIComponent for each path segment instead of encodeURI on the entire splat value:
    // Before
    return encodeURI(value)
    // After
    return value.split('/').map((segment) => encodeURIComponent(segment)).join('/')
    This properly encodes spaces (%20), plus signs (%2B), brackets (%5B/%5D), and other special characters that encodeURI leaves unencoded, while still preserving forward slashes as path separators.
    Changes
  • packages/router-core/src/path.ts: Updated encodeParam() function
  • packages/router-core/tests/path.test.ts: Added tests for splat params with special characters
  • packages/router-core/tests/utils.test.ts: Added tests for encodeNonAscii() (for coverage)
    Testing
    All 1030 existing tests pass, plus 18 new tests for special character encoding.

Resolves #6519

Summary by CodeRabbit

  • Bug Fixes

    • Splat path segments now encode per segment with a fast-path for already-safe strings, fixing encoding for spaces, plus signs, "@", hashes, brackets, etc.
  • New Features

    • Added a public utility to percent‑encode whitespace and non‑ASCII characters in path-like URLs; exported utility name updated.
  • Tests

    • Added and updated tests covering splat edge cases, whitespace/non‑ASCII/emoji encoding, and stricter "@" percent‑encoding expectations.

✏️ Tip: You can customize this high-level summary in your review settings.

Use encodeURIComponent instead of encodeURI for splat route parameters
to properly encode spaces, plus signs, and other special characters.

This fixes an SSR redirect loop that occurs when:
1. User navigates to a splat route with encoded characters (e.g., %20)
2. TanStack Router decodes params with decodeURIComponent
3. When rebuilding URL, encodeURI doesn't encode spaces/special chars
4. URL mismatch triggers redirect loop (307 redirects)

encodeURIComponent properly encodes these characters, preventing the
mismatch while still preserving forward slashes as path separators.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 26, 2026

📝 Walkthrough

Walkthrough

Splat params now early-return if the value is URL-safe; otherwise splat values are split on '/' and each segment is percent-encoded individually. The path-utils encoder was renamed to encodePathLikeUrl and broadened to encode whitespace and non‑ASCII per-segment. Tests across router-core and framework suites updated to expect stricter percent-encoding (e.g., @%40).

Changes

Cohort / File(s) Summary
Core Encoding Logic
packages/router-core/src/path.ts
_splat handling: if value contains only URL-safe chars return early; otherwise split on / and encode each segment instead of using encodeURI.
Utility Function
packages/router-core/src/utils.ts
encodeNonAscii renamed to encodePathLikeUrl; now encodes whitespace and non-ASCII characters per path-segment (preserves /).
Router Core Tests
packages/router-core/tests/path.test.ts, packages/router-core/tests/utils.test.ts
Added tests for splat params with spaces, parentheses, brackets, hashes, plus/equals, nested paths; added tests for encodePathLikeUrl behavior (ASCII, non-ASCII, emoji, mixed).
Framework Integration Tests
packages/react-router/tests/*.test.tsx, packages/solid-router/tests/*.test.tsx, packages/vue-router/tests/*.test.tsx
Updated expected path/location strings in link/navigate/useNavigate tests to reflect stricter percent-encoding (notably @%40).

Sequence Diagram(s)

(Skipped.)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel
  • Sheraff

Poem

🐰 I hopped through paths both near and far,
I split the strings like carrots on a skewer,
Spaces tucked, emojis wrapped in code,
At-signs curled into %40 mode,
I nibble bytes and bounce — hooray! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing splat route parameter encoding by using encodeURIComponent instead of encodeURI.
Linked Issues check ✅ Passed The PR successfully addresses the primary objective from #6519 (splat route encoding fix) by replacing encodeURI with per-segment encodeURIComponent in path.ts. It also addresses the secondary objective of updating encodeNonAscii (now encodePathLikeUrl) to encode whitespace and non-ASCII characters. Issue #5031 is correctly noted as not resolved by this PR (percent-encoding is correct per URL standards).
Out of Scope Changes check ✅ Passed All code changes are within scope: splat encoding fix in path.ts, encodeNonAscii to encodePathLikeUrl rename with expanded whitespace/non-ASCII encoding, updated imports in router.ts, and comprehensive test updates across multiple packages reflecting the stricter encoding behavior.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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 and usage tips.

@nlynzaad
Copy link
Contributor

nlynzaad commented Jan 26, 2026

@thejasonxie thanks for this.

I agree with the change to treat the various path segments for splat params similar to how path params is treated. encodePathParam uses encodeURIComponent underlying so made a change to rather use this and standardise across both path params and splat params.

There were a few tests that failed, these were expected due to the change from encodeURI to encodeURIComponent and I updated those accordingly.

@nlynzaad
Copy link
Contributor

just a quick note.

this does not resolve #5031.

The problem there was in fact that a space in a file-based route name results in a %20 i.e. test.tsx becomes /%20test and test me.tsx becomes /test%20me. This handling is correct and your problem highlights exactly why this should be happening, i.e. spaces in paths should be encoded as %20 in order to follow URL standards.

@nlynzaad nlynzaad requested review from Sheraff and nlynzaad January 26, 2026 02:09
@nx-cloud
Copy link

nx-cloud bot commented Jan 26, 2026

View your CI Pipeline Execution ↗ for commit 2fe5668

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 1m 12s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1s View ↗

☁️ Nx Cloud last updated this comment at 2026-01-26 21:50:01 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 26, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/arktype-adapter@6520

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/eslint-plugin-router@6520

@tanstack/history

npm i https://pkg.pr.new/TanStack/router/@tanstack/history@6520

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/nitro-v2-vite-plugin@6520

@tanstack/react-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router@6520

@tanstack/react-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router-devtools@6520

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router-ssr-query@6520

@tanstack/react-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start@6520

@tanstack/react-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-client@6520

@tanstack/react-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-server@6520

@tanstack/router-cli

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-cli@6520

@tanstack/router-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-core@6520

@tanstack/router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-devtools@6520

@tanstack/router-devtools-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-devtools-core@6520

@tanstack/router-generator

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-generator@6520

@tanstack/router-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-plugin@6520

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-ssr-query-core@6520

@tanstack/router-utils

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-utils@6520

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-vite-plugin@6520

@tanstack/solid-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router@6520

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router-devtools@6520

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router-ssr-query@6520

@tanstack/solid-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start@6520

@tanstack/solid-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-client@6520

@tanstack/solid-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-server@6520

@tanstack/start-client-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-client-core@6520

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-fn-stubs@6520

@tanstack/start-plugin-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-plugin-core@6520

@tanstack/start-server-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-core@6520

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-static-server-functions@6520

@tanstack/start-storage-context

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-storage-context@6520

@tanstack/valibot-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/valibot-adapter@6520

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/TanStack/router/@tanstack/virtual-file-routes@6520

@tanstack/vue-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-router@6520

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-router-devtools@6520

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-router-ssr-query@6520

@tanstack/vue-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-start@6520

@tanstack/vue-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-start-client@6520

@tanstack/vue-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-start-server@6520

@tanstack/zod-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/zod-adapter@6520

commit: 2fe5668

@Sheraff
Copy link
Contributor

Sheraff commented Jan 26, 2026

The issue (#6519) and the added tests seem to imply that there is an issue with encodeNonAscii but I do not see it modified in this PR. Was it not actually part of the problem?

Add performance optimization that skips the split/map/join operation
when splat values only contain URL-safe characters (alphanumeric,
dash, period, underscore, tilde, exclamation, forward slash).

This avoids unnecessary processing in the hot path for common cases.
Update encodeNonAscii to also encode whitespace characters (spaces, tabs, etc.)
in addition to non-ASCII characters. This fixes SSR redirect loops where:

1. Request URL contains encoded spaces: /path/file%20name.pdf
2. parseLocation decodes pathname: /path/file name.pdf
3. buildLocation calls encodeNonAscii but spaces weren't encoded
4. URL mismatch triggers redirect loop

Now encodeNonAscii properly encodes:
- Whitespace characters (spaces become %20)
- Non-ASCII/unicode characters (including emojis)

Other ASCII special characters (parentheses, brackets, etc.) are preserved
as they are valid in URL paths.
@thejasonxie
Copy link
Contributor Author

thejasonxie commented Jan 26, 2026

The issue (#6519) and the added tests seem to imply that there is an issue with encodeNonAscii but I do not see it modified in this PR. Was it not actually part of the problem?

Yes, encodeNonAscii is part of the problem. Here's the flow that causes the SSR redirect loop:

  1. Request URL: /path/file%20name.pdf
  2. parseLocation decodes pathname → /path/file name.pdf (actual space)
  3. SSR calls buildLocation({ to: latestLocation.pathname })
  4. Since pathname doesn't contain $, interpolatePath returns it unchanged
  5. encodeNonAscii(fullPath) is called, but spaces are ASCII (0x20), so they weren't encoded
  6. nextLocation.publicHref = /path/file name.pdf
  7. Compare: /path/file%20name.pdf !== /path/file name.pdf → redirect triggered → loop

I've added a second commit that updates encodeNonAscii to also encode whitespace characters. The fix:

  • Encodes whitespace (spaces → %20, tabs → %09, etc.)
  • Encodes non-ASCII/unicode characters (including emojis)
  • Preserves other ASCII special characters (parentheses, brackets, etc.) as they're valid in URL paths

Both fixes are needed:

  • encodeParam fix handles the $ splat route param case
  • encodeNonAscii fix handles the direct pathname case in buildLocation

@thejasonxie
Copy link
Contributor Author

thejasonxie commented Jan 26, 2026

Issue happens from accessing /file-name%20(3).pdf? in the url then refreshing which crashes

Updated patch to fix my issue:

diff --git a/node_modules/@tanstack/router-core/dist/esm/path.js b/node_modules/@tanstack/router-core/dist/esm/path.js
index 114353f..212f57d 100644
--- a/node_modules/@tanstack/router-core/dist/esm/path.js
+++ b/node_modules/@tanstack/router-core/dist/esm/path.js
@@ -121,7 +121,11 @@ function encodeParam(key, params, decoder) {
   const value = params[key];
   if (typeof value !== "string") return value;
   if (key === "_splat") {
-    return encodeURI(value);
+    // Early return if value only contains URL-safe characters (performance optimization)
+    if (/^[a-zA-Z0-9\-._~!/]*$/.test(value)) return value;
+    // Use encodeURIComponent for each segment to properly encode spaces and special characters
+    // but preserve forward slashes as path separators
+    return value.split('/').map(segment => encodeURIComponent(segment)).join('/');
   } else {
     return encodePathParam(value, decoder);
   }
diff --git a/node_modules/@tanstack/router-core/dist/esm/utils.js b/node_modules/@tanstack/router-core/dist/esm/utils.js
index ebada3c..b5f5ca5 100644
--- a/node_modules/@tanstack/router-core/dist/esm/utils.js
+++ b/node_modules/@tanstack/router-core/dist/esm/utils.js
@@ -226,8 +226,10 @@ function decodePath(path, decodeIgnore) {
   return result;
 }
 function encodeNonAscii(path) {
-  if (!/[^\u0000-\u007F]/.test(path)) return path;
-  return path.replace(/[^\u0000-\u007F]/gu, encodeURIComponent);
+  // Encode non-ASCII characters and whitespace that should be encoded in URLs
+  // eslint-disable-next-line no-control-
+  // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check
+  if (!/\s|[^\u0000-\u007F]/.test(path)) return path;
+  return path.replace(/\s|[^\u0000-\u007F]/gu, encodeURIComponent);
 }
 function buildDevStylesUrl(basepath, routeIds) {
   const trimmedBasepath = basepath.replace(/^\/+|\/+$/g, "");

Comment on lines 650 to 657
return [...segment]
.map((char) => {
if (needsEncoding.test(char)) {
return encodeURIComponent(char)
}
return char
})
.join('')
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need char-by-char encoding? Can't we just return encodeURIComponent(segment)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

encodeURIComponent encodes all characters except: A-Z a-z 0-9 - _ . ! ~ * ' ( )

This means it would encode characters like @, $, &, +, ,, ;, =, : which are valid in URL paths and were not being encoded before.

The function is named encodeNonAscii and its purpose is specifically to encode:

  • Whitespace (spaces → %20, tabs → %09)
  • Non-ASCII/Unicode (emojis, accented characters, etc.)

Using encodeURIComponent(segment) would be a breaking change - paths like /user@domain/file would become /user%40domain/file.

However, the char-by-char approach can be simplified. Instead of:

return [...segment]
  .map((char) => needsEncoding.test(char) ? encodeURIComponent(char) : char)
  .join('')

We can use a regex replace:

return segment.replace(/\s|[^\u0000-\u007F]/gu, encodeURIComponent)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

switched to regex

Copy link
Contributor

@Sheraff Sheraff Jan 26, 2026

Choose a reason for hiding this comment

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

Yeah I think that encodeNonAscii is poorly named, it's goal is to "encode like new URL would, but without requiring all the parsing that new URL does".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed it to encodePathLikeUrl and updated jsdoc

@thejasonxie thejasonxie force-pushed the fix/splat-route-encoding branch from 76bb322 to 8846a33 Compare January 26, 2026 18:11
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/router-core/src/utils.ts`:
- Around line 638-643: The regex using \u0000-\u007F triggers
lint/control-character errors; replace the regex-based detection/replace in
encodeNonAscii with an ASCII/whitespace scanner and segment-wise encoding:
implement a small loop that checks each character's charCodeAt(i) > 127 or
/\s/.test(char) to decide if encoding is needed, return the original path when
no encoding required, otherwise split path by '/' and map each segment to
encodeURIComponent(segment) only if that segment contains non-ASCII/whitespace
(using the same charCodeAt check), then join with '/'—update the encodeNonAscii
function to use these helpers instead of the problematic Unicode-range regex.

@thejasonxie thejasonxie force-pushed the fix/splat-route-encoding branch from 8846a33 to 885d008 Compare January 26, 2026 18:28
@thejasonxie thejasonxie force-pushed the fix/splat-route-encoding branch from 885d008 to bef5cb9 Compare January 26, 2026 19:45
Rename the function to better reflect its expanded responsibility of
encoding paths the same way new URL() would, including:
- Whitespace characters (spaces → %20, tabs → %09)
- Non-ASCII/Unicode characters (emojis, accented characters)

The old name 'encodeNonAscii' was misleading since the function now
encodes more than just non-ASCII characters.
@nlynzaad nlynzaad merged commit 4814d4e into TanStack:main Jan 26, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSR redirect loop for splat routes with URL-encoded characters (%20, parentheses)

3 participants