From e83a3ace5b963d734c396232797ec3d34b81b171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 11:01:02 +0200 Subject: [PATCH] size: stop BigInt.prototype.toLocaleString pinning the Intl number formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-why_live` on a hello-world binary shows a single retainer chain into the ECMA-402 number-formatting machinery: intl::number_format_options::configure_number_format <- object::primitive_proto_thunks::bigint_proto_to_locale_string_thunk <- object::global_this::populate::populate_global_this_builtins `primitive_proto_thunks.rs` carried no cfg at all, so every binary linked `crate::intl` whether or not the program could reach it. The compiler already detects this surface — `feature_detect.rs` turns `intl-namespace` on for any source containing the substring `toLocale`, matched against the HIR debug dump, so string literals count too and `x["toLocaleString"]()` / `x[m]()` with `m = "toLocaleString"` are both covered. Only the runtime half of the gate was missing. Split the thunk on `intl-namespace`. With the feature on the behavior is byte-for-byte what it was. With it off the program provably cannot name the method and cannot have supplied `locales`/`options` either, so render plain decimal digits — ECMA-262 leaves the result implementation-defined when ECMA-402 is absent. hello world: 4,874,072 -> 4,791,416 bytes (-82,656). Verified `cargo check -p perry-runtime` on both cfg paths (default, and --no-default-features --features full). The new gap test covers the ECMA-402 side and matches `node --experimental-strip-types` byte for byte. Two pre-existing gaps surfaced while testing and are NOT touched here; both reproduce identically on the unmodified parent: - `bigIntValue.toLocaleString()` with no arguments skips default grouping (prints 12345678901234567890, Node prints 12,345,678,901,234,567,890) - `fr-FR` groups with `,` where Node uses U+202F --- .../7427-bigint-tolocalestring-intl-gate.md | 1 + .../src/object/primitive_proto_thunks.rs | 28 +++++++++-- ...est_gap_bigint_tolocalestring_intl_gate.ts | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 changelog.d/7427-bigint-tolocalestring-intl-gate.md create mode 100644 test-files/test_gap_bigint_tolocalestring_intl_gate.ts diff --git a/changelog.d/7427-bigint-tolocalestring-intl-gate.md b/changelog.d/7427-bigint-tolocalestring-intl-gate.md new file mode 100644 index 0000000000..fc5788d3e8 --- /dev/null +++ b/changelog.d/7427-bigint-tolocalestring-intl-gate.md @@ -0,0 +1 @@ +size: `BigInt.prototype.toLocaleString` no longer pins the ECMA-402 number formatter into programs that never mention `toLocale*` — hello world drops 82,656 bytes (4,874,072 → 4,791,416). diff --git a/crates/perry-runtime/src/object/primitive_proto_thunks.rs b/crates/perry-runtime/src/object/primitive_proto_thunks.rs index 6a116d3949..a39746e24b 100644 --- a/crates/perry-runtime/src/object/primitive_proto_thunks.rs +++ b/crates/perry-runtime/src/object/primitive_proto_thunks.rs @@ -380,11 +380,29 @@ pub(super) extern "C" fn bigint_proto_to_locale_string_thunk( let value = bigint_receiver_or_throw("toLocaleString"); let args = super::global_this::global_this_rest_array_values(rest); let undef = f64::from_bits(crate::value::TAG_UNDEFINED); - let locales = args.first().copied().unwrap_or(undef); - let options = args.get(1).copied().unwrap_or(undef); - string_value(crate::intl::bigint_to_locale_string( - value, locales, options, - )) + let _locales = args.first().copied().unwrap_or(undef); + let _options = args.get(1).copied().unwrap_or(undef); + #[cfg(feature = "intl-namespace")] + { + string_value(crate::intl::bigint_to_locale_string( + value, _locales, _options, + )) + } + // Binary size: this thunk is the ONLY retainer of the ECMA-402 + // number-formatting machinery in a program that never mentions + // `toLocale*` (measured with `-why_live`: everything reachable under + // `intl::number_format*` hangs off this one call). The compiler turns + // `intl-namespace` on for any source containing the substring `toLocale` + // — including inside a string literal, so `x["toLocaleString"]()` and + // `x[m]()` with `m = "toLocaleString"` are both covered — so reaching + // this arm means the program cannot name the method, and cannot have + // supplied `locales`/`options` either. ECMA-262 leaves the result + // implementation-defined when ECMA-402 is absent, so render plain + // decimal digits. + #[cfg(not(feature = "intl-namespace"))] + { + string_value(crate::value::js_jsvalue_to_string_radix(value, 10.0)) + } } /// `String.prototype.toString()` — brand-checked: returns the underlying string diff --git a/test-files/test_gap_bigint_tolocalestring_intl_gate.ts b/test-files/test_gap_bigint_tolocalestring_intl_gate.ts new file mode 100644 index 0000000000..91582370eb --- /dev/null +++ b/test-files/test_gap_bigint_tolocalestring_intl_gate.ts @@ -0,0 +1,47 @@ +// `BigInt.prototype.toLocaleString` is the sole retainer of the ECMA-402 +// number-formatting machinery for programs that never mention `toLocale*`, +// so the thunk carries an `intl-namespace` cfg split. This file mentions +// `toLocaleString`, which turns the feature on — it therefore covers the +// ECMA-402 side of that split: every case below must keep formatting through +// the real Intl number formatter. + +const big = 9876543210n; +const small = 42n; +const negative = -1234567n; + +console.log("en-US:", big.toLocaleString("en-US")); +console.log("de-DE:", big.toLocaleString("de-DE")); +// `fr-FR` is deliberately absent: Perry groups it with `,` where Node uses +// U+202F, a pre-existing separator gap unrelated to this thunk. + +// Under three digits there is no grouping to apply in any locale. +console.log("small en-US:", small.toLocaleString("en-US")); +console.log("small de-DE:", small.toLocaleString("de-DE")); + +// The sign must survive grouping. +console.log("negative en-US:", negative.toLocaleString("en-US")); + +// Options reach the formatter, not just the locale tag. +console.log( + "currency:", + big.toLocaleString("en-US", { style: "currency", currency: "USD" }), +); +console.log( + "grouping off:", + big.toLocaleString("en-US", { useGrouping: false }), +); + +// Reflective dispatch hits the same thunk as a direct call. +console.log( + "reflective:", + (BigInt.prototype.toLocaleString as any).call(big, "en-US"), +); + +// Brand check: a non-BigInt receiver must throw rather than format. +let threw = false; +try { + (BigInt.prototype.toLocaleString as any).call({}); +} catch { + threw = true; +} +console.log("brand check throws:", threw);