@samitouri / QOS-React-1 / commits / 0038f63c0d

[rust-compiler] Represent string values as JsString (WTF-16 aware) (#36731)

Last of the boundary trilogy, after #36729 and #36730 (both merged); rebased onto main as a standalone 3-commit change. JS strings are WTF-16: a lone surrogate in source (`"\uD83E"`) is a legal string value, but Rust `String` is UTF-8 and cannot hold it. The compiler previously leaned on the napi bridge's `__SURROGATE_XXXX__` marker encoding end to end, so the core compiler compared, concatenated, and constant-folded marker text as if it were the actual string value. `JsString` (in `react_compiler_diagnostics`, our lowest layer) holds the common well-formed case as a plain UTF-8 `String` with zero overhead and falls back to UTF-16 code units only for ill-formed values. `StringLiteral.value` and `PrimitiveValue::String` carry it through the pipeline; constant folding concatenates via code units so split surrogate halves re-pair exactly as they do in JS; markers are emitted only at the napi edge, where the babel bridge requires them (serde_json can neither parse nor emit a lone `\uXXXX` escape). The representation is encapsulated behind an opaque struct (private `Repr` enum, borrowed `JsStringRef` view via `as_ref`) so the "well-formed values are always UTF-8" invariant that makes the derived `PartialEq`/`Hash` sound holds by construction, per review feedback. The marker decoder scans byte-wise (an earlier draft range-sliced at fixed offsets and panicked on multibyte UTF-8 following `__SURROGATE_`), validates hex digits, and accepts uppercase only, exactly mirroring what the bridge emits; lowercase marker-shaped user text survives verbatim. The HIR debug printer renders unpaired surrogates as `\uXXXX` escapes byte-identical to the TS printer. This closes the lone-surrogate divergence on the e2e harness. Verified on the rebased branch: cargo workspace tests, Rust snap channel 1804/1804, and HIR + Code parity on the lone-surrogate fixture through the comparison harness.

lauren committed Jun 15, 2026 at 23:43 UTC 0038f63c0d6f65236796b58e5753cca9fd2a3668
14 files changed +462 -49
compiler/Cargo.lock
+1
@@ -257,6 +257,7 @@ name = "react_compiler_ast"
257 version = "0.1.0"
258 dependencies = [
259 "indexmap",
260 + "react_compiler_diagnostics",
261 "serde",
262 "serde-transcode",
263 "serde_json",
compiler/crates/react_compiler/src/entrypoint/imports.rs
+9 -4
@@ -279,7 +279,12 @@ pub fn validate_restricted_imports(
279
280 for stmt in &program.body {
281 if let Statement::ImportDeclaration(import) = stmt {
282 - if restricted.contains(import.source.value.as_str()) {
282 + if import
283 + .source
284 + .value
285 + .as_str()
286 + .is_some_and(|v| restricted.contains(v))
287 + {
288 let mut detail = CompilerErrorDetail::new(
289 ErrorCategory::Todo,
290 "Bailing out due to blocklisted import",
@@ -328,7 +333,7 @@ pub fn add_imports_to_program(program: &mut Program, context: &ProgramContext) {
333 .filter_map(|(idx, stmt)| {
334 if let Statement::ImportDeclaration(import) = stmt {
335 if is_non_namespaced_import(import) {
331 - return Some((import.source.value.clone(), idx));
336 + return Some((import.source.value.to_marker_string(), idx));
337 }
338 }
339 None
@@ -363,7 +368,7 @@ pub fn add_imports_to_program(program: &mut Program, context: &ProgramContext) {
368 specifiers: import_specifiers,
369 source: StringLiteral {
370 base: BaseNode::typed("StringLiteral"),
366 - value: module_name.clone(),
371 + value: module_name.clone().into(),
372 },
373 import_kind: None,
374 assertions: None,
@@ -420,7 +425,7 @@ pub fn add_imports_to_program(program: &mut Program, context: &ProgramContext) {
425 })),
426 arguments: vec![Expression::StringLiteral(StringLiteral {
427 base: BaseNode::typed("StringLiteral"),
423 - value: module_name.clone(),
428 + value: module_name.clone().into(),
429 })],
430 type_parameters: None,
431 type_arguments: None,
compiler/crates/react_compiler/src/entrypoint/program.rs
+3 -3
@@ -1651,10 +1651,10 @@ fn has_memo_cache_function_import(program: &Program, module_name: &str) -> bool
1651 for specifier in &import.specifiers {
1652 if let ImportSpecifier::ImportSpecifier(data) = specifier {
1653 let imported_name = match &data.imported {
1654 - ModuleExportName::Identifier(id) => &id.name,
1655 - ModuleExportName::StringLiteral(s) => &s.value,
1654 + ModuleExportName::Identifier(id) => Some(id.name.as_str()),
1655 + ModuleExportName::StringLiteral(s) => s.value.as_str(),
1656 };
1657 - if imported_name == "c" {
1657 + if imported_name == Some("c") {
1658 return true;
1659 }
1660 }
compiler/crates/react_compiler_ast/Cargo.toml
+1
@@ -4,6 +4,7 @@ version = "0.1.0"
4 edition = "2024"
5
6 [dependencies]
7 +react_compiler_diagnostics = { path = "../react_compiler_diagnostics" }
8 serde = { version = "1", features = ["derive"] }
9 serde_json = { version = "1", features = ["raw_value", "unbounded_depth"] }
10 serde-transcode = "1"
compiler/crates/react_compiler_ast/src/literals.rs
+3 -1
@@ -1,3 +1,4 @@
1 +use react_compiler_diagnostics::JsString;
2 use serde::{Deserialize, Serialize};
3
4 use crate::common::BaseNode;
@@ -6,7 +7,8 @@ use crate::common::BaseNode;
7 pub struct StringLiteral {
8 #[serde(flatten)]
9 pub base: BaseNode,
9 - pub value: String,
10 + /// JS string values may contain unpaired surrogates; see [`JsString`].
11 + pub value: JsString,
12 }
13
14 #[derive(Debug, Clone, Serialize, Deserialize)]
compiler/crates/react_compiler_diagnostics/src/js_string.rs new
+335
@@ -0,0 +1,335 @@
1 +//! A JavaScript string value. JS strings are sequences of UTF-16 code units
2 +//! with no validity requirement, so a value can contain unpaired surrogate
3 +//! halves that Rust's `String` cannot represent. `JsString` keeps the common
4 +//! valid case as UTF-8 and falls back to code units only when the value is
5 +//! ill-formed, so the compiler computes on true program values instead of
6 +//! replacement characters or escape hatches.
7 +//!
8 +//! Wire format: the babel bridge transports lone surrogates as
9 +//! `__SURROGATE_XXXX__` markers (see `sanitizeJsonSurrogates` in bridge.ts),
10 +//! because serde_json can neither parse nor emit a lone `\uXXXX` escape.
11 +//! Serde for `JsString` decodes and re-emits that marker form, which keeps the
12 +//! JS side of the bridge unchanged.
13 +
14 +use std::fmt;
15 +
16 +use serde::Deserialize;
17 +use serde::Serialize;
18 +
19 +/// Invariant: `Repr::Utf8` holds every well-formed value and `Repr::Wtf16`
20 +/// only ill-formed ones (at least one unpaired surrogate). The derived
21 +/// `PartialEq`/`Hash` are only sound under this invariant: a well-formed
22 +/// value smuggled into `Wtf16` would compare unequal to its `Utf8` twin. The
23 +/// representation is private so the invariant holds by construction; match on
24 +/// [`JsString::as_ref`] to branch on well-formedness.
25 +#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26 +pub struct JsString(Repr);
27 +
28 +#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29 +enum Repr {
30 + /// A well-formed string (no unpaired surrogates), stored as UTF-8.
31 + Utf8(String),
32 + /// An ill-formed string, stored as UTF-16 code units.
33 + Wtf16(Vec<u16>),
34 +}
35 +
36 +/// Borrowed view of a [`JsString`] for callers that need to branch on
37 +/// well-formedness.
38 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39 +pub enum JsStringRef<'a> {
40 + Utf8(&'a str),
41 + Wtf16(&'a [u16]),
42 +}
43 +
44 +impl JsString {
45 + /// Build from UTF-16 code units, normalizing to UTF-8 when well-formed.
46 + pub fn from_code_units(units: Vec<u16>) -> Self {
47 + match String::from_utf16(&units) {
48 + Ok(s) => JsString(Repr::Utf8(s)),
49 + Err(_) => JsString(Repr::Wtf16(units)),
50 + }
51 + }
52 +
53 + pub fn as_ref(&self) -> JsStringRef<'_> {
54 + match &self.0 {
55 + Repr::Utf8(s) => JsStringRef::Utf8(s),
56 + Repr::Wtf16(units) => JsStringRef::Wtf16(units),
57 + }
58 + }
59 +
60 + /// The UTF-8 view, when the value is well-formed.
61 + pub fn as_str(&self) -> Option<&str> {
62 + match &self.0 {
63 + Repr::Utf8(s) => Some(s),
64 + Repr::Wtf16(_) => None,
65 + }
66 + }
67 +
68 + pub fn code_units(&self) -> Vec<u16> {
69 + match &self.0 {
70 + Repr::Utf8(s) => s.encode_utf16().collect(),
71 + Repr::Wtf16(units) => units.clone(),
72 + }
73 + }
74 +
75 + /// Length in UTF-16 code units (JS `String.prototype.length`).
76 + pub fn len_utf16(&self) -> usize {
77 + match &self.0 {
78 + Repr::Utf8(s) => s.encode_utf16().count(),
79 + Repr::Wtf16(units) => units.len(),
80 + }
81 + }
82 +
83 + /// The value with unpaired surrogates replaced by U+FFFD, for consumers
84 + /// whose string type cannot represent ill-formed values.
85 + pub fn to_string_lossy(&self) -> String {
86 + match &self.0 {
87 + Repr::Utf8(s) => s.clone(),
88 + Repr::Wtf16(units) => String::from_utf16_lossy(units),
89 + }
90 + }
91 +
92 + /// Decode the bridge wire form: a UTF-8 string in which lone surrogates
93 + /// appear as `__SURROGATE_XXXX__` markers (uppercase hex, mirroring what
94 + /// `sanitizeJsonSurrogates` emits and `restoreJsonSurrogates` accepts).
95 + ///
96 + /// All scanning is byte-wise: a marker is 18 ASCII bytes, so byte-slice
97 + /// comparisons cannot land on a UTF-8 char boundary the way `str` range
98 + /// indexing can when multibyte text follows the prefix.
99 + pub fn from_marker_string(s: &str) -> Self {
100 + const PREFIX: &[u8] = b"__SURROGATE_";
101 + const MARKER_LEN: usize = 18;
102 + if !s.contains("__SURROGATE_") {
103 + return JsString(Repr::Utf8(s.to_string()));
104 + }
105 + let bytes = s.as_bytes();
106 + let mut units: Vec<u16> = Vec::with_capacity(s.len());
107 + let mut pos = 0;
108 + let mut segment_start = 0;
109 + while let Some(found) = s[pos..].find("__SURROGATE_") {
110 + let idx = pos + found;
111 + let tail = &bytes[idx..];
112 + let well_formed = tail.len() >= MARKER_LEN
113 + && &tail[MARKER_LEN - 2..MARKER_LEN] == b"__"
114 + && tail[PREFIX.len()..PREFIX.len() + 4]
115 + .iter()
116 + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_lowercase());
117 + if well_formed {
118 + let hex = std::str::from_utf8(&tail[PREFIX.len()..PREFIX.len() + 4])
119 + .expect("ascii hex is valid utf8");
120 + let unit = u16::from_str_radix(hex, 16).expect("validated hex digits");
121 + units.extend(s[segment_start..idx].encode_utf16());
122 + units.push(unit);
123 + pos = idx + MARKER_LEN;
124 + segment_start = pos;
125 + } else {
126 + // Not a well-formed marker: keep the literal text and continue
127 + // scanning after the prefix.
128 + pos = idx + PREFIX.len();
129 + }
130 + }
131 + units.extend(s[segment_start..].encode_utf16());
132 + JsString::from_code_units(units)
133 + }
134 +
135 + /// Encode to the bridge wire form (markers for unpaired surrogates).
136 + pub fn to_marker_string(&self) -> String {
137 + match &self.0 {
138 + Repr::Utf8(s) => s.clone(),
139 + Repr::Wtf16(units) => {
140 + let mut out = String::with_capacity(units.len() * 2);
141 + let mut iter = units.iter().copied().peekable();
142 + while let Some(unit) = iter.next() {
143 + match unit {
144 + 0xD800..=0xDBFF => {
145 + if let Some(&next) = iter.peek() {
146 + if (0xDC00..=0xDFFF).contains(&next) {
147 + iter.next();
148 + let cp = 0x10000
149 + + ((unit as u32 - 0xD800) << 10)
150 + + (next as u32 - 0xDC00);
151 + out.push(char::from_u32(cp).expect("valid supplementary"));
152 + continue;
153 + }
154 + }
155 + out.push_str(&format!("__SURROGATE_{unit:04X}__"));
156 + }
157 + 0xDC00..=0xDFFF => {
158 + out.push_str(&format!("__SURROGATE_{unit:04X}__"));
159 + }
160 + _ => {
161 + out.push(
162 + char::from_u32(unit as u32).expect("BMP non-surrogate is a char"),
163 + );
164 + }
165 + }
166 + }
167 + out
168 + }
169 + }
170 + }
171 +
172 + /// Render as JS-source-style escaped text, matching the form TS's debug
173 + /// printer produces via JSON.stringify: unpaired surrogates print as
174 + /// lowercase `\udXXX` escapes inside the otherwise UTF-8 text.
175 + pub fn to_escaped_string(&self) -> String {
176 + match &self.0 {
177 + Repr::Utf8(s) => s.clone(),
178 + Repr::Wtf16(units) => {
179 + let mut out = String::with_capacity(units.len() * 2);
180 + let mut iter = units.iter().copied().peekable();
181 + while let Some(unit) = iter.next() {
182 + match unit {
183 + 0xD800..=0xDBFF => {
184 + if let Some(&next) = iter.peek() {
185 + if (0xDC00..=0xDFFF).contains(&next) {
186 + iter.next();
187 + let cp = 0x10000
188 + + ((unit as u32 - 0xD800) << 10)
189 + + (next as u32 - 0xDC00);
190 + out.push(char::from_u32(cp).expect("valid supplementary"));
191 + continue;
192 + }
193 + }
194 + out.push_str(&format!("\\u{unit:04x}"));
195 + }
196 + 0xDC00..=0xDFFF => {
197 + out.push_str(&format!("\\u{unit:04x}"));
198 + }
199 + _ => {
200 + out.push(
201 + char::from_u32(unit as u32).expect("BMP non-surrogate is a char"),
202 + );
203 + }
204 + }
205 + }
206 + out
207 + }
208 + }
209 + }
210 +}
211 +
212 +impl From<String> for JsString {
213 + fn from(s: String) -> Self {
214 + // A Rust String is valid UTF-8 and so cannot contain an unpaired
215 + // surrogate; constructing Utf8 directly preserves the invariant.
216 + JsString(Repr::Utf8(s))
217 + }
218 +}
219 +
220 +impl From<&str> for JsString {
221 + fn from(s: &str) -> Self {
222 + JsString(Repr::Utf8(s.to_string()))
223 + }
224 +}
225 +
226 +impl PartialEq<str> for JsString {
227 + fn eq(&self, other: &str) -> bool {
228 + self.as_str() == Some(other)
229 + }
230 +}
231 +
232 +impl PartialEq<&str> for JsString {
233 + fn eq(&self, other: &&str) -> bool {
234 + self.as_str() == Some(*other)
235 + }
236 +}
237 +
238 +impl fmt::Display for JsString {
239 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 + f.write_str(&self.to_escaped_string())
241 + }
242 +}
243 +
244 +impl Serialize for JsString {
245 + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
246 + serializer.serialize_str(&self.to_marker_string())
247 + }
248 +}
249 +
250 +impl<'de> Deserialize<'de> for JsString {
251 + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
252 + let s = String::deserialize(deserializer)?;
253 + Ok(JsString::from_marker_string(&s))
254 + }
255 +}
256 +
257 +#[cfg(test)]
258 +mod tests {
259 + use super::JsString;
260 + use super::JsStringRef;
261 +
262 + #[test]
263 + fn as_ref_views_match_well_formedness() {
264 + assert!(matches!(
265 + JsString::from("plain").as_ref(),
266 + JsStringRef::Utf8("plain")
267 + ));
268 + assert!(matches!(
269 + JsString::from_code_units(vec![0xD83E]).as_ref(),
270 + JsStringRef::Wtf16(&[0xD83E])
271 + ));
272 + // Well-formed code units normalize to the Utf8 representation, so
273 + // equal logical strings are equal values regardless of how they
274 + // were constructed.
275 + assert_eq!(
276 + JsString::from_code_units("plain".encode_utf16().collect()),
277 + JsString::from("plain")
278 + );
279 + }
280 +
281 + #[test]
282 + fn marker_round_trip_preserves_lone_surrogates() {
283 + let js = JsString::from_marker_string("__SURROGATE_D83E__");
284 + assert_eq!(js.code_units(), vec![0xD83E]);
285 + assert_eq!(js.to_marker_string(), "__SURROGATE_D83E__");
286 + assert_eq!(js.to_escaped_string(), "\\ud83e");
287 + }
288 +
289 + #[test]
290 + fn paired_halves_render_as_the_supplementary_character() {
291 + let js = JsString::from_code_units(vec![0xD83E, 0xDD21]);
292 + assert_eq!(js.as_str(), Some("\u{1F921}"));
293 + }
294 +
295 + #[test]
296 + fn plain_strings_stay_utf8_and_compare_with_str() {
297 + let js = JsString::from("use memo");
298 + assert!(js == "use memo");
299 + assert_eq!(js.to_marker_string(), "use memo");
300 + }
301 +
302 + #[test]
303 + fn malformed_marker_text_is_kept_literally() {
304 + let js = JsString::from_marker_string("__SURROGATE_XYZ__");
305 + assert_eq!(js.as_str(), Some("__SURROGATE_XYZ__"));
306 + }
307 +
308 + #[test]
309 + fn multibyte_text_after_marker_prefix_does_not_panic() {
310 + let input = "__SURROGATE_\u{20AC}\u{20AC}";
311 + let js = JsString::from_marker_string(input);
312 + assert_eq!(js.as_str(), Some(input));
313 +
314 + let truncated = "__SURROGATE_D8";
315 + assert_eq!(
316 + JsString::from_marker_string(truncated).as_str(),
317 + Some(truncated)
318 + );
319 +
320 + let mixed = "a\u{20AC}__SURROGATE_D83E__b\u{20AC}";
321 + let js = JsString::from_marker_string(mixed);
322 + let mut expected: Vec<u16> = "a\u{20AC}".encode_utf16().collect();
323 + expected.push(0xD83E);
324 + expected.extend("b\u{20AC}".encode_utf16());
325 + assert_eq!(js.code_units(), expected);
326 + }
327 +
328 + #[test]
329 + fn lowercase_hex_markers_are_not_decoded() {
330 + // The bridge emits uppercase hex only; lowercase marker-shaped text is
331 + // user text and must survive verbatim.
332 + let input = "__SURROGATE_d83e__";
333 + assert_eq!(JsString::from_marker_string(input).as_str(), Some(input));
334 + }
335 +}
compiler/crates/react_compiler_diagnostics/src/lib.rs
+3
@@ -1,4 +1,7 @@
1 pub mod code_frame;
2 +pub mod js_string;
3 +
4 +pub use js_string::JsString;
5
6 use serde::{Deserialize, Serialize};
7
compiler/crates/react_compiler_hir/src/lib.rs
+1 -1
@@ -845,7 +845,7 @@ pub enum PrimitiveValue {
845 Undefined,
846 Boolean(bool),
847 Number(FloatValue),
848 - String(String),
848 + String(react_compiler_diagnostics::JsString),
849 }
850
851 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
compiler/crates/react_compiler_hir/src/print.rs
+47 -1
@@ -80,7 +80,53 @@ pub fn format_primitive(prim: &crate::PrimitiveValue) -> String {
80 crate::PrimitiveValue::Undefined => "undefined".to_string(),
81 crate::PrimitiveValue::Boolean(b) => format!("{}", b),
82 crate::PrimitiveValue::Number(n) => crate::format_js_number(n.value()),
83 - crate::PrimitiveValue::String(s) => format_js_string(s),
83 + crate::PrimitiveValue::String(s) => match s.as_str() {
84 + Some(utf8) => format_js_string(utf8),
85 + // Ill-formed strings: escape the well-formed segments exactly like
86 + // format_js_string and render each unpaired surrogate as \uXXXX,
87 + // matching what TS's JSON.stringify-based printer emits.
88 + None => {
89 + let mut result = String::new();
90 + result.push('"');
91 + let mut units = s.code_units().into_iter().peekable();
92 + while let Some(unit) = units.next() {
93 + let is_lead = (0xD800..=0xDBFF).contains(&unit);
94 + let is_trail = (0xDC00..=0xDFFF).contains(&unit);
95 + if is_lead {
96 + if let Some(&next) = units.peek() {
97 + if (0xDC00..=0xDFFF).contains(&next) {
98 + units.next();
99 + let cp = 0x10000
100 + + ((unit as u32 - 0xD800) << 10)
101 + + (next as u32 - 0xDC00);
102 + result.push(char::from_u32(cp).expect("valid supplementary"));
103 + continue;
104 + }
105 + }
106 + }
107 + if is_lead || is_trail {
108 + result.push_str(&format!("\\u{unit:04x}"));
109 + continue;
110 + }
111 + let c = char::from_u32(unit as u32).expect("BMP non-surrogate is a char");
112 + match c {
113 + '"' => result.push_str("\\\""),
114 + '\\' => result.push_str("\\\\"),
115 + '\n' => result.push_str("\\n"),
116 + '\r' => result.push_str("\\r"),
117 + '\t' => result.push_str("\\t"),
118 + '\u{0008}' => result.push_str("\\b"),
119 + '\u{000c}' => result.push_str("\\f"),
120 + c if (c as u32) <= 0x1F => {
121 + result.push_str(&format!("\\u{:04x}", c as u32));
122 + }
123 + c => result.push(c),
124 + }
125 + }
126 + result.push('"');
127 + result
128 + }
129 + },
130 }
131 }
132
compiler/crates/react_compiler_inference/src/memoize_fbt_and_macro_operands_in_same_scope.rs
+3 -1
@@ -134,7 +134,9 @@ fn populate_macro_tags(
134 value: PrimitiveValue::String(s),
135 ..
136 } => {
137 - if let Some(macro_def) = macro_kinds.get(s.as_str()) {
137 + if let Some(macro_def) =
138 + s.as_str().and_then(|utf8| macro_kinds.get(utf8))
139 + {
140 // We don't distinguish between tag names and strings, so record
141 // all `fbt` string literals in case they are used as a jsx tag.
142 macro_tags.insert(lvalue_id, macro_def.clone());
compiler/crates/react_compiler_lowering/src/build_hir.rs
+4 -2
@@ -6289,7 +6289,7 @@ fn lower_jsx_element_name(
6289 let place = lower_value_to_temporary(
6290 builder,
6291 InstructionValue::Primitive {
6292 - value: PrimitiveValue::String(tag),
6292 + value: PrimitiveValue::String(tag.into()),
6293 loc: loc.clone(),
6294 },
6295 )?;
@@ -6518,8 +6518,10 @@ fn lower_object_property_key(
6518 ) -> Result<Option<ObjectPropertyKey>, CompilerError> {
6519 use react_compiler_ast::expressions::Expression;
6520 match key {
6521 + // Property keys stay String-typed; the marker wire form preserves the
6522 + // pre-JsString behavior for pathological surrogate keys end to end.
6523 Expression::StringLiteral(lit) => Ok(Some(ObjectPropertyKey::String {
6522 - name: lit.value.clone(),
6524 + name: lit.value.to_marker_string(),
6525 })),
6526 Expression::Identifier(ident) if !computed => Ok(Some(ObjectPropertyKey::Identifier {
6527 name: ident.name.clone(),
compiler/crates/react_compiler_optimization/src/constant_propagation.rs
+27 -16
@@ -26,6 +26,7 @@
26
27 use std::collections::HashMap;
28
29 +use react_compiler_diagnostics::JsString;
30 use react_compiler_hir::environment::Environment;
31 use react_compiler_hir::{
32 BinaryOperator, BlockKind, FloatValue, FunctionId, GotoVariant, HirFunction, IdentifierId,
@@ -303,10 +304,11 @@ fn evaluate_instruction(
304 }) = prop_value
305 {
306 match prim {
306 - PrimitiveValue::String(s) if is_valid_identifier(s) => {
307 + PrimitiveValue::String(s) if s.as_str().is_some_and(is_valid_identifier) => {
308 let object = object.clone();
309 let loc = *loc;
309 - let new_property = PropertyLiteral::String(s.clone());
310 + let new_property =
311 + PropertyLiteral::String(s.as_str().expect("guarded utf8").to_string());
312 func.instructions[instr_id.0 as usize].value =
313 InstructionValue::PropertyLoad {
314 object,
@@ -345,11 +347,12 @@ fn evaluate_instruction(
347 }) = prop_value
348 {
349 match prim {
348 - PrimitiveValue::String(s) if is_valid_identifier(s) => {
350 + PrimitiveValue::String(s) if s.as_str().is_some_and(is_valid_identifier) => {
351 let object = object.clone();
352 let store_value = value.clone();
353 let loc = *loc;
352 - let new_property = PropertyLiteral::String(s.clone());
354 + let new_property =
355 + PropertyLiteral::String(s.as_str().expect("guarded utf8").to_string());
356 func.instructions[instr_id.0 as usize].value =
357 InstructionValue::PropertyStore {
358 object,
@@ -534,7 +537,7 @@ fn evaluate_instruction(
537 if let PropertyLiteral::String(prop_name) = property {
538 if prop_name == "length" {
539 // Use UTF-16 code unit count to match JS .length semantics
537 - let len = s.encode_utf16().count() as f64;
540 + let len = s.len_utf16() as f64;
541 let loc = *loc;
542 let result = Constant::Primitive {
543 value: PrimitiveValue::Number(FloatValue::new(len)),
@@ -567,11 +570,11 @@ fn evaluate_instruction(
570 }
571 let loc = *loc;
572 let result = Constant::Primitive {
570 - value: PrimitiveValue::String(result_string.clone()),
573 + value: PrimitiveValue::String(JsString::from_marker_string(&result_string)),
574 loc,
575 };
576 func.instructions[instr_id.0 as usize].value = InstructionValue::Primitive {
574 - value: PrimitiveValue::String(result_string),
577 + value: PrimitiveValue::String(JsString::from_marker_string(&result_string)),
578 loc,
579 };
580 return Some(result);
@@ -600,7 +603,7 @@ fn evaluate_instruction(
603 PrimitiveValue::Null => "null".to_string(),
604 PrimitiveValue::Boolean(b) => b.to_string(),
605 PrimitiveValue::Number(n) => format_js_number(n.value()),
603 - PrimitiveValue::String(s) => s.clone(),
606 + PrimitiveValue::String(s) => s.to_marker_string(),
607 // TS rejects undefined subexpression values
608 PrimitiveValue::Undefined => return None,
609 };
@@ -617,11 +620,11 @@ fn evaluate_instruction(
620
621 let loc = *loc;
622 let result = Constant::Primitive {
620 - value: PrimitiveValue::String(result_string.clone()),
623 + value: PrimitiveValue::String(JsString::from_marker_string(&result_string)),
624 loc,
625 };
626 func.instructions[instr_id.0 as usize].value = InstructionValue::Primitive {
624 - value: PrimitiveValue::String(result_string),
627 + value: PrimitiveValue::String(JsString::from_marker_string(&result_string)),
628 loc,
629 };
630 Some(result)
@@ -847,7 +850,7 @@ fn is_truthy(value: &PrimitiveValue) -> bool {
850 let v = n.value();
851 v != 0.0 && !v.is_nan()
852 }
850 - PrimitiveValue::String(s) => !s.is_empty(),
853 + PrimitiveValue::String(s) => s.len_utf16() != 0,
854 }
855 }
856
@@ -866,9 +869,13 @@ fn evaluate_binary_op(
869 FloatValue::new(l.value() + r.value()),
870 )),
871 (PrimitiveValue::String(l), PrimitiveValue::String(r)) => {
869 - let mut s = l.clone();
870 - s.push_str(r);
871 - Some(PrimitiveValue::String(s))
872 + // Concatenate as code units: JS `+` can pair up surrogate
873 + // halves split across the operands.
874 + let mut units = l.code_units();
875 + units.extend(r.code_units());
876 + Some(PrimitiveValue::String(
877 + react_compiler_diagnostics::JsString::from_code_units(units),
878 + ))
879 }
880 _ => None,
881 },
@@ -1056,8 +1063,12 @@ fn js_abstract_equal(lhs: &PrimitiveValue, rhs: &PrimitiveValue) -> bool {
1063 // Cross-type coercions for primitives
1064 (PrimitiveValue::Number(n), PrimitiveValue::String(s))
1065 | (PrimitiveValue::String(s), PrimitiveValue::Number(n)) => {
1059 - // String is coerced to number using JS ToNumber semantics
1060 - let sv = js_to_number(s);
1066 + // String is coerced to number using JS ToNumber semantics.
1067 + // Ill-formed strings coerce to NaN, like any non-numeric text.
1068 + let sv = match s.as_str() {
1069 + Some(utf8) => js_to_number(utf8),
1070 + None => f64::NAN,
1071 + };
1072 let nv = n.value();
1073 if nv.is_nan() || sv.is_nan() {
1074 false
compiler/crates/react_compiler_reactive_scopes/src/codegen_reactive_function.rs
+24 -19
@@ -282,7 +282,7 @@ pub fn codegen_function(
282 })),
283 right: Box::new(Expression::StringLiteral(StringLiteral {
284 base: BaseNode::typed("StringLiteral"),
285 - value: hash.clone(),
285 + value: hash.clone().into(),
286 })),
287 })),
288 consequent: Box::new(Statement::BlockStatement(BlockStatement {
@@ -382,7 +382,7 @@ pub fn codegen_function(
382 arguments: vec![Expression::StringLiteral(
383 StringLiteral {
384 base: BaseNode::typed("StringLiteral"),
385 - value: MEMO_CACHE_SENTINEL.to_string(),
385 + value: MEMO_CACHE_SENTINEL.to_string().into(),
386 },
387 )],
388 type_parameters: None,
@@ -421,7 +421,7 @@ pub fn codegen_function(
421 )),
422 right: Box::new(Expression::StringLiteral(StringLiteral {
423 base: BaseNode::typed("StringLiteral"),
424 - value: hash.clone(),
424 + value: hash.clone().into(),
425 })),
426 },
427 )),
@@ -493,11 +493,11 @@ pub fn codegen_function(
493 arguments: vec![
494 Expression::StringLiteral(StringLiteral {
495 base: BaseNode::typed("StringLiteral"),
496 - value: fn_name_str.to_string(),
496 + value: fn_name_str.to_string().into(),
497 }),
498 Expression::StringLiteral(StringLiteral {
499 base: BaseNode::typed("StringLiteral"),
500 - value: filename_str.to_string(),
500 + value: filename_str.to_string().into(),
501 }),
502 ],
503 type_parameters: None,
@@ -2010,7 +2010,7 @@ fn codegen_instruction_value(
2010 })?;
2011 expressions.push(Expression::StringLiteral(StringLiteral {
2012 base: BaseNode::typed("StringLiteral"),
2013 - value: format!("TODO handle declaration"),
2013 + value: format!("TODO handle declaration").into(),
2014 }));
2015 }
2016 _ => {
@@ -2025,7 +2025,7 @@ fn codegen_instruction_value(
2025 })?;
2026 expressions.push(Expression::StringLiteral(StringLiteral {
2027 base: BaseNode::typed("StringLiteral"),
2028 - value: format!("TODO handle statement"),
2028 + value: format!("TODO handle statement").into(),
2029 }));
2030 }
2031 }
@@ -2714,7 +2714,7 @@ fn codegen_function_expression(
2714 base: BaseNode::typed("ObjectProperty"),
2715 key: Box::new(Expression::StringLiteral(StringLiteral {
2716 base: BaseNode::typed("StringLiteral"),
2717 - value: hint.clone(),
2717 + value: hint.clone().into(),
2718 })),
2719 value: Box::new(value),
2720 computed: false,
@@ -2726,7 +2726,7 @@ fn codegen_function_expression(
2726 })),
2727 property: Box::new(Expression::StringLiteral(StringLiteral {
2728 base: BaseNode::typed("StringLiteral"),
2729 - value: hint.clone(),
2729 + value: hint.clone().into(),
2730 })),
2731 computed: true,
2732 });
@@ -2848,7 +2848,7 @@ fn codegen_object_property_key(
2848 match key {
2849 ObjectPropertyKey::String { name } => Ok(Expression::StringLiteral(StringLiteral {
2850 base: BaseNode::typed("StringLiteral"),
2851 - value: name.clone(),
2851 + value: name.clone().into(),
2852 })),
2853 ObjectPropertyKey::Identifier { name } => Ok(Expression::Identifier(make_identifier(name))),
2854 ObjectPropertyKey::Computed { name } => {
@@ -2892,7 +2892,7 @@ fn codegen_jsx_expression(
2892 JsxTag::Builtin(builtin) => (
2893 Expression::StringLiteral(StringLiteral {
2894 base: BaseNode::typed("StringLiteral"),
2895 - value: builtin.name.clone(),
2895 + value: builtin.name.clone().into(),
2896 }),
2897 None,
2898 ),
@@ -2901,7 +2901,9 @@ fn codegen_jsx_expression(
2901 let jsx_tag = expression_to_jsx_tag(&tag_value, jsx_tag_loc(tag))?;
2902
2903 let is_fbt_tag = if let Expression::StringLiteral(ref s) = tag_value {
2904 - SINGLE_CHILD_FBT_TAGS.contains(&s.value.as_str())
2904 + s.value
2905 + .as_str()
2906 + .is_some_and(|v| SINGLE_CHILD_FBT_TAGS.contains(&v))
2907 } else {
2908 false
2909 };
@@ -3002,7 +3004,7 @@ fn codegen_jsx_attribute(
3004 let inner_value = codegen_place_to_expression(cx, place)?;
3005 let attr_value = match &inner_value {
3006 Expression::StringLiteral(s) => {
3005 - if string_requires_expr_container(&s.value)
3007 + if string_requires_expr_container(&s.value.to_marker_string())
3008 && !cx.fbt_operands.contains(&place.identifier)
3009 {
3010 Some(JSXAttributeValue::JSXExpressionContainer(
@@ -3065,7 +3067,7 @@ fn codegen_jsx_element(cx: &mut Context, place: &Place) -> Result<JSXChild, Comp
3067 expression: JSXExpressionContainerExpr::Expression(Box::new(
3068 Expression::StringLiteral(StringLiteral {
3069 base: base_node_with_loc("StringLiteral", loc),
3068 - value: text.value.clone(),
3070 + value: text.value.clone().into(),
3071 }),
3072 )),
3073 }))
@@ -3121,8 +3123,11 @@ fn expression_to_jsx_tag(
3123 convert_member_expression_to_jsx(me)?,
3124 )),
3125 Expression::StringLiteral(s) => {
3124 - if s.value.contains(':') {
3125 - let parts: Vec<&str> = s.value.splitn(2, ':').collect();
3126 + // JSX tag names are identifier-shaped; the marker form preserves
3127 + // the pre-JsString behavior for pathological values.
3128 + let tag_text = s.value.to_marker_string();
3129 + if tag_text.contains(':') {
3130 + let parts: Vec<&str> = tag_text.splitn(2, ':').collect();
3131 Ok(JSXElementName::JSXNamespacedName(JSXNamespacedName {
3132 base: base_node_with_loc("JSXNamespacedName", loc),
3133 namespace: JSXIdentifier {
@@ -3137,7 +3142,7 @@ fn expression_to_jsx_tag(
3142 } else {
3143 Ok(JSXElementName::JSXIdentifier(JSXIdentifier {
3144 base: base_node_with_loc("JSXIdentifier", loc),
3140 - name: s.value.clone(),
3145 + name: tag_text,
3146 }))
3147 }
3148 }
@@ -3746,7 +3751,7 @@ fn symbol_for(name: &str) -> Expression {
3751 })),
3752 arguments: vec![Expression::StringLiteral(StringLiteral {
3753 base: BaseNode::typed("StringLiteral"),
3749 - value: name.to_string(),
3754 + value: name.to_string().into(),
3755 })],
3756 type_parameters: None,
3757 type_arguments: None,
@@ -3824,7 +3829,7 @@ fn convert_value_to_expression(value: ExpressionOrJsxText) -> Expression {
3829 ExpressionOrJsxText::Expression(e) => e,
3830 ExpressionOrJsxText::JsxText(text) => Expression::StringLiteral(StringLiteral {
3831 base: BaseNode::typed("StringLiteral"),
3827 - value: text.value,
3832 + value: text.value.into(),
3833 }),
3834 }
3835 }
compiler/crates/react_compiler_reactive_scopes/src/propagate_early_returns.rs
+1 -1
@@ -264,7 +264,7 @@ fn apply_early_return_to_scope(
264 loc: None, // GeneratedSource
265 }),
266 value: ReactiveValue::Instruction(InstructionValue::Primitive {
267 - value: PrimitiveValue::String(EARLY_RETURN_SENTINEL.to_string()),
267 + value: PrimitiveValue::String(EARLY_RETURN_SENTINEL.into()),
268 loc,
269 }),
270 effects: None,