Skip to content

fix(router-core): avoid null bytes in dehydrated SSR match ids#7586

Open
VihAMBR wants to merge 1 commit into
TanStack:mainfrom
VihAMBR:fix/ssr-match-id-no-null-byte
Open

fix(router-core): avoid null bytes in dehydrated SSR match ids#7586
VihAMBR wants to merge 1 commit into
TanStack:mainfrom
VihAMBR:fix/ssr-match-id-no-null-byte

Conversation

@VihAMBR
Copy link
Copy Markdown

@VihAMBR VihAMBR commented Jun 8, 2026

Summary

dehydrateSsrMatchId encodes match IDs for the SSR hydration payload by replacing / with \0. That encoding came from #6739, so the dehydrated ids stop looking like relative URLs that crawlers pick up as phantom pages.

The catch is that U+0000 is forbidden in the HTML input stream (the control-character-in-input-stream parse error in the HTML spec). The dehydrated ids are inlined into the $tsr-stream-barrier <script>, so every SSR response ends up with raw null bytes and fails markup validation. validator.w3.org reports Saw U+0000 in stream (#7581). It only works in browsers today because the parser silently rewrites those null bytes to U+FFFD, which is exactly why hydrateSsrMatchId already carries a � -> / fallback.

This swaps the delimiter from \0 to (U+FFFD REPLACEMENT CHARACTER):

The existing \0 -> / decode branch is left in place so any payload still carrying a null byte keeps round-tripping.

Testing

Added a codec test asserting the dehydrated id has no C0 control characters. It fails on main and passes with this change.

  • nx run @tanstack/router-core:test:unit (39 files, 1179 passed)
  • nx run @tanstack/router-core:test:types
  • nx run @tanstack/router-core:test:eslint
  • nx run @tanstack/router-core:build

Fixes #7581

Summary by CodeRabbit

  • Bug Fixes

    • Fixed server-side rendering hydration payload encoding to use valid characters, preventing HTML validator failures on inline hydration scripts.
  • Tests

    • Added test coverage to verify hydration data contains only valid HTML-safe characters.

dehydrateSsrMatchId replaced "/" with U+0000 so dehydrated ids would not
look like crawlable URLs (TanStack#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 "/".
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 8, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes invalid HTML produced during Server-Side Rendering by replacing null-byte (U+0000) encoding in dehydrated SSR match IDs with the Unicode replacement character (U+FFFD). The change modifies the encoding function, adds validation tests, and documents the patch release.

Changes

SSR Match ID Null-Byte Encoding Fix

Layer / File(s) Summary
SSR match ID replacement character encoding and validation
packages/router-core/src/ssr/ssr-match-id.ts, packages/router-core/tests/ssr-match-id.test.ts, .changeset/ssr-match-id-null-byte.md
dehydrateSsrMatchId now escapes / using \uFFFD instead of \0 to avoid C0 control characters in HTML payloads. New test validates the dehydrated match ID contains no null bytes or characters with codes <= 0x1f. Changeset documents the patch-level fix.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~5 minutes

Suggested labels

package: router-core

Suggested reviewers

  • schiller-manuel

Poem

🐰 A null byte squatted in your script tag bright,
Breaking HTML specs and the validators' night.
With \uFFFD replacement, the rabbit's made it right,
No more U+0000 in the SSR light! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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: replacing null bytes with Unicode replacement character in dehydrated SSR match IDs.
Linked Issues check ✅ Passed All objectives from issue #7581 are met: encoding changed from U+0000 to U+FFFD, backward compatibility maintained, test added, and SSR output now validates under HTML standards.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing issue #7581: changeset documentation, SSR match ID encoding logic, and validation tests are all tightly related.

✏️ 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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/router-core/tests/ssr-match-id.test.ts (1)

27-42: ⚡ Quick win

Consider adding a backward compatibility test for null-byte decoding.

The new test correctly validates that dehydrateSsrMatchId no longer emits C0 control characters. However, the PR description states "The existing decode branch for NUL -> "/" is retained for backward compatibility with payloads that still contain null bytes."

To ensure this backward compatibility path remains functional, consider adding an explicit test:

it('decodes legacy null-byte delimiters for backward compatibility', () => {
  expect(hydrateSsrMatchId('\0posts\01')).toBe('/posts/1')
})

This would verify that old SSR payloads containing null bytes can still be hydrated correctly by the client.

🤖 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/tests/ssr-match-id.test.ts` around lines 27 - 42, Add a
backward-compatibility unit test to verify hydrateSsrMatchId still decodes
legacy NUL delimiters: create a new test case that calls hydrateSsrMatchId with
a string containing embedded null characters (e.g., '\0posts\01') and asserts
the result equals '/posts/1'; this complements the existing dehydrateSsrMatchId
control-character test and ensures the legacy decode branch in hydrateSsrMatchId
remains functional.
🤖 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.

Nitpick comments:
In `@packages/router-core/tests/ssr-match-id.test.ts`:
- Around line 27-42: Add a backward-compatibility unit test to verify
hydrateSsrMatchId still decodes legacy NUL delimiters: create a new test case
that calls hydrateSsrMatchId with a string containing embedded null characters
(e.g., '\0posts\01') and asserts the result equals '/posts/1'; this complements
the existing dehydrateSsrMatchId control-character test and ensures the legacy
decode branch in hydrateSsrMatchId remains functional.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d7f7c13-5c8a-4233-adeb-0ca53bf28840

📥 Commits

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

📒 Files selected for processing (3)
  • .changeset/ssr-match-id-null-byte.md
  • packages/router-core/src/ssr/ssr-match-id.ts
  • packages/router-core/tests/ssr-match-id.test.ts

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.

SSR produces invalid HTML (Null Byte U+0000)

1 participant