diff --git a/.changeset/compiler-js-number-to-string.md b/.changeset/compiler-js-number-to-string.md
new file mode 100644
index 000000000..7ca7045f2
--- /dev/null
+++ b/.changeset/compiler-js-number-to-string.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/compiler": patch
+---
+
+Numbers inlined into templates now print the way JS `String(number)` does, as Babel's output does. Before, any whole number of 2^63 or more saturated to `9223372036854775807`, and very large or very small values never switched to exponent form. Now `{1e21}` prints `1e+21`, `{1e19}` prints `10000000000000000000` and `{1e-7}` prints `1e-7`. A child hole now also folds `-`, `*` and `/` the way an attribute does, so `
{1 / 0}
` puts `Infinity` in the template instead of inserting `1 / 0` at runtime (#4322).
diff --git a/packages/compiler/__tests__/parity-probes.test.js b/packages/compiler/__tests__/parity-probes.test.js
index 08e149357..2be2dd27a 100644
--- a/packages/compiler/__tests__/parity-probes.test.js
+++ b/packages/compiler/__tests__/parity-probes.test.js
@@ -473,6 +473,17 @@ const a = {value() ?? "fallback"}
;
`,
"exotic literal holes": `
const a = {0}{-0}{1n}{true}{undefined}{null}
;
+`,
+ // #4322: numbers print as JS `String(number)` does (exponent form at 1e21
+ // and above and below 1e-6, no i64 saturation), and a child hole folds
+ // the same arithmetic an attribute does. `-1e21` and `1 / 0` sit in
+ // child position: as attributes, Babel keeps negative and non-finite
+ // values dynamic in SSR and universal until #4321 lands.
+ "number attribute spellings": `
+const a = ;
+`,
+ "number child spellings and folding": `
+const a = {1e21}{1e19}{1e-7}{1.5e-7}{123456789012345680000}{-1e21}{1 / 0}{-1 / 0}{2 * 3}{5 - 1}
;
`,
"tagged template in hole": `
const a = {tag\`body\`}
;
diff --git a/packages/compiler/src/shared/utils.rs b/packages/compiler/src/shared/utils.rs
index 4f07bd283..bcfab01a8 100644
--- a/packages/compiler/src/shared/utils.rs
+++ b/packages/compiler/src/shared/utils.rs
@@ -226,6 +226,19 @@ fn static_binary_expression(
right.into_template_value()
))),
},
+ // The arithmetic the attribute evaluator (`evaluate_confident`) folds
+ // too, so `{1 / 0}` is static in a child just as it is in an
+ // attribute (Babel's `path.evaluate()` folds both).
+ BinaryOperator::Subtraction | BinaryOperator::Multiplication | BinaryOperator::Division => {
+ let (StaticValue::Number(left), StaticValue::Number(right)) = (left, right) else {
+ return None;
+ };
+ Some(StaticValue::Number(match operator {
+ BinaryOperator::Subtraction => left - right,
+ BinaryOperator::Multiplication => left * right,
+ _ => left / right,
+ }))
+ }
_ => None,
}
}
@@ -371,19 +384,52 @@ fn can_omit_attribute_quotes(value: &str) -> bool {
})
}
+/// Port of JS `Number::toString` (ECMA-262 §6.1.6.1.20), i.e. what
+/// `String(number)` gives: the shortest digits that round-trip (Rust's
+/// float formatting finds the same ones), laid out with JS's rules —
+/// plain decimal when the decimal exponent is in `-7 < e < 21`, otherwise
+/// exponent form with an explicit sign (`1e+21`, `1.5e-7`).
pub(crate) fn format_number(value: f64) -> String {
- // JS `String(number)` spellings for the non-finite values (Rust would
- // print `NaN`/`inf`).
if value.is_nan() {
return "NaN".to_string();
}
+ if value == 0.0 {
+ // Covers -0, which JS prints as "0".
+ return "0".to_string();
+ }
+ if value < 0.0 {
+ return format!("-{}", format_number(-value));
+ }
if value.is_infinite() {
- return if value > 0.0 { "Infinity" } else { "-Infinity" }.to_string();
+ return "Infinity".to_string();
}
- if value.fract() == 0.0 {
- format!("{}", value as i64)
+
+ // `{:e}` gives `d[.ddd]e[-]x` with the shortest round-trip digits.
+ let scientific = format!("{value:e}");
+ let (mantissa, exponent) = scientific
+ .split_once('e')
+ .expect("LowerExp output always has an exponent");
+ let exponent: i32 = exponent.parse().expect("LowerExp exponent is an integer");
+ let digits: String = mantissa.chars().filter(|char| *char != '.').collect();
+ let k = digits.len() as i32;
+ // JS's `n`: the value is `0. * 10^n`.
+ let n = exponent + 1;
+
+ if k <= n && n <= 21 {
+ format!("{digits}{}", "0".repeat((n - k) as usize))
+ } else if 0 < n && n <= 21 {
+ let (integer, fraction) = digits.split_at(n as usize);
+ format!("{integer}.{fraction}")
+ } else if -6 < n && n <= 0 {
+ format!("0.{}{digits}", "0".repeat((-n) as usize))
} else {
- value.to_string()
+ let sign = if exponent < 0 { '-' } else { '+' };
+ let (first, rest) = digits.split_at(1);
+ if rest.is_empty() {
+ format!("{first}e{sign}{}", exponent.abs())
+ } else {
+ format!("{first}.{rest}e{sign}{}", exponent.abs())
+ }
}
}
@@ -674,3 +720,44 @@ pub(crate) fn jsx_child_is_function_hole(child: &JSXChild<'_>) -> bool {
_ => false,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::format_number;
+
+ /// Each pair is `String(value)` as Node prints it.
+ #[test]
+ fn format_number_matches_js_number_to_string() {
+ let cases: &[(f64, &str)] = &[
+ (0.0, "0"),
+ (-0.0, "0"),
+ (1.0, "1"),
+ (-1.0, "-1"),
+ (1.5, "1.5"),
+ (0.1, "0.1"),
+ (0.1 + 0.2, "0.30000000000000004"),
+ (123.456, "123.456"),
+ (1e-6, "0.000001"),
+ (1.5e-6, "0.0000015"),
+ (1e-7, "1e-7"),
+ (1.5e-7, "1.5e-7"),
+ (-1e-7, "-1e-7"),
+ (5e-324, "5e-324"),
+ (9007199254740993.0, "9007199254740992"),
+ (2f64.powi(63), "9223372036854776000"),
+ (1e19, "10000000000000000000"),
+ (1e20, "100000000000000000000"),
+ (123456789012345680000.0, "123456789012345680000"),
+ (1e21, "1e+21"),
+ (-1e21, "-1e+21"),
+ (1.5e21, "1.5e+21"),
+ (1.7976931348623157e308, "1.7976931348623157e+308"),
+ (f64::NAN, "NaN"),
+ (f64::INFINITY, "Infinity"),
+ (f64::NEG_INFINITY, "-Infinity"),
+ ];
+ for (value, expected) in cases {
+ assert_eq!(format_number(*value), *expected, "String({value:e})");
+ }
+ }
+}