Skip to content

perf(signals,web): get the hello world back under 10 KB - #4104

Merged
ryansolid merged 2 commits into
nextfrom
claude/project-thread-kyj9yk
Oct 15, 2026
Merged

ryansolid merged 2 commits into
nextfrom
claude/project-thread-kyj9yk

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Requested by Ryan · project thread

Summary

Before: the JSX hello world (app: compiled floor) shipped 10,352 B brotli and had been over 10 KB since we added it on Oct 5. The hand-written floor (render + one signal) crossed 10 KB today with #4085 (9,994 → 10,156 B) and sat 1 B over its cap on next.

After: the JSX hello world ships 9,752 B (−600 B) and the hand-written floor ships 9,549 B (−622 B). The signals core floor goes from 7,657 B to 7,238 B. Behavior doesn't change, and there's no new public API.

The bundler's minifier can remove a module-level let x = null that nothing assigns, along with every check on it. It can't do that for GlobalQueue._x statics, which is how the optional engines plugged in until now.

How:

  • signals: the 21 GlobalQueue hooks the floor reads become module variables in scheduler.ts. Each engine (lanes, verdicts, boundaries, store, affects, external source) sets its own through an install…Hooks() function when it loads. Code that only lanes or verdicts can reach is gated on that engine being installed: the lane seat and staging in recompute, lane and pending-flight reads in read, the lane write in setSignal, propagateStatus's lane step, stagedReaders, stagedScreen/frameRead, and the settle step's zombie re-run loop. Without those engines, the gated state can't occur: only lanes.ts (and store/optimistic.ts through it) sets lane state, and only verdict.ts sets the verdict flag. The dead untrack fast path is removed. It compared _externalUntrack === null against a static that was always undefined, so it never ran.
  • web: client-only render no longer keeps hydration text adoption in normalize, which is now gated on the hydration runtime that only hydrate() installs. eventHandler drops guards that no caller needs. reconcileArrays reuses isLive for its ownership checks. selectInsert moved so the minifier can remove it.
  • Caps are ratcheted down (lower only), so the savings don't erode silently.

Three pages that load every engine move slightly the other way, because brotli compresses the smaller web code a little worse there:

  • page: live server components: +92 B brotli, −1 B minified. It's now 43 B over its cap, but within its recorded minified (+4 B), so the gate warns rather than fails.
  • live + router and compiled live: already over their caps on next, and they move +43 B and +13 B.

How did you test this change?

  • node scripts/size/size.mjs before and after; all scenario numbers above come from it, and the ratchet table is in scenarios.js.
  • signals pnpm test: 5,312 passed, the same as next.
  • solid pnpm test: 868/868. cross-package-fields needs packages/universal/dist built, both before and after this change.
  • web client config: 1,403/1,403. Hydrate config: 511 passed, with 16 expected failures. Server config: one failure, server-functions-adapter-request.spec.tsx, which also fails on next.
  • Prettier is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F58uQgS9taZV2yd3SW4srW


Generated by Claude Code

Optional engines install their scheduler hooks as module-level variables
instead of GlobalQueue statics, so the minifier folds their paths away in
bundles that never load them. Lane- and verdict-only paths in recompute,
read, setSignal and the settle step are gated on those hooks. On the web
side, client-only render drops hydration text adoption, eventHandler drops
guards no caller needs, and reconcileArrays reuses isLive.

compiled floor 10,352 -> 9,752 B brotli; render + one signal 10,171 ->
9,549 B; signals core floor 7,657 -> 7,238 B. Caps ratcheted down.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F58uQgS9taZV2yd3SW4srW
@ryansolid ryansolid self-assigned this Oct 15, 2026
@changeset-bot

changeset-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ad5ede

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

