Skip to content

fix(router-core): encode URL-unsafe ASCII chars in encodePathLikeUrl to break infinite redirect on <test-style paths#7594

Open
spokodev wants to merge 1 commit into
TanStack:mainfrom
spokodev:fix/encoded-unsafe-chars-pathname
Open

fix(router-core): encode URL-unsafe ASCII chars in encodePathLikeUrl to break infinite redirect on <test-style paths#7594
spokodev wants to merge 1 commit into
TanStack:mainfrom
spokodev:fix/encoded-unsafe-chars-pathname

Conversation

@spokodev

Copy link
Copy Markdown

What

Requests whose pathname contains encoded URL-unsafe ASCII characters (e.g. <, >, ", `, {, }) crashed with ERR_TOO_MANY_REDIRECTS because the router kept rewriting them between encoded and decoded forms. Visiting http://localhost:3000/<test on any fresh TanStack Start project would loop until the browser gave up. This PR makes encodePathLikeUrl percent-encode the WHATWG URL "path percent-encode set" so its output is a fixed point of decodePath, and the SSR redirect comparator no longer fires on these inputs.

Closes #7587.

Root cause

The SSR pipeline does three things to incoming URLs:

  1. decodePath (in packages/router-core/src/utils.ts) strips control characters and decodes percent sequences via decodeURI. For /%3Ctest this returns "/<test".
  2. parseLocation (router fast path) keeps publicHref as the raw pathname ("/%3Ctest") and stores the decoded form in pathname ("/<test").
  3. buildLocation rebuilds the URL from the decoded pathname and runs the assembled path through encodePathLikeUrl, which previously only re-encoded whitespace and non-ASCII characters. For < (an ASCII char in the WHATWG path percent-encode set) it returned the input verbatim, so the new publicHref was "/<test".

The redirect detector in router.ts then compared:

if (this.latestLocation.publicHref !== nextLocation.publicHref) {
  throw redirect({ href, ... })
}

"/%3Ctest" !== "/<test" so the router threw a redirect to /<test. The browser percent-encoded the response Location header back to /%3Ctest and re-requested, which produced the same diff. The loop terminated only when Chrome stopped at ERR_TOO_MANY_REDIRECTS.

The clean fix is to make encodePathLikeUrl invertible against decodePath for the characters decodePath decodes. The WHATWG URL spec defines the path percent-encode set as: C0 control + space + " + < + > + ` + { + }. Whitespace and the control range were already covered; this PR adds <, >, ", `, {, }. ? and # are deliberately not added because the function is called on the combined pathname + search + hash string where those characters are the component separators.

Tests added

A new describe('URL-unsafe ASCII chars (WHATWG path percent-encode set)') block in packages/router-core/tests/utils.test.ts. Each it targets a specific contract:

  • **encodes < > " { } in path segments** — happy path for every character in the added encode set. Without the fix, all five assertions fail with expected "/<test" to be "/%3Ctest"` etc.
  • round-trips with decodePath for unsafe ASCII chars — guards the actual invariant used by the SSR comparator (encodePathLikeUrl(decodePath(raw).path) === raw) across three inputs.
  • does not double-encode already-percent-encoded sequences — negative regression so the new regex does not match % literals.
  • preserves URL component separators (?, #, /) — confirms ?, #, and / still pass through, so search/hash survive when encodePathLikeUrl is called on a combined path string.

The existing encodePathLikeUrl happy-path tests (unicode, spaces, emoji, [1] brackets) still pass unchanged, so the fix does not widen the encode set beyond what the WHATWG spec mandates.

Verified the failing-test -> fix -> passing-test loop locally:

  • Before the fix: 3 of the new tests fail with the exact mismatch above.
  • After the fix: all 116 tests in packages/router-core/tests/utils.test.ts pass (113 passed | 3 expected fail, no regressions in any existing case).

Repro

Before the fix, on any TanStack Start project (the issue's reproducer):

  1. npx @tanstack/cli@latest new start-basic && cd start-basic
  2. pnpm dev
  3. Visit http://localhost:3000/<test
  4. Browser crashes with ERR_TOO_MANY_REDIRECTS within seconds.

Direct repro at the utility layer (no Start app required):

import { decodePath, encodePathLikeUrl } from '@tanstack/router-core'

const raw = '/%3Ctest'
const decoded = decodePath(raw).path           // "/<test"
const reencoded = encodePathLikeUrl(decoded)   // before: "/<test"   after: "/%3Ctest"
console.log(reencoded === raw)                 // before: false      after: true

The reencoded === raw invariant is exactly what the SSR redirect comparator relies on - the boolean flip is what stops the redirect loop.

Validation

pnpm vitest run tests/utils.test.ts                           # 113 passed | 3 expected fail, 0 failures
pnpm vitest run tests/utils.test.ts -t "encodePathLikeUrl"    # 10 passed (6 existing + 4 new)

Pre-existing import resolution errors in unrelated test files (build-location.test.ts, callbacks.test.ts etc. fail to resolve @tanstack/history regardless of this branch — confirmed by running them on main with the change stashed).

Changeset added as a patch bump on @tanstack/router-core.

…to break infinite redirect on `<test`-style paths

`decodePath` decodes `%3C` -> `<` (and the rest of the WHATWG URL "path
percent-encode set": `<`, `>`, `"`, `` ` ``, `{`, `}`), but
`encodePathLikeUrl` only encoded whitespace and non-ASCII characters.
The pair therefore failed to round-trip for those characters.

The SSR redirect comparator in router.ts uses
`latestLocation.publicHref !== nextLocation.publicHref` as the signal
that the URL was rewritten and needs a redirect. For requests like
`/<test` the browser sends `/%3Ctest`, the fast path in `parseLocation`
keeps `publicHref = "/%3Ctest"`, but `buildLocation` rebuilds the URL
from the decoded `pathname = "/<test"` and runs it through
`encodePathLikeUrl`, which left `<` un-encoded - so the new
`publicHref = "/<test"` differs from the latest `"/%3Ctest"`, the
router throws a redirect to `/<test`, the browser re-encodes to
`/%3Ctest`, and the loop continues until the browser gives up with
`ERR_TOO_MANY_REDIRECTS`.

Extending the encode regex to include the WHATWG "path percent-encode
set" makes encode/decode invertible for those characters. `?` and `#`
remain unencoded because the function is called on the combined
`pathname + search + hash` string where they are component separators.

Closes TanStack#7587
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes an infinite redirect loop that occurs when request pathnames contain URL-unsafe ASCII characters. The encodePathLikeUrl utility is updated to percent-encode additional unsafe characters per the WHATWG URL standard, ensuring proper round-tripping with decodePath and preventing spurious URL change detections in SSR redirect comparisons.

Changes

URL-unsafe character encoding

Layer / File(s) Summary
encodePathLikeUrl fix with validation
packages/router-core/src/utils.ts, packages/router-core/tests/utils.test.ts, .changeset/encode-url-unsafe-ascii-chars.md
encodePathLikeUrl now encodes <, >, ", `, {, } in addition to whitespace and non-ASCII characters, fixing the redirect loop. Tests verify encoding of unsafe ASCII, round-trip behavior with decodePath, prevention of double-encoding, and preservation of URL separators.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

package: router-core

Poem

A rabbit hops through encoded URLs with care,
Fixing redirects that led nowhere, nowhere!
With < and > now safely wrapped in percent,
No more infinite loops—the fix is heaven-sent. 🐰✨

🚥 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 accurately describes the main change: fixing URL encoding of unsafe ASCII characters in encodePathLikeUrl to resolve infinite redirects, matching the core purpose of the changeset.
Linked Issues check ✅ Passed The PR addresses all coding requirements from issue #7587 by extending encodePathLikeUrl to percent-encode WHATWG path percent-encode set characters, preventing infinite redirect loops for unsafe ASCII character paths.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the encoding issue: changeset metadata, encodePathLikeUrl function updates, and comprehensive test coverage for the fix with no extraneous modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

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

@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: 1

🤖 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 `@packages/router-core/src/utils.ts`:
- Line 692: The single-line if using the regex /[\s<>"`{}]|[^\u0000-\u007F]/ on
variable path must use curly braces; replace the single-line form "if
(!/.../.test(path)) return path" with a block-style if that contains the return
statement so it conforms to the repo's control-statement brace rule (locate the
usage in utils.ts where path is tested against that regex).
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: fe1e0687-9d1f-417c-9110-c54e257d9c3b

📥 Commits

Reviewing files that changed from the base of the PR and between 6f1daf5 and 74b0424.

📒 Files selected for processing (3)
  • .changeset/encode-url-unsafe-ascii-chars.md
  • packages/router-core/src/utils.ts
  • packages/router-core/tests/utils.test.ts

// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check
// eslint-disable-next-line no-control-regex
if (!/\s|[^\u0000-\u007F]/.test(path)) return path
if (!/[\s<>"`{}]|[^\u0000-\u007F]/.test(path)) return path

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add braces to the single-line if body to match repo style.

This changed line violates the TS/JS control-statement brace rule.

Suggested patch
-  if (!/[\s<>"`{}]|[^\u0000-\u007F]/.test(path)) return path
+  if (!/[\s<>"`{}]|[^\u0000-\u007F]/.test(path)) {
+    return path
+  }

As per coding guidelines, **/*.{ts,tsx,js,jsx} must “Always use curly braces for if, else, loops, and similar control statements.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!/[\s<>"`{}]|[^\u0000-\u007F]/.test(path)) return path
if (!/[\s<>"`{}]|[^\u0000-\u007F]/.test(path)) {
return path
}
🤖 Prompt for 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.

In `@packages/router-core/src/utils.ts` at line 692, The single-line if using the
regex /[\s<>"`{}]|[^\u0000-\u007F]/ on variable path must use curly braces;
replace the single-line form "if (!/.../.test(path)) return path" with a
block-style if that contains the return statement so it conforms to the repo's
control-statement brace rule (locate the usage in utils.ts where path is tested
against that regex).

Source: Coding guidelines

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.

Infinite redirect loop ("ERR_TOO_MANY_REDIRECTS") caused by encoded unsafe characters in URL pathname

1 participant