fix(ecmascript): do not treat lone-surrogate strings as constants (#25084)

The value of a StringLiteral and the cooked value of a template quasi encode lone surrogates with `\u{FFFD}` escapes and mark the node with a `lone_surrogates` flag. ToJsString and constant evaluation returned the encoded text, and consumers materialized it into new string literals without the flag, printing the escape encoding as literal text: `"\ud800" + "y"` folded to a string containing a literal `d800`.

Return `None` for such strings and template quasis so folds keep the original nodes.

```js
// source
console.log("\ud800" + "y");  // node prints: �y

// before (miscompiled)
console.log(`�d800y`);         // node prints: �d800y

// after
console.log(`\ud800`+`y`);     // node prints: �y
```
This commit is contained in:
Dunqing committed 2026-07-30 10:52:55 +00:00
1 parent fb402b010c
commit 8a47ff3743
4 files changed
+25

No files matched your search

@@ -162,6 +162,13 @@ impl<'a> ConstantEvaluation<'a> for Expression<'a> {
Expression::BooleanLiteral(lit) => Some(ConstantValue::Boolean(lit.value)),
Expression::BigIntLiteral(lit) => lit.to_big_int(ctx).map(ConstantValue::BigInt),
Expression::StringLiteral(lit) => {
// The value of a string with lone surrogates encodes them with
// `\u{FFFD}` escapes. Consumers materialize the returned value
// into new string literals without the `lone_surrogates` flag,
// which would print the escape encoding as literal text.
if lit.lone_surrogates {
return None;
}
Some(ConstantValue::String(Cow::Borrowed(lit.value.as_str())))
}
Expression::StaticMemberExpression(e) => e.evaluate_value_to(ctx, target_ty),
+12
View File
@@ -53,6 +53,13 @@ impl<'a> ToJsString<'a> for ArrayExpressionElement<'a> {
impl<'a> ToJsString<'a> for StringLiteral<'a> {
fn to_js_string(&self, _ctx: &impl GlobalContext<'a>) -> Option<Cow<'a, str>> {
// The value of a string with lone surrogates encodes them with
// `\u{FFFD}` escapes. Consumers build new string literals from the
// returned value without carrying the `lone_surrogates` flag over,
// which would print the escape encoding as literal text.
if self.lone_surrogates {
return None;
}
Some(Cow::Borrowed(self.value.as_str()))
}
}
@@ -61,6 +68,11 @@ impl<'a> ToJsString<'a> for TemplateLiteral<'a> {
fn to_js_string(&self, ctx: &impl GlobalContext<'a>) -> Option<Cow<'a, str>> {
let mut str = String::new();
for (i, quasi) in self.quasis.iter().enumerate() {
// See the `StringLiteral` impl: the cooked value encodes lone
// surrogates with `\u{FFFD}` escapes.
if quasi.lone_surrogates {
return None;
}
str.push_str(quasi.value.cooked.as_ref()?);
if i < self.expressions.len() {
@@ -927,6 +927,9 @@ fn test_fold_bit_shifts() {
#[test]
fn test_string_add() {
fold("x = 'a' + 'bc'", "x = 'abc'");
// Lone surrogates are stored escaped in the string value; folding would
// materialize the escape encoding as literal text.
fold_same("x = '\\ud800' + 'y'");
fold("x = 'a' + 5", "x = 'a5'");
fold("x = 5 + 'a'", "x = '5a'");
fold("x = 'a' + 5n", "x = 'a5'");
@@ -328,6 +328,9 @@ fn test_string_array_splitting() {
#[test]
fn test_template_string_to_string() {
test("x = `abcde`", "x = 'abcde'");
// Lone surrogates are stored escaped in the cooked value; converting to a
// string literal would materialize the escape encoding as literal text.
test_same("x = `\\ud800y`");
test("x = `ab cd ef`", "x = 'ab cd ef'");
test_same("x = `hello ${name}`");
test_same("tag `hello ${name}`");