Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions changelog.d/9728-dynamic-number-tostring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
**A dynamically dispatched `x["toString"]()` on a number now produces
`NumberToString`, not Rust's `f64` Display** (#9713). It printed `inf` for
`Infinity` and the full decimal expansion past the exponential thresholds, so
the same value stringified four static ways and once dynamically disagreed
inside one program:

```ts
const a = 2.2e-308;
a.toString(); // 2.2e-308 (all four static forms)
((x: any, m: string) => x[m]())(a, "toString"); // 0.000…00022 — ~308 digits
```

Three arms of the native-method tower — the plain-number and boxed-`Number`
`toString` in `dispatch_common`, and the boxed-`Number`
`toString`/`toLocaleString` in `dispatch_primitive` — formatted with

```rust
if n.fract() == 0.0 && n.abs() < INT_EXACT_FASTPATH_LIMIT { (n as i64).to_string() } else { n.to_string() }
```

`f64::to_string()` is Rust's shortest-round-trip Display, which never switches
to scientific notation and renders the infinities as `inf`. It is the exact
mistake `js_format_f64`'s doc comment already warns about — #3987 replaced the
same `format!("{}", n)` in the string-concat fast paths and these three arms
were not part of that sweep. They now call `js_number_to_string`, which carries
the spec's `|n| >= 1e21 || |n| < 1e-6` switch, the `Infinity` / `NaN` / `-0`
spellings, and its own (safer) integer fast path — `js_format_f64` cuts over to
the shortest-round-trip formatter at 1e15 rather than 2^53, so it also avoids
the `2**58` → `…744` vs `…740` divergence the local fast path could reach.

Measured against node 26.5.1, previously wrong and now correct: `1e21`,
`1e-7`, `-2.5e-9`, `2.2e-308`, `Number.MAX_VALUE`, `Number.MIN_VALUE`,
`Number.EPSILON`, `±Infinity`, and every one of those again through
`new Number(x).toString()`.

One neighbouring defect in the same arms rides along: a boxed receiver dropped
an explicit radix entirely, so `new Number(255).toString(16)` answered `"255"`
instead of `"ff"`. Both boxed arms now route an explicit radix through
`js_jsvalue_to_string_radix` the way the unboxed arm already did (which also
means an out-of-range radix throws `RangeError` there, as the spec requires).
`toLocaleString` keeps ignoring its argument — that one is a locale, not a
radix.

`test-files/test_gap_9713_dynamic_number_tostring.ts` pins 18 values across the
thresholds in all seven renderings plus the radix and `toFixed` /
`toPrecision` / `toExponential` forms. Unpatched it differs from node on 12
lines; patched it is byte-identical.

