Skip to content

Commit 667af8c

Browse files
authored
fix(es/optimization): preserve JSON numeric values (#12051)
**Description:** The JSON parse optimization had a number fast path that cast through `i64`. That path could be unsafe for out-of-range values and for `-0`, changing observable results. This PR keeps the optimization but only uses the `i64` path when safe: when the number is an integer within `i64` range and does not alter the zero sign. Other finite numbers are serialized via `serde_json::Number::from_f64`, and non-finite handling is updated so `NaN` is rejected while `±Infinity` is emitted as `±2e308`. **Related issue (if exists):** - #12043 - #12047 - #12050 - #12048 - **#12051** (current) - #12052 - #12053 - #12054 - #12055 - #12056 - #12057 - #12058 - #12049 - #12059 - #12060
1 parent 4c9d277 commit 667af8c

7 files changed

Lines changed: 122 additions & 54 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
swc_core: patch
3+
swc_ecma_transforms_optimization: patch
4+
swc_ecma_utils: patch
5+
---
6+
7+
fix(es/optimization): Preserve large numbers, overflowed infinities, and negative zero in JSON literals.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/swc_ecma_transforms_optimization/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ once_cell = { workspace = true }
2929
par-core = { workspace = true }
3030
petgraph = { workspace = true }
3131
rustc-hash = { workspace = true }
32-
serde_json = { workspace = true }
32+
serde = { workspace = true }
33+
serde_json = { workspace = true, features = ["raw_value"] }
3334
tracing = { workspace = true }
3435

3536
swc_atoms = { version = "10.0.0", path = "../swc_atoms" }

crates/swc_ecma_transforms_optimization/src/json_parse.rs

Lines changed: 100 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
use serde_json::Value;
1+
use std::{collections::BTreeMap, fmt::Write};
2+
3+
use serde::{
4+
ser::{SerializeMap, SerializeSeq},
5+
Serialize, Serializer,
6+
};
7+
use serde_json::value::RawValue;
28
use swc_atoms::Wtf8Atom;
3-
use swc_common::{util::take::Take, Spanned, DUMMY_SP};
9+
use swc_common::{Spanned, DUMMY_SP};
410
use swc_ecma_ast::*;
511
use swc_ecma_transforms_base::perf::Parallel;
612
use swc_ecma_utils::{calc_literal_cost, member_expr, number::ToJsString, ExprFactory};
@@ -65,13 +71,11 @@ impl VisitMut for JsonParse {
6571
Expr::Array(..) | Expr::Object(..) => {
6672
let (is_lit, cost) = calc_literal_cost(&*expr, false);
6773
if is_lit && cost >= self.min_cost {
68-
let value =
69-
serde_json::to_string(&jsonify(expr.take())).unwrap_or_else(|err| {
70-
unreachable!("failed to serialize serde_json::Value as json: {}", err)
71-
});
74+
let span = expr.span();
75+
let value = jsonify(expr);
7276

7377
*expr = CallExpr {
74-
span: expr.span(),
78+
span,
7579
callee: member_expr!(Default::default(), DUMMY_SP, JSON.parse).as_callee(),
7680
args: vec![Lit::Str(Str {
7781
span: DUMMY_SP,
@@ -109,61 +113,105 @@ fn wtf8_to_json_string(value: &Wtf8Atom) -> String {
109113
result.push(ch);
110114
} else {
111115
// Lone surrogate - escape as \uXXXX
112-
use std::fmt::Write;
113116
write!(&mut result, "\\u{:04X}", cp.to_u32()).unwrap();
114117
}
115118
}
116119
result
117120
}
118121

119-
fn jsonify(e: Expr) -> Value {
120-
match e {
121-
Expr::Object(obj) => Value::Object(
122-
obj.props
123-
.into_iter()
124-
.map(|v| match v {
125-
PropOrSpread::Prop(p) if p.is_key_value() => p.key_value().unwrap(),
126-
_ => unreachable!(),
127-
})
128-
.map(|p: KeyValueProp| {
129-
let value = jsonify(*p.value);
130-
let key = match p.key {
131-
PropName::Str(s) => wtf8_to_json_string(&s.value),
132-
PropName::Ident(id) => id.sym.to_string(),
133-
PropName::Num(n) => n.value.to_js_string(),
134-
_ => unreachable!(),
122+
/// Converts a finite ECMAScript number without using a saturating integer cast.
123+
fn json_number(value: f64) -> serde_json::Number {
124+
let is_i64 = value.fract() == 0.0 && value >= i64::MIN as f64 && value < i64::MAX as f64;
125+
let preserves_zero_sign = value != 0.0 || value.is_sign_positive();
126+
127+
if is_i64 && preserves_zero_sign {
128+
return (value as i64).into();
129+
}
130+
131+
serde_json::Number::from_f64(value)
132+
.unwrap_or_else(|| unreachable!("non-finite numbers require raw JSON serialization"))
133+
}
134+
135+
/// Converts a property name to the string key created by an object literal.
136+
fn json_key(key: &PropName) -> String {
137+
match key {
138+
PropName::Str(s) => wtf8_to_json_string(&s.value),
139+
PropName::Ident(id) => id.sym.to_string(),
140+
PropName::Num(n) => n.value.to_js_string(),
141+
_ => unreachable!(),
142+
}
143+
}
144+
145+
/// Serializes a literal expression to JSON text while preserving ECMAScript
146+
/// numeric values that are not representable by `serde_json::Value`.
147+
fn jsonify(e: &Expr) -> String {
148+
serde_json::to_string(&JsonLiteral(e))
149+
.unwrap_or_else(|err| unreachable!("failed to serialize literal as JSON: {err}"))
150+
}
151+
152+
/// Adapts an ECMAScript literal expression to Serde's data model.
153+
struct JsonLiteral<'a>(&'a Expr);
154+
155+
impl Serialize for JsonLiteral<'_> {
156+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
157+
where
158+
S: Serializer,
159+
{
160+
match self.0 {
161+
Expr::Object(obj) => {
162+
let mut values = BTreeMap::new();
163+
for prop in &obj.props {
164+
let PropOrSpread::Prop(prop) = prop else {
165+
unreachable!()
166+
};
167+
let Prop::KeyValue(prop) = &**prop else {
168+
unreachable!()
135169
};
136-
(key, value)
137-
})
138-
.collect(),
139-
),
140-
Expr::Array(arr) => Value::Array(
141-
arr.elems
142-
.into_iter()
143-
.map(|v| jsonify(*v.unwrap().expr))
144-
.collect(),
145-
),
146-
Expr::Lit(Lit::Str(Str { value, .. })) => Value::String(wtf8_to_json_string(&value)),
147-
Expr::Lit(Lit::Num(Number { value, .. })) => {
148-
if value.fract() == 0.0 {
149-
Value::Number((value as i64).into())
150-
} else {
151-
match serde_json::Number::from_f64(value) {
152-
Some(n) => Value::Number(n),
153-
None => Value::Number((value as i64).into()),
170+
values.insert(json_key(&prop.key), &*prop.value);
154171
}
172+
173+
let mut map = serializer.serialize_map(Some(values.len()))?;
174+
for (key, value) in values {
175+
map.serialize_entry(&key, &JsonLiteral(value))?;
176+
}
177+
map.end()
178+
}
179+
Expr::Array(arr) => {
180+
let mut seq = serializer.serialize_seq(Some(arr.elems.len()))?;
181+
for value in &arr.elems {
182+
let value = value.as_ref().unwrap();
183+
seq.serialize_element(&JsonLiteral(value.expr.as_ref()))?;
184+
}
185+
seq.end()
186+
}
187+
Expr::Lit(Lit::Str(Str { value, .. })) => {
188+
wtf8_to_json_string(value).serialize(serializer)
189+
}
190+
Expr::Lit(Lit::Num(Number { value, .. })) if value.is_infinite() => {
191+
let value = if value.is_sign_positive() {
192+
"2e308"
193+
} else {
194+
"-2e308"
195+
};
196+
RawValue::from_string(value.into())
197+
.unwrap()
198+
.serialize(serializer)
199+
}
200+
Expr::Lit(Lit::Num(Number { value, .. })) => json_number(*value).serialize(serializer),
201+
Expr::Lit(Lit::Null(..)) => serializer.serialize_none(),
202+
Expr::Lit(Lit::Bool(v)) => serializer.serialize_bool(v.value),
203+
Expr::Tpl(Tpl { quasis, .. }) => {
204+
let value = match quasis.first() {
205+
Some(TplElement {
206+
cooked: Some(value),
207+
..
208+
}) => wtf8_to_json_string(value),
209+
_ => String::new(),
210+
};
211+
value.serialize(serializer)
155212
}
213+
_ => unreachable!("jsonify: Expr {:?} cannot be converted to json", self.0),
156214
}
157-
Expr::Lit(Lit::Null(..)) => Value::Null,
158-
Expr::Lit(Lit::Bool(v)) => Value::Bool(v.value),
159-
Expr::Tpl(Tpl { quasis, .. }) => Value::String(match quasis.first() {
160-
Some(TplElement {
161-
cooked: Some(value),
162-
..
163-
}) => wtf8_to_json_string(value),
164-
_ => String::new(),
165-
}),
166-
_ => unreachable!("jsonify: Expr {:?} cannot be converted to json", e),
167215
}
168216
}
169217

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const values = {
2+
smallInteger: 42,
3+
largeInteger: 1e21,
4+
i64Boundary: 9223372036854775808,
5+
negativeZero: -0,
6+
positiveInfinity: 2e308,
7+
negativeInfinity: -2e308,
8+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const values = JSON.parse('{"i64Boundary":9.223372036854776e18,"largeInteger":1e21,"negativeInfinity":-2e308,"negativeZero":-0.0,"positiveInfinity":2e308,"smallInteger":42}');

crates/swc_ecma_utils/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1182,7 +1182,9 @@ impl Visit for LiteralVisitor {
11821182
}
11831183

11841184
fn visit_number(&mut self, node: &Number) {
1185-
if !self.allow_non_json_value && !node.value.is_finite() {
1185+
// JSON number syntax can express infinities as overflowed values such
1186+
// as `2e308`, but it has no representation that parses to `NaN`.
1187+
if !self.allow_non_json_value && node.value.is_nan() {
11861188
self.is_lit = false;
11871189
}
11881190
}

0 commit comments

Comments
 (0)