Skip to content

fix(compiler): print numbers like JS String(number) - #4324

Merged
ryansolid merged 3 commits into
nextfrom
claude/project-thread-x1ore2-4322
Oct 27, 2026
Merged

ryansolid merged 3 commits into
nextfrom
claude/project-thread-x1ore2-4322

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Summary

Before: the Rust compiler printed inlined numbers differently from Babel. Any whole number of 2^63 or more was squeezed into an i64, so <div k={1e21} /> gave k=9223372036854775807 where Babel gives k=1e+21, and k={1e19} gave the same wrong value instead of 10000000000000000000. Fractions never switched to exponent form, so k={1e-7} gave 0.0000001 instead of 1e-7. Also, a child {1 / 0} stayed a runtime _$insert(_el$, 1 / 0, …), even though the same expression as an attribute folds to Infinity, and Babel folds both.

After: numbers print exactly as JS String(number) prints them, in attributes and in children, in every mode. A child hole folds -, * and / on numbers the same way an attribute does, so <div>{1 / 0}</div> puts Infinity in the template, as Babel does.

How

  • format_number (packages/compiler/src/shared/utils.rs) is now a port of JS Number::toString (ECMA-262 §6.1.6.1.20). It takes the shortest round-trip digits from Rust's {:e} and lays them out with JS's rules: plain decimal when the decimal exponent is between −7 and 21, otherwise exponent form with a sign (1e+21, 1.5e-7). It also handles NaN, Infinity and -Infinity, and prints -0 as "0".
  • static_binary_expression, which the child path uses through static_jsx_expression, now folds -, * and / when both operands are numbers. These are the same operators AttrPlanner::evaluate_confident folds for attributes.

Relation to #4320 / #4321

No overlap. Neither PR touches these files. On next, Babel's SSR and universal output keeps negative and non-finite attribute values dynamic until #4321 lands, and that is why the existing "exotic literal holes" probe fails in 6 modes on next, with or without this change. So the new probes put -1e21 and 1 / 0 in child position. With #4321's babel-plugin/src/shared/utils.ts applied locally, the attribute forms (f={-1e21} g={1/0}) also match in every mode, and all 4110 probes pass.

How did you test this change?

  • New Rust unit test format_number_matches_js_number_to_string. It covers 26 values (0, -0, 0.1+0.2, 1e-6, 1e-7, 1.5e-7, 5e-324, 2^53+1, 2^63, 1e19, 1e20, 123456789012345680000, 1e21, -1e21, 1.5e21, MAX_VALUE, NaN, ±Infinity), and each expected string was checked against Node.
  • New Babel-vs-Oxc parity probes in packages/compiler/__tests__/parity-probes.test.js. Each runs in all 15 harness modes (dom, ssr, hydratable, universal, dynamic, tsrx, …):
    • "number attribute spellings": a={1e21} b={1e19} c={1e-7} d={1.5e-7} e={123456789012345680000} f={0.1 + 0.2}
    • "number child spellings and folding": {1e21}{1e19}{1e-7}{1.5e-7}{123456789012345680000}{-1e21}{1 / 0}{-1 / 0}{2 * 3}{5 - 1}
    • Both fail on next and pass with this change.
  • cargo test in all three configs from test:rust (default, --no-default-features, --no-default-features --features tsrx): all pass.
  • cargo clippy -- -D warnings: clean. cargo fmt --check: clean.
  • Compiler vitest: 6842 passed, 2 skipped. The only failures are the 6 "exotic literal holes" cases, which already fail on next until fix(babel-plugin): keep evaluated negative/non-finite numbers static #4321 lands. With fix(babel-plugin): keep evaluated negative/non-finite numbers static #4321's Babel fix applied, 4110/4110 probes pass.
  • packages/babel-plugin pnpm test: 411/411. That package is not changed here.
  • I did not run the WASI suite locally because this environment has no wasm32-wasip1-threads target or WASI SDK. The compiler-binaries workflow runs it.

Client bundle impact: 0 B. This is a compiler-only change.

Fixes #4322


Generated by Claude Code

format_number saturated whole numbers at i64::MAX (`{1e21}` gave
`9223372036854775807`) and never used exponent form, where Babel prints
`1e+21` / `1e-7`. Port JS Number::toString: shortest round-trip digits
from Rust's `{:e}`, laid out with the ECMA-262 rules (exponent form when
the decimal exponent is >= 21 or <= -7, `e+N` / `e-N`; NaN, Infinity,
-Infinity; -0 prints "0").

