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..ba2c48d667 100644 --- a/packages/router-core/tests/ssr-match-id.test.ts +++ b/packages/router-core/tests/ssr-match-id.test.ts @@ -23,4 +23,29 @@ describe('ssr match id codec', () => { it('decodes browser-normalized replacement chars back to slashes', () => { expect(hydrateSsrMatchId('\uFFFDposts\uFFFD1')).toBe('/posts/1') }) + + it('still decodes legacy null-byte delimiters for backward compatibility', () => { + // Payloads emitted before the switch to U+FFFD encoded slashes as U+0000. + // hydrateSsrMatchId keeps the legacy decode branch so an in-flight payload + // from a previous deploy still hydrates correctly. + const nullChar = String.fromCharCode(0) + expect(hydrateSsrMatchId(`${nullChar}posts${nullChar}1`)).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