Not fixed here, and filed separately: `toString(radix)` above 2^53 for a
non-power-of-two radix still emits exact digits rather than V8's shortest
round-trip (#9725) — that one reproduces from a plain static call and is a
different formatter.
Original file line number Diff line number Diff line change
Expand Up @@ -802,15 +802,28 @@ pub(super) unsafe fn dispatch_common(
} else {
payload
};
let s = if n.fract() == 0.0
&& n.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT
{
(n as i64).to_string()
} else {
n.to_string()
};
let str_ptr =
crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32);
// #9713: `NumberToString`, not Rust's `{}`. A bare
// `f64::to_string()` prints `inf` for Infinity and the
// full decimal expansion past the exponential
// thresholds (`1e21` → `1000000000000000000000`,
// `Number.EPSILON` → `0.000…0002220446049250313`).
// `js_number_to_string` carries the spec's
// `|n| >= 1e21 || |n| < 1e-6` switch and its own
// integer fast path, so the local one is redundant too.
//
// A boxed receiver takes a radix like an unboxed one
// (`new Number(255).toString(16)` is "ff"); this arm
// dropped the argument entirely and answered "255".
// `js_jsvalue_to_string_radix` already accepts a boxed
// Number receiver, so hand it the box, not the payload.
let radix_arg = refreshed_args().first().copied();
if let Some(r) = radix_arg {
if !JSValue::from_bits(r.to_bits()).is_undefined() {
let str_ptr = crate::value::js_jsvalue_to_string_radix(object, r);
return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits()));
}
}
let str_ptr = crate::string::js_number_to_string(n);
return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits()));
}
Some("Boolean") => {
Expand Down Expand Up @@ -861,12 +874,10 @@ pub(super) unsafe fn dispatch_common(
crate::value::js_jsvalue_to_string_radix(object, radix_arg.unwrap());
return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits()));
}
let s = if n.fract() == 0.0 && n.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT {
(n as i64).to_string()
} else {
n.to_string()
};
let str_ptr = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32);
// #9713: same as the boxed-Number arm above — `NumberToString`,
// not Rust's `f64` Display. This is the arm a dynamic
// `x["toString"]()` on a plain number reaches.
let str_ptr = crate::string::js_number_to_string(n);
return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits()));
} else if jsval.is_bool() {
let s = if jsval.as_bool() { "true" } else { "false" };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,15 +222,34 @@ pub(super) unsafe fn dispatch_primitive(
} else {
payload
};
let s = if n.fract() == 0.0
&& n.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT
{
(n as i64).to_string()
// #9713: `NumberToString`, not Rust's `{}`. A bare
// `f64::to_string()` prints `inf` for Infinity and the
// full decimal expansion past the exponential
// thresholds (`1e21` → `1000000000000000000000`,
// `Number.EPSILON` → `0.000…0002220446049250313`).
// `js_number_to_string` carries the spec's
// `|n| >= 1e21 || |n| < 1e-6` switch and its own
// integer fast path, so the local one is redundant too.
//
// A boxed receiver takes a radix like an unboxed one
// (`new Number(255).toString(16)` is "ff"); this arm
// dropped the argument entirely and answered "255".
// `js_jsvalue_to_string_radix` already accepts a boxed
// Number receiver, so hand it the box, not the payload.
// `toLocaleString`'s argument is a locale, not a
// radix, so only `toString` consumes it here.
let radix_arg = if method_name == "toString" {
refreshed_args().first().copied()
} else {
n.to_string()
None
};
let str_ptr =
crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32);
if let Some(r) = radix_arg {
if !JSValue::from_bits(r.to_bits()).is_undefined() {
let str_ptr = crate::value::js_jsvalue_to_string_radix(object, r);
return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits()));
}
}
let str_ptr = crate::string::js_number_to_string(n);
return Some(f64::from_bits(JSValue::string_ptr(str_ptr).bits()));
}
Some("Boolean") => {
Expand Down
68 changes: 68 additions & 0 deletions test-files/test_gap_9713_dynamic_number_tostring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// #9713: a dynamically dispatched `x["toString"]()` on a number reached the
// native-method tower's own formatter — a bare Rust `f64::to_string()` — instead
// of ECMA-262 NumberToString. That prints `inf` for Infinity and the full
// decimal expansion past the exponential thresholds, so the same value
// stringified four static ways and once dynamically disagreed inside one
// program. Boxed `new Number(x)` receivers took the same wrong arm.
//
// Every row prints all the renderings so a future divergence shows which path
// moved, not just that something changed. `toLocaleString` is deliberately
// absent: node applies locale grouping there (`1,000,000,000,000,000,000,000`,
// `\u221e`) and perry does not, which is a separate gap this fixture should not
// be entangled with.

function dynCall(x: any, m: string): any { return x[m](); }
function dynCall1(x: any, m: string, a: any): any { return x[m](a); }

const values: [string, number][] = [
["1e21", 1e21],
["1e20", 1e20],
["1e-6", 1e-6],
["1e-7", 1e-7],
["-2.5e-9", -2.5e-9],
["2.2e-308", 2.2e-308],
["1e-310", 1e-310],
["MAX_VALUE", Number.MAX_VALUE],
["MIN_VALUE", Number.MIN_VALUE],
["EPSILON", Number.EPSILON],
["Infinity", Infinity],
["-Infinity", -Infinity],
["NaN", NaN],
["-0", -0],
["0.1", 0.1],
["255", 255],
["2**53", 9007199254740992],
["2**58", 288230376151711744],
];

for (const [label, n] of values) {
const parts = [
"static=" + n.toString(),
"String=" + String(n),
"tpl=" + `${n}`,
"concat=" + (n + ""),
"dyn=" + dynCall(n, "toString"),
"boxed=" + dynCall(new Number(n), "toString"),
"boxedValueOf=" + String(dynCall(new Number(n), "valueOf")),
];
console.log(label + " :: " + parts.join(" | "));
}

// An explicit radix must still reach the radix formatter, and an explicit
// `undefined` radix must behave like no argument at all.
console.log("radix16=" + dynCall1(255, "toString", 16));
console.log("radix2=" + dynCall1(5, "toString", 2));
// Radix values stay at or below 2^53: above it perry's non-power-of-two radix
// formatter emits exact digits where V8 emits the shortest round-trip form
// (`(1e21).toString(36)` → `5v1j4f4ds7c4ks` vs `5v1j4f4ds7c000`), statically as
// well as dynamically. That is a separate defect and not what this pins.
console.log("radix36=" + dynCall1(9007199254740992, "toString", 36));
console.log("radix7=" + dynCall1(255, "toString", 7));
console.log("radixUndef=" + dynCall1(1e21, "toString", undefined));
console.log("boxedRadix16=" + dynCall1(new Number(255), "toString", 16));

// Sibling numeric methods on the same dynamic route, so a shared regression in
// the tower's number handling is visible here too.
console.log("toFixed=" + dynCall1(3.14159, "toFixed", 2));
console.log("toPrecision=" + dynCall1(1234.5678, "toPrecision", 6));
console.log("toExponential=" + dynCall1(1e21, "toExponential", 3));
Loading