The child static-value path also folds `-`, `*` and `/` on numbers, as
the attribute evaluator does, so `<div>{1 / 0}</div>` inlines
`Infinity` instead of inserting `1 / 0` at runtime.

Fixes #4322

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4af5f10

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@solidjs/compiler Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch
@solidjs/web Patch
test-integration Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.42 KB 0 B 0 B −119 B 7.44 KB ✅
signals: + createStore 14.87 KB 0 B 0 B −1463 B 15.04 KB ✅
signals: + isPending/latest 10.38 KB 0 B 0 B −8 B 10.38 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B −123 B 8.05 KB ✅
app: render + one signal (the simple-app floor) 9.78 KB 0 B 0 B −107 B 9.75 KB ⚠️ over by 29 B, 127 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.61 KB 0 B 0 B −180 B 18.49 KB ⚠️ over by 122 B, 200 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.94 KB 0 B 0 B 0 B 30.96 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.39 KB 0 B 0 B −186 B 13.40 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.10 KB 0 B 0 B −189 B 15.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.69 KB 0 B 0 B −190 B 29.70 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.96 KB 0 B 0 B −106 B 9.93 KB ⚠️ over by 26 B, 126 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.14 KB 0 B 0 B −736 B 26.27 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.24 KB 0 B 0 B −663 B 32.25 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.76 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.45 KB 0 B 0 B −177 B 33.37 KB ⚠️ over by 78 B, 197 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 8.92 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.92 KB 0 B 0 B +20 B 37.75 KB ⚠️ over by 171 B, 0 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 8.93 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.53 KB 0 B 0 B −160 B 35.43 KB ⚠️ over by 97 B, 180 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 8.94 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.77 KB 0 B 0 B +14 B 41.56 KB ⚠️ over by 205 B, 6 B minified headroom eager (counted): web.js 22.32 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 8.94 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.95 KB 0 B 0 B −179 B 40.81 KB ⚠️ over by 139 B, 199 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.03 KB, serverForms.js 3.58 KB, trace.js 8.97 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.32 KB 0 B 0 B +11 B 47.16 KB ⚠️ over by 161 B, 9 B minified headroom eager (counted): client.js 27.42 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.32 KB, trace.js 8.95 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B −69 B 1.34 KB ✅
server: renderToString (the server-render floor) 22.79 KB 0 B 0 B 0 B 22.80 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • app: render + one signal (the simple-app floor): over brotli cap by 29 B; minified 26,922 B vs 27,029 B recorded with the cap (−107 B) — 127 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 122 B; minified 53,440 B vs 53,620 B recorded with the cap (−180 B) — 200 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 26 B; minified 27,454 B vs 27,560 B recorded with the cap (−106 B) — 126 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 78 B; minified 101,382 B vs 101,559 B recorded with the cap (−177 B) — 197 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 171 B; minified 115,237 B vs 115,217 B recorded with the cap (+20 B) — 0 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 97 B; minified 107,758 B vs 107,918 B recorded with the cap (−160 B) — 180 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 205 B; minified 123,230 B vs 123,216 B recorded with the cap (+14 B) — 6 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 139 B; minified 125,208 B vs 125,387 B recorded with the cap (−179 B) — 199 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 161 B; minified 140,491 B vs 140,480 B recorded with the cap (+11 B) — 9 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 27, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40538821516

Coverage remained the same at 78.727%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1357
Covered Lines: 1127
Line Coverage: 83.05%
Relevant Branches: 1078
Covered Branches: 790
Branch Coverage: 73.28%
Branches in Coverage %: Yes
Coverage Strength: 28.46 hits per line

💛 - Coveralls

Copy link
Copy Markdown
Member Author

The job check is red on e94dec9. One signals test failed: heap-mark-incremental.test.ts > mounting rows with a per-row user effect is linear in N (#3350) hit vitest's 5 s timeout in the coverage-instrumented run. The other 5627 tests passed. This PR doesn't touch signals: its own change is in the Rust compiler's number formatting and the parity probes, and the rest is a merge of next. The test is a wall-clock tripwire. No fix exists for it yet, so I'm re-running the failed job once.

The earlier red build * compiler-binaries checks on a68efe1 were the "exotic literal holes" parity probes, which also fail on next without #4321. #4321 has since merged, and this head brings it in. The parity probes pass locally on e94dec9 (4110/4110).


Generated by Claude Code

@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 4 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 196 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing claude/project-thread-x1ore2-4322 (4af5f10) with next (2c9fdd9)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@ryansolid
ryansolid merged commit b989693 into next Oct 27, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants