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