Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/encode-url-unsafe-ascii-chars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Fix infinite redirect loop on requests whose pathname contains encoded URL-unsafe ASCII characters (e.g. `<`, `>`, `"`, `` ` ``, `{`, `}`). `encodePathLikeUrl` now percent-encodes the WHATWG URL "path percent-encode set" so it round-trips with `decodePath` and the SSR redirect comparator no longer sees the URL as having changed.
16 changes: 13 additions & 3 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,14 +675,24 @@ export function decodePath(path: string) {
* encodePathLikeUrl('/path/already%20encoded') // '/path/already%20encoded' (preserved)
*/
export function encodePathLikeUrl(path: string): string {
// Encode whitespace and non-ASCII characters that browsers encode in URLs
// Encode whitespace, non-ASCII characters, and the URL-unsafe ASCII chars
// in the WHATWG URL "path percent-encode set" (`< > " ` { }`). Browsers
// always percent-encode these when they appear in a path, and `decodePath`
// turns their `%XX` forms into the literal characters - leaving them
// un-encoded here causes encode/decode to fail to round-trip, which the
// SSR redirect comparator interprets as a URL change and triggers an
// infinite redirect loop for inputs like `/<test` (#7587).
//
// `?` and `#` are intentionally left unencoded because callers pass the
// full `pathname + search + hash` string, and those characters are the
// query and fragment separators.

// 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

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

/**
Expand Down
42 changes: 42 additions & 0 deletions packages/router-core/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1019,4 +1019,46 @@ describe('encodePathLikeUrl', () => {
'/path/%F0%9F%98%80/file',
)
})

// https://github.com/TanStack/router/issues/7587 — URL-unsafe ASCII characters
// were being preserved by encodePathLikeUrl, but decodePath decoded their
// percent-encoded forms (e.g. %3C -> <). The encode/decode pair therefore
// failed to round-trip, which the SSR redirect comparator in the router used
// as a signal that the URL had changed — driving an infinite redirect loop
// on requests like `/<test`. Per the WHATWG URL "path percent-encode set"
// these characters must always be percent-encoded.
describe('URL-unsafe ASCII chars (WHATWG path percent-encode set)', () => {
it('encodes < > " ` { } in path segments', () => {
expect(encodePathLikeUrl('/<test')).toBe('/%3Ctest')
expect(encodePathLikeUrl('/test>')).toBe('/test%3E')
expect(encodePathLikeUrl('/foo"bar')).toBe('/foo%22bar')
expect(encodePathLikeUrl('/back`tick')).toBe('/back%60tick')
expect(encodePathLikeUrl('/curly{open}close')).toBe(
'/curly%7Bopen%7Dclose',
)
})

it('round-trips with decodePath for unsafe ASCII chars', () => {
// The whole point of the fix: encode and decode are inverses.
const cases = ['/%3Ctest', '/path/%3C%3Estuff', '/%22quoted%22']
for (const raw of cases) {
expect(encodePathLikeUrl(decodePath(raw).path)).toBe(raw)
}
})

it('does not double-encode already-percent-encoded sequences', () => {
// encodeURIComponent of a `%` would yield `%25`, but the input string
// does not actually contain a `%` character we want to encode — the
// sequence `%3C` is three ASCII letters that pass through unchanged.
expect(encodePathLikeUrl('/%3Ctest')).toBe('/%3Ctest')
expect(encodePathLikeUrl('/already%20encoded')).toBe('/already%20encoded')
})

it('preserves URL component separators (?, #, /) so search and hash survive', () => {
expect(encodePathLikeUrl('/path?query=1')).toBe('/path?query=1')
expect(encodePathLikeUrl('/path#anchor')).toBe('/path#anchor')
expect(encodePathLikeUrl('/a/b/c')).toBe('/a/b/c')
expect(encodePathLikeUrl('/path/<x>?q=1#h')).toBe('/path/%3Cx%3E?q=1#h')
})
})
})