This PR includes changesets to release 14 packages
Name Type
@solidjs/signals Patch
@solidjs/web Patch
test-integration Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
board-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/compiler Patch
solid-js Patch
@solidjs/universal 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

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.24 KB −419 B (−5.5%) −1150 B 0 B 7.25 KB ✅
signals: + createStore 14.77 KB −382 B (−2.5%) −1028 B 0 B 14.79 KB ✅
signals: + isPending/latest 9.94 KB −28 B (−0.3%) +1 B 0 B 9.96 KB ✅
signals: + until/refresh (truth readers, no optimism) 7.85 KB −429 B (−5.2%) −1149 B −1132 B 7.86 KB ✅
app: render + one signal (the simple-app floor) 9.55 KB −622 B (−6.1%) −1767 B 0 B 9.56 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.86 KB −443 B (−2.4%) −1242 B 0 B 17.87 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.91 KB −12 B (−0.0%) −290 B 0 B 29.91 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.86 KB −502 B (−3.8%) −1561 B 0 B 12.87 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.44 KB −512 B (−3.4%) −1567 B 0 B 14.46 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.07 KB −494 B (−1.7%) −1576 B 0 B 29.09 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.75 KB −600 B (−5.8%) −1768 B 0 B 9.77 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.65 KB −449 B (−1.7%) −1259 B 0 B 25.66 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.70 KB −381 B (−1.2%) −1077 B 0 B 31.72 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.87 KB 0 B 0 B 0 B 11.87 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.60 KB −283 B (−0.8%) −1075 B 0 B 34.62 KB ✅ assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.04 KB, wire.js 1.35 KB
page: live server components (base + live/GET + action + isPending/latest) 38.74 KB +92 B (+0.2%) −1 B +4 B 38.70 KB ⚠️ over by 43 B, 16 B minified headroom assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.04 KB, wire.js 1.35 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.35 KB −501 B (−1.4%) −1073 B 0 B 35.37 KB ✅ assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 9.01 KB, wire.js 1.35 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.48 KB +13 B (+0.0%) +23 B 0 B 41.21 KB ⚠️ over by 269 B, 20 B minified headroom eager (counted): web.js 21.98 KB; assets.js 0.78 KB, bind.js 2.55 KB, decode.js 6.25 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.99 KB, wire.js 1.35 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.94 KB −354 B (−0.8%) −1067 B 0 B 41.96 KB ✅ assets.js 0.78 KB, bind.js 2.56 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.59 KB, trace.js 9.03 KB, wire.js 1.36 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.12 KB +43 B (+0.1%) +15 B +19 B 48.05 KB ⚠️ over by 68 B, 1 B minified headroom eager (counted): client.js 28.14 KB; assets.js 0.78 KB, bind.js 2.56 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 9.01 KB, wire.js 1.36 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.57 KB 0 B 0 B +8 B 20.58 KB ✅

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

  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 43 B; minified 119,544 B vs 119,540 B recorded with the cap (+4 B) — 16 B of the 20 B minified allowance left; −1 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 269 B; minified 124,010 B vs 124,010 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +23 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 68 B; minified 144,771 B vs 144,752 B recorded with the cap (+19 B) — 1 B of the 20 B minified allowance left; +15 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).

Copy link
Copy Markdown
Member Author

Failing: chat-client-example#test. The failing test is "a failed reply is the boundary's fallback, and Retry asks again", which trips WASTED_RECOMPUTE ("effect re-ran 5 times… 8.3ms of compute").

Why it's not this PR: the same job fails on next @ 3e94215 (Solid CI run 38800023796). That example landed today in #4099. On a fast machine the test passes on this branch: it ran 4/4 locally. wastedRecompute only fires once unchanged re-runs cost at least budgetMs (2 ms) of wall-clock time, so a slow runner trips it. The test file already turns off the other wall-clock findings for exactly this reason (hotTime: false).

No fix exists yet. Here is the proposed patch, outside this PR's scope:

 // examples/chat-client/test/chat.test.tsx
-const attribution = { hotTime: false, fallbackFlashes: false } as const;
+const attribution = { hotTime: false, fallbackFlashes: false, wastedRecompute: false } as const;

expectNoWaste/expectRerunBudget still guard re-run counts without a clock.


Generated by Claude Code

@coveralls

coveralls commented Oct 15, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 38812119112

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Warning

No base build found for commit 3e94215 on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 77.511%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1328
Covered Lines: 1086
Line Coverage: 81.78%
Relevant Branches: 1042
Covered Branches: 751
Branch Coverage: 72.07%
Branches in Coverage %: Yes
Coverage Strength: 27.89 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ 2 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 193 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ build + consume 694.5 µs 740.8 µs -6.25%
⚡ dynamic-tag update 100/1000 rows × 16: Dynamic 37.7 ms 34.9 ms +7.98%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-kyj9yk (9ad5ede) with next (3e94215)

Open in CodSpeed

…like the other wall-clock findings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F58uQgS9taZV2yd3SW4srW
@ryansolid
ryansolid marked this pull request as ready for review October 15, 2026 21:45
@ryansolid
ryansolid merged commit ccd9948 into next Oct 15, 2026
7 of 8 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