Pull Request Overview
- Opened on September 18, 2026
- Status Open
- Commit count 1 with first commit September 18, 2026
Total Delta
Open Days
Test Delta
How long has this pull request spent in each phase of its lifecycle?
Data pending calculation for pull request
[rust-compiler] Escape literal text that looks like a __SURROGATE_XXXX__ marker
Summary
Fixes #37647.
The Rust bridge encodes lone surrogates in the AST JSON as __SURROGATE_XXXX__ text markers (sanitizeJsonSurrogates in bridge.ts). The decoders β JsString::from_marker_string on the Rust side, and restoreJsonSurrogates on the way back β decoded any text matching that pattern into a surrogate, including literal user text that merely happens to look like a marker (e.g. the string literal "__SURROGATE_D83D__"), silently corrupting it into a lone surrogate with no error.
function foo() {
return <Stringify value={['__SURROGATE_D83D__']} />;
}
Non-forget (expected):
(kind: ok) <div>{"value":["__SURROGATE_D83D__"]}</div>
Forget:
(kind: ok) <div>{"value":["\ud83d"]}</div>
This implements option 1 from the issue: escape any pre-existing literal occurrence of the marker pattern into a distinct __SURROGATE_ESCAPED_XXXX__ form before minting real markers, on both sides of the round trip:
-
bridge.ts:escapeLiteralMarkersruns on the JSON text beforesanitizeJsonSurrogatesmints markers for actual lone surrogates.restoreJsonSurrogatesfirst decodes real markers back into\uXXXXescapes, then unescapes the escaped form back into the original literal text. -
js_string.rs:JsString::from_marker_stringrecognizes the escaped form and unescapes it back to the plain literal text instead of decoding a surrogate.JsString::to_marker_stringre-escapes any literal marker-shaped text it emits (for both the well-formedUtf8case and literal runs interleaved with real surrogates in theWtf16case), so the JS side can safely unescape it again on the way back.
The escape/unescape steps are pure text transforms triggered only by the literal ASCII marker pattern, and are independent of the surrogate-unit-triggered marker minting, so they compose safely without colliding with genuine markers.
How did you test this change?
- Added
literal-text-matching-surrogate-marker-pattern.js, a fixture using the exact repro from the issue, undercompiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/. - Verified the bug reproduces without this fix (
yarn snap --rust -p literal-text-matching-surrogate-marker-patternfails with the corrupted lone-surrogate output shown above onmain), and passes with this fix. - Ran the full Rust fixture suite:
yarn snap --rustβ 1825/1825 passed (no regressions, including the existinglone-surrogate-string-valuesfixture covering the genuine-surrogate path). - Added Rust unit tests in
js_string.rscovering: escaped-marker decode/re-encode round trip, a plain string containing literal marker-shaped text round-tripping throughto_marker_stringas the escaped form, and a string mixing literal marker-shaped text with an actual unpaired surrogate round-tripping correctly. -
cargo test --workspacepasses;cargo fmt --checkandcargo clippy --libare clean for the touched crate. -
yarn workspace babel-plugin-react-compiler lintpasses;tscbuild ofbabel-plugin-react-compiler-rustpasses;prettier --checkis clean onbridge.ts.
What kind of issue is this?
- [x] React Compiler core (the JS output is incorrect, or your app works incorrectly after optimization)
Link to repro
No playground link: this only affects the Rust backend (babel-plugin-react-compiler-rust). The fixture below reproduces it with yarn snap --rust on main (2b19aec).
Repro steps
The Rust bridge encodes lone surrogates in the AST JSON as __SURROGATE_XXXX__ text (sanitizeJsonSurrogates in bridge.ts). The decoders, JsString::from_marker_string on the Rust side and restoreJsonSurrogates on the way back, turn any __SURROGATE_XXXX__ text into a surrogate, including text the user actually wrote. Existing text that looks like a marker is never escaped, so it gets decoded too.
import {Stringify} from 'shared-runtime';
function foo() {
return <Stringify value={['__SURROGATE_D83D__']} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: foo,
params: [],
isComponent: false,
};
yarn snap --rust:
Non-forget (expected):
(kind: ok) <div>{"value":["__SURROGATE_D83D__"]}</div>
Forget:
(kind: ok) <div>{"value":["\ud83d"]}</div>
The string silently becomes a lone surrogate, with no error. The same happens when the text is built from pieces that are folded, e.g. '__SURROGATE_' + 'D83D__' or a template literal. The TS backend is not affected. Only uppercase hex matches the marker pattern.
Any in-band text marker needs an escape for itself, so fixing this means changing the wire format on both sides. Two options I can see:
- Keep the markers and escape existing marker-like text before encoding (e.g. rewrite a literal
__SURROGATE_prefix into a separate escape marker). Then undo that escape infrom_marker_string,to_marker_stringandrestoreJsonSurrogates. This is a small change, but all four functions have to agree. - Stop sending lone surrogates as text: pass them out of band (for example as a list of positions and code units next to the JSON), or use an encoding that serde can read directly. This removes the ambiguity, but it's a bigger change to the bridge.
Happy to send a PR once there's a preferred approach.
How often does this bug happen?
Every time
What version of React are you using?
main (2b19aec)
What version of React Compiler are you using?
main (2b19aec), Rust backend
No comments have been left on this PR.