From c1029d1534496eb2058c23e2cfb6b907044629d0 Mon Sep 17 00:00:00 2001 From: VihAMBR Date: Mon, 8 Jun 2026 16:23:10 +0530 Subject: [PATCH] fix(router-core): avoid null bytes in dehydrated SSR match ids dehydrateSsrMatchId replaced "/" with U+0000 so dehydrated ids would not look like crawlable URLs (#6739). U+0000 is forbidden in the HTML input stream though, so the inlined hydration payload tripped a control-character-in-input-stream parse error and failed markup validation. Encode with U+FFFD instead, which is valid in HTML and which hydrateSsrMatchId already decodes back to "/". --- .changeset/ssr-match-id-null-byte.md | 5 +++++ packages/router-core/src/ssr/ssr-match-id.ts | 2 +- packages/router-core/tests/ssr-match-id.test.ts | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .changeset/ssr-match-id-null-byte.md diff --git a/.changeset/ssr-match-id-null-byte.md b/.changeset/ssr-match-id-null-byte.md new file mode 100644 index 0000000000..1f0b654b66 --- /dev/null +++ b/.changeset/ssr-match-id-null-byte.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Encode dehydrated SSR match IDs with the replacement character instead of a null byte, so the inlined hydration payload no longer contains U+0000 (which is invalid in HTML and rejected by markup validators) diff --git a/packages/router-core/src/ssr/ssr-match-id.ts b/packages/router-core/src/ssr/ssr-match-id.ts index 498c3a0c7e..314c42835e 100644 --- a/packages/router-core/src/ssr/ssr-match-id.ts +++ b/packages/router-core/src/ssr/ssr-match-id.ts @@ -1,5 +1,5 @@ export function dehydrateSsrMatchId(id: string): string { - return id.replaceAll('/', '\0') + return id.replaceAll('/', '\uFFFD') } export function hydrateSsrMatchId(id: string): string { diff --git a/packages/router-core/tests/ssr-match-id.test.ts b/packages/router-core/tests/ssr-match-id.test.ts index 77399edb5a..676746899f 100644 --- a/packages/router-core/tests/ssr-match-id.test.ts +++ b/packages/router-core/tests/ssr-match-id.test.ts @@ -23,4 +23,21 @@ describe('ssr match id codec', () => { it('decodes browser-normalized replacement chars back to slashes', () => { expect(hydrateSsrMatchId('\uFFFDposts\uFFFD1')).toBe('/posts/1') }) + + it('does not emit control characters that are invalid in SSR HTML', () => { + const dehydratedId = dehydrateSsrMatchId( + '/$orgId/projects/$projectId//acme/projects/dashboard/{}', + ) + + // U+0000 and the other C0 control characters trigger a + // control-character-in-input-stream parse error when the dehydrated id is + // inlined into the SSR