Skip to content

injectTraceMetaTags corrupts multi-byte UTF-8 split across SSR stream chunks (React hydration error #418) #23305

Description

@rolginroman

Is there an existing issue for this?

How do you use Sentry?

Sentry Saas (sentry.io)

Which SDK are you using?

@sentry/browser

SDK Version

10.70.0 (also verified against 10.54.0, 10.69.0, and 11.0.0-alpha.0;)

Framework Version

React Router 7.9.6, React 19.2.1, @cloudflare/vite-plugin 1.15.3, Vite 7

Link to Sentry event

No response

Reproduction Example/SDK Setup

Minimal deterministic repro (no React, no React Router, no server; only the package and a two-chunk stream): https://github.com/rolginroman/sentry-react-router-utf8-stream-repro

npm install @sentry/react-router
node repro.mjs

injectTraceMetaTags takes a ReadableStream and returns one, so nothing else is required to hit the bug. repro.mjs feeds it a stream whose chunk boundary falls between the two bytes of © (c2 | a9). No Sentry client is initialised, so getTraceMetaTags() returns "" and the injection is a semantic no-op. The corruption comes only from the decode/re-encode round-trip.

A full-stack repro (clean project from cloudflare/templates/react-router-starter-template, with a ?inject=0 control for offset and an end-to-end sweep harness) is at the repository root.

Steps to Reproduce

Layer 1, deterministic, no server:

git clone https://github.com/rolginroman/sentry-react-router-utf8-stream-repro
cd sentry-react-router-utf8-stream-repro/minimal
npm install
node repro.mjs

Expected output shows 1 of 4 boundary cases corrupting:

in 20 bytes ... c2 a9 20 32 30 32 36 "<head></head>© 2026"
out 24 bytes ... ef bf bd ef bf bd 20 32 30 32 36 "<head></head>�� 2026"
FAIL boundary mid-© (c2 | a9) in=20B out=24B U+FFFD=2
PASS boundary before © (| c2 a9) in=20B out=20B U+FFFD=0
PASS boundary after © (c2 a9 |) in=20B out=20B U+FFFD=0
PASS single chunk (no split) in=20B out=20B U+FFFD=0
REPRODUCED: 1/4 case(s) corrupted.

Only the mid-character boundary corrupts. That is also why this can look intermittent in production (see Why it looks intermittent below).

Layer 2, real SSR stream on workerd, with a control arm (repository root):

# from the repository root
npm install
npm run dev
node repro/sweep.mjs http://localhost:5173 900 1300 1

The route renders © after ?pad=N bytes of padding, so sweeping N walks the character across React's 2048-byte chunk boundaries. ?inject=0 is the control: same render, injectTraceMetaTags bypassed.

swept pad=900..1300 step=1 (401 requests, injection ON)
corrupted: 1/401 (pad=985: bytes 4990, U+FFFD=2, before-marker = ef bf bd ef bf bd 20)
first corrupted, re-probed with injection OFF (control):
pad=985 control U+FFFD=0 control before-marker = 3c703ec2a920 (byte-clean)

c2 a9 becomes ef bf bd ef bf bd, and the response grows by 2 bytes. The same request with injection bypassed is byte-clean.

Layer 3, production build (not a dev-server artifact):

# from the repository root
npm run preview # react-router build && vite preview
node repro/sweep.mjs http://localhost:4173 900 1300 1
# same pad=985 corrupts, at the same byte offset as dev

Layer 4, browser symptom (load /?pad=985 in Chrome):

Error: Hydration failed because the server rendered text didn't match the client.
<p>
+ © 2026 Spring. Building the future of community-backed fundraising.
- �� 2026 Spring. Building the future of community-backed fundraising.

Production build surfaces Minified React error #418. Control matrix (hydration errors in console):

URLhydration errors
:5173/?pad=985 (injection ON)1
:5173/?pad=985&inject=00
:5173/?pad=900 (injection ON)0
:4173/?pad=985 (injection ON)1
:4173/?pad=985&inject=00

Expected Result

For valid UTF-8 input, injectTraceMetaTags returns byte-identical content aside from the injected meta tags. A multi-byte character split across stream chunks is carried over and reassembled, not replaced with U+FFFD. React hydrates normally.

Actual Result

Every chunk is decoded with a fresh, non-streaming TextDecoder and re-encoded. A multi-byte character that straddles a chunk boundary is replaced by two U+FFFD, and those corrupted bytes are written into the response.

Downstream effects:

  • React hydration fails (#418). React discards the server tree and re-renders the whole document on the client.
  • The client re-render rebuilds <head> and wipes the injected sentry-trace / baggage meta tags, so the distributed-tracing linkage this function is meant to provide is lost on the pages it instruments.
  • The constant #418 noise makes it harder to notice real hydration bugs in the app.

Additional Context

Root cause in packages/react-router/src/cloudflare/index.ts:

consthtml=valueinstanceofUint8Array ? newTextDecoder().decode(value) : String(value);

A fresh TextDecoder per chunk holds no carry-over state, so an incomplete trailing sequence is flushed as U+FFFD instead of held for the next chunk (see whatwg/encoding#184). Two details make the damage stick:

  1. The corrupted string is re-encoded and enqueued, so the bad bytes land in the response, not just in a local copy.
  2. pull runs for the whole document. The return after injecting ends that one pull call, not the stream, so every chunk after </head> keeps being round-tripped (and a new TextEncoder is allocated each time).

The Node entry point has the same defect class. packages/react-router/src/server/getMetaTagTransformer.ts:

transform(chunk,_encoding,callback){consthtml=Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);// per-chunk, non-streamingif(html.includes(headClosingTag)){constmodifiedHtml=html.replace(headClosingTag,`${getTraceMetaTags()}${headClosingTag}`);callback(null,modifiedHtml);// forwards the decoded string (corrupted) on the match chunkreturn;}callback(null,chunk);}

Buffer.toString() defaults to UTF-8 and produces U+FFFD for truncated sequences the same way. The Node path only forwards the decoded string on the </head>-containing chunk, so its exposure is narrower than the Cloudflare path (which round-trips every chunk), but the root cause is the same.

Why it looks intermittent in production. Whether the bug shows up depends on whether a multi-byte character lands on a chunk boundary, and that depends on every byte offset upstream of it. Anything that shifts SSR byte offsets can flip it on or off: added markup, a different data payload, a copy edit. A branch that appears to "cause" the bug is usually just moving offsets. That may be why this has gone unreported: it shows up as a React #418 that comes and goes, and the browser renders U+FFFD silently or as . Bisect by toggling injectTraceMetaTags, not by feature branch.

Affected versions, checked against the published ESM on unpkg (re-checked 2026-08-11):

versiondecode call{ stream: true }decoder hoisted
10.54.0new TextDecoder().decode(value)nono
10.69.0new TextDecoder().decode(value)nono
10.70.0 (latest)new TextDecoder().decode(value)nono
11.0.0-alpha.0 (next)new TextDecoder().decode(value)nono

Upgrading does not fix this. On develop, packages/react-router/src/cloudflare/index.ts was last touched in #17145 (2025-08-06); getMetaTagTransformer.ts still uses per-chunk Buffer.toString().

Existing similar problems

Same defect class, already corrected elsewhere in this monorepo:

  • #9985 / #9989: the Astro middleware had the same bug (per-chunk, non-streaming decode corrupting the response). The PR description cites whatwg/encoding#184, the encoding-spec issue explaining why a streaming decoder must carry incomplete trailing bytes across chunks.
  • packages/solidstart/src/server/middleware.ts carries the same corrected pattern.

Where the per-chunk decode currently sits across the SSR meta-tag SDKs:

packageper-chunk decode
packages/astro/src/server/middleware.tsstreaming (hoisted decoder + { stream: true })
packages/solidstart/src/server/middleware.tsstreaming
packages/react-router/src/cloudflare/index.tsnon-streaming (fresh TextDecoder per chunk)
packages/react-router/src/server/getMetaTagTransformer.tsnon-streaming (Buffer.toString() per chunk)

Same symptom area, different root cause (so this is not a duplicate of #21915):

  • #21915 / #22004: @sentry/tanstackstart-react trace meta tags also surface as a React #418 hydration error, but the cause there is a '\n' whitespace text node inside <head> (from getTraceMetaTags() joining the two tags with a newline), not UTF-8 mojibake. Different SDK, different function (addMetaTagToHead), already resolved.

Same defect class outside Sentry (shows the failure mode is reachable in modern SSR frameworks):

  • TanStack/router#6223: SSR streaming corrupts multi-byte UTF-8 (Hebrew) in TanStack's own renderer. Independent of Sentry; not an artifact of this repro.

Priority

React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions