[rust-compiler] Escape literal text that looks like a __SURROGATE_XXXX__ marker #37656

Open
sleitor opened 4:53am on September 18, 2026 wants to merge 1 commit into facebook/react main from
fix-37647

Pull Request Overview

  • Opened on September 18, 2026
  • Status Open
  • Commit count 1 with first commit September 18, 2026

Total Delta

0 Total Diff Delta

Open Days

Open 1 weekdays

Test Delta

0 Diff Delta in Test Files
Breakdown by Phase

How long has this pull request spent in each phase of its lifecycle?

Data pending calculation for pull request

Author avatar

[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: escapeLiteralMarkers runs on the JSON text before sanitizeJsonSurrogates mints markers for actual lone surrogates. restoreJsonSurrogates first decodes real markers back into \uXXXX escapes, then unescapes the escaped form back into the original literal text.


  • js_string.rs: JsString::from_marker_string recognizes the escaped form and unescapes it back to the plain literal text instead of decoding a surrogate. JsString::to_marker_string re-escapes any literal marker-shaped text it emits (for both the well-formed Utf8 case and literal runs interleaved with real surrogates in the Wtf16 case), 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, under compiler/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-pattern fails with the corrupted lone-surrogate output shown above on main), and passes with this fix.

  • Ran the full Rust fixture suite: yarn snap --rust β€” 1825/1825 passed (no regressions, including the existing lone-surrogate-string-values fixture covering the genuine-surrogate path).

  • Added Rust unit tests in js_string.rs covering: escaped-marker decode/re-encode round trip, a plain string containing literal marker-shaped text round-tripping through to_marker_string as the escaped form, and a string mixing literal marker-shaped text with an actual unpaired surrogate round-tripping correctly.


  • cargo test --workspace passes; cargo fmt --check and cargo clippy --lib are clean for the touched crate.


  • yarn workspace babel-plugin-react-compiler lint passes; tsc build of babel-plugin-react-compiler-rust passes; prettier --check is clean on bridge.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:

  1. 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 in from_marker_string, to_marker_string and restoreJsonSurrogates. This is a small change, but all four functions have to agree.
  2. 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.