diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 0ee9fb0d6..b098d1205 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -12,7 +12,8 @@ name: Size # (absolute limits, no base ref), and PR-only triggering let the # +createStore scenario sit failing at a release commit unnoticed — direct # pushes never measured. The compare job stays PR-only; it needs a PR -# context (base branch, comment target). +# context (base branch, comment target). The floor-cap freeze step in check +# is PR-only for the same reason (it diffs against the base branch). on: push: branches: [next] @@ -46,8 +47,22 @@ jobs: - run: pnpm build - run: npm ci --no-audit --no-fund working-directory: scripts/size + # size-limit gates every scenario against its cap, then attribute.mjs + # prints per-package minified bytes for each so a bump is attributed + # in the same log that reports it. - run: npm run size working-directory: scripts/size + # The three floor caps (scripts/size/floor-caps.json) are frozen: a PR + # may lower them, never raise them, unless its body carries a + # `Size-Exception:` line. Compared against the PR's base branch, so + # PR events only. + - if: github.event_name == 'pull_request' + run: git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" + - if: github.event_name == 'pull_request' + run: npm run check-floor-caps -- "origin/${{ github.base_ref }}" + working-directory: scripts/size + env: + SIZE_EXCEPTION: ${{ github.event.pull_request.body }} # Best-effort base-vs-head delta comment. Requires the base branch to also # carry scripts/size/, so it is informational and never blocks: continue-on-error diff --git a/documentation/plans/size-reduction-audit.md b/documentation/plans/size-reduction-audit.md new file mode 100644 index 000000000..e90e1ccac --- /dev/null +++ b/documentation/plans/size-reduction-audit.md @@ -0,0 +1,324 @@ +# Size reduction — audit and options (2026-09-26) + +Branch `size/audit` off `next` @ `663031d7b`. All numbers are **minified + brotli (q11)** +against the built prod dists, bundled with esbuild the way `scripts/size` does it. Measurement +scripts live outside the repo (`/tmp/size-audit/{attr,fnattr,hist,exp}.mjs`); they are +reproducible from this document. + +Targets stated for this effort: hello world well under 10 KB; a server-component page ≤ 30–40 KB +on the upper end. Today: **12.6 / 47.3 / 46.5–53.5 KB**. + +## 1. Baseline + +| Scenario | min | br | signals | solid-js | web | frames | sf | +| ------------------------------------------------------------------------ | ------: | ---------: | ------: | -------: | ----: | -----: | ----: | +| `createSignal` alone (signals) | 25,499 | **9,217** | 25.5K | | | | | +| signals floor (signal/memo/effect/root/flush) | 27,604 | **9,844** | 27.6K | | | | | +| signals floor + `createStore` | 51,828 | 17,165 | 51.8K | | | | | +| **hello world** (`render` + one signal) | 35,639 | **12,615** | 28.3K | 0.2K | 7.1K | | | +| CSR: Show/For/Loading/Errored/lazy | 44,806 | 15,776 | 36.3K | 1.3K | 7.1K | | | +| hydrating, no stores | 62,144 | 21,220 | 36.8K | 14.9K | 10.3K | | | +| hydrating + every store family | 97,242 | 31,487 | 71.4K | 15.2K | 10.3K | | | +| **base server components** (hydrating + `Dynamic` + frames + sf ref) | 148,562 | **47,256** | 67.7K | 15.8K | 20.3K | 31.8K | 12.7K | +| **live SC**, no client stores (+ `live`/`GET`, action, isPending/latest) | 145,728 | **46,522** | | | 10.4K | 31.8K | 17.2K | +| live SC + client stores + `Dynamic` | 169,462 | **53,537** | 83.7K | 16.1K | 20.3K | 31.8K | 17.2K | + +Solid 1.9.15 on the same shapes: floor **2.55 KB**, floor+store 3.8, render+signal **3.9**, +hydrate+Show/For/Suspense/ErrorBoundary/lazy/Dynamic **9.2**. Solid 2 is 3–4.5× on every row. + +### Where the signals floor is (function-level, share of retained bytes) + +`GlobalQueue` (scheduler class: flush, transitions, parking, stashing) ≈ 16%; `recompute` ≈ 10% +(~650 source lines, one function); `handleAsync` ≈ 8%; `read` ≈ 5%; `notifyStatus`, `Queue`, +`finalizePureQueue`, `disposeChildren`, `setSignal`, `runEffect` 1.5–2.5% each. Module split: +`core.js` 8.9K min, `scheduler.js` 7.9K, `async.js` 4.7K, `owner.js` 1.6K, `heap.js` 1.2K, +`effect`/`graph`/`lanes` ~0.9K each. A bare `createSignal` retains 92% of the floor: memo, effect, +root and flush together add 2 KB min. **The floor is not "features you imported"; it is the +model.** + +## 2. Trajectory (published `@solidjs/signals`, same floor import) + +| version | date | signal only | floor | floor + store | +| ------------- | ---------- | ----------: | --------: | ------------: | +| 0.9.0 | 2026-01-07 | 3,248 | 3,724 | 5,979 | +| **0.10.0** | 2026-02-13 | 4,701 | **5,180** | 7,802 | +| 0.13.13 | 2026-04-14 | 6,369 | 6,838 | 9,462 | +| 2.0.0-beta.10 | 2026-04-30 | 6,692 | 7,143 | 10,030 | +| 2.0.0-rc.0 | 2026-08-12 | 6,685 | 7,224 | 13,086 | +| 2.0.0-rc.5 | 2026-09-01 | 7,435 | 8,019 | 14,648 | +| 2.0.0-rc.9 | 2026-09-18 | 8,950 | 9,570 | 16,746 | +| `next` today | 2026-09-26 | 9,217 | 9,844 | 17,165 | + +Maintainer's framing: the 0.10 era added features; Feb 2026 is the meaningful baseline. From +there the floor is **+90%** and floor+store **+120%**, with no new primitive after beta. rc.0 → +today is +2.6 KB in six weeks. The ratchet ledger in `scripts/size/.size-limit.js` (290 lines of +notes for the floor alone, 55 issue references) attributes the rc-era growth as: + +- ≈ 65%: transaction/hold consistency rules — A15/A17/A18/A28/A29/A30/A31/A34, lanes stage, + born-held, contested effects, reporters, parked/woken transactions, held trims, unflushed masks + (`SPEC-ASYNC-SEMANTICS.md`). +- ≈ 15%: the stage-3 perf batch — explicitly bytes-for-monomorphic-speed. +- ≈ 10%: `loadingValue` window, promise-of-AsyncIterable flattening, iterator teardown. +- ≈ 10%: disposal/ownership fixes, hydration snapshot capture, misc. + +Every bump was individually justified at +10–300 B. The ledger's own rule ("the winning move is +relocation into pay-for-use modules") was applied to optimistic and verdict — 33 of the 42 +`GlobalQueue` hook slots are installed by those two modules and shake out of the floor — but +**the hold model itself is not behind the seam**. The scheduler's comment is the architecture: +"Ambient work IS a transaction." + +## 3. Findings by category + +### F1. The reactive floor — "every flush is a potential transaction" + +**What it costs.** ~9.8 KB br for a signal, before any DOM. Of the three targets, hello world +is _only_ this: `web.js` contributes 7.1K min (~2.5 KB br) and nothing else is there. + +**Why.** A plain sync write may become a held transaction if any derivation downstream goes +async (graph-driven entanglement, A15), so every node carries committed/staged/override slots, +every read selects among them (`read`, `serve`, `readerSeesCommitted`, `enterStagedRead`), every +recompute decides publish-vs-stage and enters/leaves transactions, every effect can be contested, +and every flush can park. The code is gated by flags at runtime (cheap to execute) but not by +imports (every byte ships). `recompute` alone threads: lane posture, derived overrides, born-held +staging, zombie children, pending-source sweeps, reask, loading windows, contested effects, dep +trim deferral, A28 promotion. + +**Constraint (maintainer, 2026-09-26): public API changes are off the table.** That removes +the one lever that would take async out of the floor. "Install the async engine on first +thenable" does not tree-shake anything — code has to be in the bundle to be installable, and +pay-for-use only works off an import edge, which `createMemo(async …)` does not have. Async stays +in the floor. What remains: + +1. _Arrest and relocate (no semantic change)._ Freeze the three floor caps (§A, landed); move + cold arms behind existing hook slots (`heldFromStale`, `reporterBlocksSource`, `wakeParked`, + `resyncUnflushedCompanions`, `captureWriteSnapshot`, born-held's boundary walk). The ledger + records several of these as "relocation measured NO-WIN" because the _call site_ stays, so + expect **−0.5 to −1 KB**, not more. Re-measure the stage-3 perf trade (~0.4 KB bought + monomorphism on the core loop) piece by piece with CodSpeed; keep what still pays. +2. _Behavior leniency (maintainer: "more lenient on behavior, perhaps")._ Explicit transactions + (`action`, lanes, `startTransition`) keep most of the scheduler's transaction apparatus + regardless. The candidates are the **mainline implicit** hold rules: A28 unflushed masks, + A29 born-held, A34 write-as-proposal, contested effects (#3322/#3319), reporters and + parked/woken transactions (#3375/#3426), held trims (A30), the pending-source re-park sweeps + (#3371/#3456). Their ledgered costs sum to ~3.5 KB minified → **−1.5 to −2.5 KB br on every + scenario** (the earlier −3 to −4 KB estimate assumed transactions themselves could go; they + cannot). Each rule fixed a real torn frame in a fuzzer or an issue and would come back as + accepted tearing outside explicit transactions. Deliverable before any code: a table of the + ~15 rules — bytes, motivating issue, and the visibility-oracle cells that flip — for a + per-rule ruling. + +**Ceiling.** With async in the floor, hello world lands at **~9.5–10 KB** after (1) and (2) — +under the 10 KB goal with a thin margin, and only if (2) is ruled in. Without (2) it is ~11.8. + +### F2. Governance — the ratchet is permissive by construction + +Sixty ratchets in ten weeks, each small, each with a paragraph of justification; the harness +records history but does not resist growth. Proposals: + +- Floor caps (signals floor, hello world, hydrating-no-stores) become **hard**: a bump requires an + equal-or-larger relocation out of the floor in the same PR, or a maintainer-signed exception. +- Add the two server-component scenarios from this audit (`sc-base`, `sc-live` — see §1) to the + harness so the target pages are measured, not inferred. +- Every scenario also reports **per-package minified bytes** (esbuild metafile) so a bump is + attributed at PR time, not in a later audit. +- The 2,479-line `.size-limit.js` is itself a smell: move the ledger prose to a + `SIZE-LEDGER.md` and keep the config to scenarios and caps. + +### F3. Store engine shape + +`store/next/store.js` 17.1K min + `reconcile` 3.2K + `projection` 1.6K + `store/store` 1.2K + +`target` 0.2K ≈ **23K min / ~7.3 KB br** for `createStore` alone (the function form pulls +reconcile and projection by design). Function-level: proxy `traps` (7.5K un-mangled), +`notifyWrites`, `drainFolds`, `ensurePB`/`adoptPB`/`materializePB`/`privatizeCommitted`/ +`pendingBackingVisible`/`visibleKeys`/`cloneRaw`/`flattenOverlay` — i.e. **roughly half the +store is the dual committed/pending backing (`.v`/`.pb`) and its fold protocol**, the store's +own implementation of F1's hold model. Solid 1's store (proxy + per-key signals + path setter + +reconcile) is 3.8K min. + +Options: + +1. _Follow F1._ Whatever F1's leniency ruling removes from mainline removes the matching store + arms (`ensurePB`/`adoptPB`/`privatizeCommitted`/`drainFolds` mainline paths). Estimate + **−2 to −3 KB br** for store users, sized after F1. +2. _Split families by import._ Would need the derived form to be its own spelling — a public + API change, ruled out. `createStore` carrying projection and reconcile is accepted as + inherent. +3. _Shape review of `traps`._ 200 lines for get/has/set/deleteProperty/ownKeys/getOwnPropertyDescriptor + with committed/pending/override selection per trap. Worth a pass once (1) decides what the + traps must select among. + +### F4. Packaging leaks (no semantic change; the fastest wins for server components) + +Measured on `sc-base` (47.3 KB br): + +| Change | br after | Δ | +| ------------------------------------------------------------------------------------------- | -------: | -----------: | +| as shipped | 47,256 | | +| frames client stops eagerly installing `materializeContainerTrace` (store engine goes lazy) | 40,163 | **−7.1 KB** | +| + `Dynamic` without the full `spread`/`mergeProps` runtime (see F5) | 35,153 | **−12.1 KB** | + +Same on `sc-live` without client stores: 46.5 → **39.5 KB** with the materializer alone. + +Causes, each an "install everything on enable" pattern: + +- `@solidjs/web/frames` client: `setContainerTraceMaterializer(materializeContainerTrace)` at + module load; the materializer calls solid's hydration-aware `createProjection`, i.e. the whole + store engine, for a container trace most pages never receive. The previous audit found the + document face needs the materializer _synchronously_ during the claim walk when a trace is + present — so the fix is: the server knows whether it serialized a trace; when it did, it + preloads the materializer module (through the same manifest path `lazy()` chunks use) and the + frames client awaits it before that scope's claim; when it didn't, nothing loads. +- `solid-js` `enableHydration()` installs `_hydrateStoreLike` → `hydrateStoreLikeFn`, + `hydrateStoreFromAsyncIterable`, `createShadowDraft`, `applyPatches` (~3.5K min, ~1.2 KB br) + in every hydrating app, store or not. The wrappers that need it (`createStore` etc.) should + import the adapter directly instead of reaching it through a slot the enabler fills. +- `solid-js` and `@solidjs/web` are flat single-file dists; route-level splitting through them + fails (a lazy route's `createStore` colors the engine into the main chunk). `preserveModules` + for both, as signals already does. This does not move the single-entry harness numbers + (brotli layout shifts a little) but fixes real apps' route splits; the previous audit measured + ~9–10 KB br on the room example's `/`. +- `configureServerFunctionsClient` is installed by the frames client (needed — same instance), + fine; but the sf client's `live` loop + ledger/resume path is carried by consumers that never + call `live` (previous audit noted it as a split candidate; ~1 KB br). + +### F5. `dynamic()` retains the element runtime — 4.3 KB br + +(`` is deprecated and not part of this effort; the numbers are for `dynamic()`.) +`hydrating` 21.2 → `hydrating + dynamic()` **25.5 KB**: the string-tag branch (`staticElement`) +references `spread`, which retains the entire attribute runtime — `assignProp`, `style`, +`className`, `classListToObject`, `SVGElements`, `eventHandler`, `setAttribute` (+10K min in +`web.js`) plus the props helpers it reads from `store/utils` (+3.8K). For server components +`dynamic(() => getStory(id()))` is _the_ client surface, and in that use it resolves a component +and never spreads onto an element. + +The API shape stays. Fix (packaging tier, via the server-driven preload seam, §D): the element +runtime is loaded only when the SSR render saw a string-tag `dynamic`; the document preloads it +and the client awaits it before the claim. A CSR bundle whose first string-tag `dynamic` renders +with no `spread` elsewhere takes a chunk load at that render — the one accepted behavior change +in the packaging tier. Estimate **−4.3 KB br** on every SC page. + +### F6. Frames client and transport (~10.3 + 3.7 KB br) + +`frames/client.js` 31.8K min; `FrameImpl` (the class: morph, slots, live holes, ledger, reconnect, +behaviors, grafts) is ~30% of it, then `adoptBoundary`, `createFrameHost`, `slotsFor`, +`reconcileChildren`, `applyFrames`, `chunkToRecords`, `morphAttributes`, `ensureStylesheet`. +`server-functions/client.js` 12.7K min (dispatch, `ChunkReader`, `initializeResponse`, +`deserializeStream`, `createRequest`, `getHeadersAndBody`, `extractBody`, `isJSONSafe`, +`stableString`) — 17.2K with `live`/`GET`. + +This is HTMX-sized on its own, sitting on a 21 KB reactive floor. Options: + +1. _Tier the frames client by wire feature._ Base: html/fragment/hole apply + slots + morph. + Installed: live holes + reconnect + ledger/have-list (`live` pages only), behaviors, grafts, + container traces (F4). The server knows which records a response can carry and can preload + the tier. Estimate **−3 to −4 KB br** for `sc-base`, ~0 for `sc-live`. +2. _The static face_ — a signals-free frames consumer — was considered and **rejected** + (maintainer, 2026-09-26): a real page's baseline carries the router, and the features the + router needs are the base. Recorded for the reasoning; not pursued. +3. _sf transport diet._ `stableString`/`isJSONSafe`/`extractBody`/`getHeadersAndBody` are the + request-encoding side; a GET-only/`live` page never posts rich args. Split rich-arg encoding + from the reader (**~−1 KB br**). + +### F7. `solid-js` hydration layer (15 KB min in every hydrating page) + +`client/hydration.ts` is 3,178 lines; retained in the no-store hydrating app: hydrated +boundary (3.8K un-mangled), store adapters (F4, ~8K), `enableHydration` 2K, `hydrateSignalLike` +1.9K, `resumeBoundaryHydration`, `normalizeIterator`, `rejectTruncatedRefs`, +`readSerializedOrCompute`, `wrapFirstYield`, `adoptedAnswerStream`, `quietAnswer`, `subFetch`, +`watchTruncation`, `markTruncated`, `MockPromise`. Much of this is the async-iterable handoff +protocol (server stream → client resume) and truncation handling. Options: (a) F4's adapter +split; (b) the iterable-handoff path (`normalizeIterator`, `wrapFirstYield`, +`adoptedAnswerStream`, `subFetch`, `watchTruncation`) installs only when the document carries +an unfinished stream — the server knows; (c) with `preserveModules` these become separate +modules and the split is natural. Estimate **−1.5 to −2.5 KB br** on hydrating pages. + +## 4. The plan (agreed 2026-09-26) + +Constraints: no public API changes; async stays in the floor; `createStore` carrying +projection/reconcile is inherent; more leniency on behavior is possible; the router is part of +every real baseline; a signals-free frames client is out. + +### A. Measurement — landed first, alone + +- `page: base server components` and `page: live server components` scenarios in + `scripts/size` (the codec aliased to a stub, as it is a lazy chunk in production). +- `attribute.mjs`: per-package minified bytes for every scenario, run after size-limit. +- The three floor caps frozen in `floor-caps.json`; `check-floor-caps.mjs` fails a PR that + raises one without a `Size-Exception:` line. Lowering is always allowed. +- Win: 0 bytes. This is the mitigation for `next` moving under the effort: growth becomes a + per-PR decision the reviewer sees, not a paragraph in a 2,500-line config. + +### B. Eager installs → pay-for-use (no semantics) + +1. `enableHydration()` store adapters — the wrappers import the adapter directly; the enabler + stops filling the slot. **−1.2 KB** every hydrating page. +2. Frames materializer — loaded only when the server serialized a container trace (§D). + **−7.1 KB** on SC pages without client stores. +3. `dynamic()` element runtime — loaded only when the server rendered a string-tag `dynamic` + (§D). **−4.3 KB** on every SC page. +4. sf client `live` loop + ledger/resume split behind the `live` import. **~−1 KB** on the base + page. + +### C. Module layout (no semantics) + +`solid-js` and `@solidjs/web` built `preserveModules` like signals. Single-entry numbers barely +move; route-level splitting through both packages starts working (prior audit: ~9–10 KB on the +room example's `/`), and it is a **prerequisite for B.2 and B.3** — the materializer and +`staticElement` are welded into flat files today, so a lazy import would lazy-load a facade and +leave the engine eager. + +### D. The server-driven preload seam (one mechanism for B.2, B.3, E) + +The SSR render records "this document needs client module X" (a trace was serialized; a +string-tag `dynamic` rendered; an unfinished stream is being handed off). The document preloads +X through the same manifest path `lazy()` chunks use; the client awaits it before the claim walk +of the scope that needs it. Build once, used three times. + +### E. Frames client, transport and hydration tiering (no semantics) + +- Frames: base tier = html/fragment/hole apply + slots + morph; installed tiers = live holes + + reconnect + have-list ledger, behaviors, grafts. **~−2–3 KB** on the base page. +- sf transport: split rich-arg request encoding from the reader. **~−1 KB**. +- `solid-js` hydration: the async-iterable handoff path installs only when the document carries + an unfinished stream (§D). **~−1.5–2.5 KB** on hydrating pages. + +### F. Core floor without semantic change + +Re-measure the stage-3 perf trade with CodSpeed; relocate cold arms behind existing slots. +**−0.5 to −1 KB**, every scenario. + +### G. Core floor with behavior leniency (needs rulings) + +The ~15 mainline implicit hold rules (F1, "behavior leniency"). Deliverable first: the ruling table (bytes, +motivating issue, oracle cells). Then each accepted removal as its own PR — rule, tests and +oracle cells together. **−1.5 to −2.5 KB**, every scenario. + +### H. Stores (follows G) + +The store's `.v`/`.pb` arms that mirror whatever G removes. **−2 to −3 KB** for store users. + +## 5. Expected landing (brotli, KB) + +| | today | after B+C+D | + E | + F | + G+H | +| ------------------------- | ----: | ----------: | ---: | ----: | ----------: | +| hello world | 12.7 | 12.7 | 12.7 | ~11.8 | **~9.5–10** | +| page: base SC | 46.8 | ~34.5 | ~30 | ~29 | **~27** | +| page: live SC (no stores) | 51.1 | ~39.5 | ~36 | ~35 | **~33** | +| live SC + client stores | ~54 | ~48 | ~45 | ~44 | **~39** | + +Goals: hello world 10, SC pages 30–40. Both SC pages reach the band on packaging alone; the +page with client stores sits at the top of it; hello world reaches 10 only with G. + +## 6. Order and risk + +1. A — one PR, no runtime change, lands before any cutting. +2. B.1 and B.4 (isolated modules, low conflict). +3. C, then D, then B.2 and B.3 on top of D. D touches SSR and the frames client where Stage 8 + is active: one focused PR, timed with that work. +4. E in parallel once D exists. +5. F any time; G's ruling table in parallel; G+H implementation after the rulings. + +Every category ships to `next` as its own small PR, rebased daily; no long-lived branch. Each PR +reports before/after on the same base commit, and `next`'s number is recorded at each landing so +drift is attributed to the PR that caused it. While the effort runs, any PR touching +`packages/signals/src/core` states its floor delta in its body. diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index f3216c1eb..eaa3227ea 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -15,6 +15,32 @@ const alias = { }; const modifyEsbuildConfig = config => ({ ...config, alias }); +// The three floor caps are FROZEN (size-reduction effort, 2026-09-26 — +// documentation/plans/size-reduction-audit.md §A): they live in +// floor-caps.json, and check-floor-caps.mjs fails a PR that raises one +// without a `Size-Exception:` line in its body. Lowering is always allowed. +// The dated notes on each scenario below remain the ledger of how the +// floor got here. +const floorCaps = require("./floor-caps.json"); + +// Server-component PAGES (audit §1): everything such a page ships eagerly, +// nothing external — the frames client and the server-function transport +// resolve to their dists alongside solid-js/web/signals. The seroval codec +// is a dynamic import in both clients and a separate chunk in production; +// size-limit does not split, so the specifiers resolve to lazy-codec.js (the +// import site stays, the chunk's ~5 KB brotli does not inline). The +// "frames: eager client consumer" scenario measures the package; these +// measure the page. Subpath aliases first (prefix matching, see above). +const pageAlias = { + "@solidjs/web/server-functions/client": "../../packages/web/server-functions/dist/client.js", + "@solidjs/web/server-functions": "../../packages/web/server-functions/dist/client.js", + "@solidjs/web/frames": "../../packages/web/frames/dist/client.js", + "@solidjs/web/serialization/decode": "./lazy-codec.js", + "@solidjs/web/serialization": "./lazy-codec.js", + ...alias +}; +const pageEsbuildConfig = config => ({ ...config, alias: pageAlias }); + // Observe tier (documentation/plans/observe-tier-plan.md): the artifacts the // `observe` export condition selects — wiring kept (attribution hook sites, // owner labels, edge counters, the diagnostics channel), checks folded. Its @@ -384,7 +410,7 @@ module.exports = [ // 38 B, the rest is the flag's parking (with the drain entry), tail and // commit sites. Every scenario below moves by +52..+113 B brotli (the // same retained core). - limit: "9.94 KB", + limit: floorCaps["signals: core floor (createSignal/Memo/Effect/Root/flush)"], modifyEsbuildConfig }, { @@ -1084,7 +1110,7 @@ module.exports = [ // A lane frame is the run's (#3662, 2026-09-26): 12.67 -> 12.78 KB, // measured at 12,725 B against `next`'s 12,619 (+106 B). Core-retained ripple of the // lane-frame sites — see the core floor note. - limit: "12.78 KB", + limit: floorCaps["app: render + one signal (the simple-app floor)"], modifyEsbuildConfig }, { @@ -1356,7 +1382,7 @@ module.exports = [ // A lane frame is the run's (#3662, 2026-09-26): 21.32 -> 21.43 KB, // measured at 21,418 B against `next`'s 21,305 (+113 B). Core-retained ripple of the // lane-frame sites — see the core floor note. - limit: "21.43 KB", + limit: floorCaps["app: hydrating (no stores) with Show/For/Loading/Errored/lazy"], modifyEsbuildConfig }, { @@ -2545,5 +2571,34 @@ module.exports = [ path: "../../packages/web/frames/dist/client.js", limit: "12.42 KB", modifyEsbuildConfig: framesEsbuildConfig + }, + { + name: "page: base server components (hydrating + dynamic + frames + sf reference)", + // Size-reduction audit baseline (2026-09-26, next @ 3af4696fb): the + // whole eager graph of a server-component page with no client stores. + // Per-package (minified, attribute.mjs): signals 64.8K, frames client + // 31.8K, web 20.2K, solid-js 15.8K, sf client 12.7K. Two of those are known + // eager costs the audit's packaging tier removes — the frames client + // installing the container-trace materializer at load (the store engine, + // ~7.1 KB brotli of this number) and dynamic()'s string-tag branch + // retaining the spread attribute runtime (~4.3 KB). This cap is a + // baseline to cut from, not headroom to grow into. + // Rebased onto `next` @ 3af4696fb (2026-09-26): 46,757 -> 46,852 B + // (+95 B) — #3671's async dynamic() landing serialization/adoption in + // web and solid-js, and #3670's draft-visibility twin in the store. + path: "sc-base-app.js", + limit: "46.86 KB", + modifyEsbuildConfig: pageEsbuildConfig + }, + { + name: "page: live server components (base + live/GET + action + isPending/latest)", + // Same baseline for the live page: the base page plus the sf client's + // live loop and GET, `action`, and the verdict (isPending/latest, which + // the router retains on every real page anyway). Still no client stores. + // Rebased onto `next` @ 3af4696fb (2026-09-26): 51,103 -> 51,156 B + // (+53 B), same two commits as the base page. + path: "sc-live-app.js", + limit: "51.16 KB", + modifyEsbuildConfig: pageEsbuildConfig } ]; diff --git a/scripts/size/README.md b/scripts/size/README.md index fdc65dca5..e21bb202d 100644 --- a/scripts/size/README.md +++ b/scripts/size/README.md @@ -2,12 +2,35 @@ Tree-shaken import-cost tracking: `.size-limit.js` defines scenario entries (signals floor, +createStore, +isPending/latest, the render+one-signal simple -app, a representative CSR app, and a hydrating pair — with and without store -primitives — that keeps the store engine pay-for-use under `hydrate()`) with -hard gzip-limits. CI fails when a scenario -exceeds its limit — that means tree-shaking regressed, or a deliberate feature -landed and the limit should be bumped in the same PR with a reason. The -simple-app scenario is pinned at 10 KB on purpose. +app, a representative CSR app, a hydrating pair — with and without store +primitives — that keeps the store engine pay-for-use under `hydrate()`, the +frames client as a package, and two server-component PAGES: base and live) +with hard brotli limits. CI fails when a scenario exceeds its limit — that +means tree-shaking regressed, or a deliberate feature landed and the limit +should be bumped in the same PR with a reason. + +## Frozen floor caps + +The three floor scenarios — the signals floor, the simple app, and the +hydrating app without stores — have their caps in `floor-caps.json`, not in +`.size-limit.js`. They are **frozen**: a PR may lower them, never raise them. +`check-floor-caps.mjs` diffs the file against the PR's base branch in CI and +fails on a raise unless the PR body contains a line starting with +`Size-Exception:` naming why the maintainer accepted the cost. Ten weeks of +individually justified 10–300 B bumps took the signals floor from 7.1 to +9.9 KB; the freeze makes the next one a decision, not a paragraph. See +`documentation/plans/size-reduction-audit.md`. + +## Attribution + +`npm run size` runs size-limit and then `attribute.mjs`, which bundles every +scenario the same way with an esbuild metafile and prints the minified bytes +each package contributed (`signals`, `solid`, `web`, `web/frames`, +`web/server-functions`, …). Minified bytes are the attributable unit; brotli +compresses across module boundaries. `node attribute.mjs --modules [name]` +lists the individual dist modules of matching scenarios. + +## Layout This directory is deliberately **outside the pnpm workspace**, with its own npm lockfile. Its tooling must never enter the workspace dependency graph: @@ -15,6 +38,11 @@ changing that graph re-keys pnpm peer instances (vitest, @codspeed/vitest-plugin), which relocates the benchmark harness and shows up as phantom CodSpeed regressions. Nothing here is published (`private: true`). +The page scenarios alias the seroval codec to `lazy-codec.js`: both clients +load it through a dynamic import (a separate chunk in production) and +size-limit does not split, so the stub keeps the import site without inlining +the chunk. `lazy-page.js` plays the same role for `lazy()`. + Run locally: `cd scripts/size && npm ci && npm run size` (build the repo first). The retained-module-graph test in `packages/signals/tests/treeshake.test.ts` is the companion diagnostic diff --git a/scripts/size/attribute.mjs b/scripts/size/attribute.mjs new file mode 100644 index 000000000..47a075801 --- /dev/null +++ b/scripts/size/attribute.mjs @@ -0,0 +1,98 @@ +// Per-package attribution for every scenario in .size-limit.js. +// +// size-limit answers "did the cap hold"; this answers "which package moved". +// Each scenario is bundled the way size-limit bundles it (same entry, same +// alias/external through modifyEsbuildConfig, no code splitting) with an +// esbuild metafile, and the minified bytes each input contributed to the +// output are summed per package. Minified bytes are the attributable unit — +// brotli compresses across module boundaries, so the compressed total is +// reported for the bundle only. Runs after size-limit in `npm run size`. +// +// Usage: node attribute.mjs [--modules] [scenario-substring ...] +// --modules also list the individual dist modules over 200 minified bytes + +import { createRequire } from "node:module"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { brotliCompressSync, constants } from "node:zlib"; +import { build } from "esbuild"; + +const here = dirname(fileURLToPath(import.meta.url)); +const checks = createRequire(import.meta.url)("./.size-limit.js"); + +const args = process.argv.slice(2); +const listModules = args.includes("--modules"); +const filters = args.filter(a => !a.startsWith("--")); + +// Maps an esbuild input path to the package that shipped it. Anything not +// under packages/ (the scenario file itself, node_modules deps such as +// seroval) is grouped as "other". +function packageOf(input) { + const m = input.match(/packages\/([^/]+)\/(?:([^/]+)\/)?dist\//); + if (!m) return "other"; + const [, pkg, sub] = m; + // packages/web/frames/dist → web/frames; packages/web/dist → web + return sub && sub !== "dist" ? `${pkg}/${sub}` : pkg; +} + +function moduleOf(input) { + return input.replace(/^.*packages\//, "").replace(/\/dist\/(prod\/)?/, ":"); +} + +const brotli = buf => + brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length; + +const scratch = mkdtempSync(join(tmpdir(), "solid-size-attr-")); +try { + for (const check of checks) { + if (filters.length && !filters.some(f => check.name.includes(f))) continue; + // Mirror size-limit's `import` option: an entry that imports the named + // bindings from `path` and keeps them alive with a console.log. + let entry = join(here, check.path); + if (check.import) { + const list = check.import.replace(/[{}]/g, "").trim(); + entry = join(scratch, `${check.name.replace(/\W+/g, "-")}.js`); + writeFileSync( + entry, + `import ${check.import} from ${JSON.stringify(join(here, check.path))};\nconsole.log(${list});\n` + ); + } + let config = { + absWorkingDir: here, + bundle: true, + entryPoints: [entry], + metafile: true, + minify: true, + treeShaking: true, + write: false, + logLevel: "silent" + }; + if (check.modifyEsbuildConfig) config = check.modifyEsbuildConfig(config); + const result = await build(config); + const output = + Object.values(result.metafile.outputs).find(o => o.entryPoint) ?? + Object.values(result.metafile.outputs)[0]; + const bytes = result.outputFiles.reduce((s, f) => s + f.contents.length, 0); + const br = result.outputFiles.reduce((s, f) => s + brotli(f.contents), 0); + + const byPackage = new Map(); + const byModule = []; + for (const [input, { bytesInOutput }] of Object.entries(output.inputs)) { + const pkg = packageOf(input); + byPackage.set(pkg, (byPackage.get(pkg) ?? 0) + bytesInOutput); + if (bytesInOutput > 200 && pkg !== "other") byModule.push([moduleOf(input), bytesInOutput]); + } + const packages = [...byPackage].sort((a, b) => b[1] - a[1]); + + console.log(`\n${check.name}`); + console.log(` minified ${bytes} B brotli ${br} B`); + console.log(" " + packages.map(([p, b]) => `${p}=${b}`).join(" ")); + if (listModules) + for (const [m, b] of byModule.sort((a, b) => b[1] - a[1])) + console.log(` ${String(b).padStart(7)} ${m}`); + } +} finally { + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/scripts/size/check-floor-caps.mjs b/scripts/size/check-floor-caps.mjs new file mode 100644 index 000000000..b131d27f0 --- /dev/null +++ b/scripts/size/check-floor-caps.mjs @@ -0,0 +1,79 @@ +// The floor caps are frozen: a PR may lower them, never raise them, unless it +// carries an explicit exception. size-limit enforces the caps themselves; +// this enforces that the caps did not move. +// +// Why a separate check: for ten weeks the floor scenarios grew through +// individually justified 10–300 B bumps (7.1 → 9.8 KB brotli on the signals +// floor), each recorded in .size-limit.js and none resisted. Moving the three +// floor caps into floor-caps.json and diffing that file against the base +// branch turns a bump from a paragraph into a decision the reviewer sees. +// +// Usage: node check-floor-caps.mjs +// Compares floor-caps.json at HEAD with the same file at . Exits +// non-zero if any cap increased, unless SIZE_EXCEPTION (the PR body, in CI) +// contains a line starting with "Size-Exception:" that names the reason. +// A cap absent at the base (a new floor scenario) is allowed. + +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const base = process.argv[2]; +if (!base) { + console.error("usage: node check-floor-caps.mjs "); + process.exit(2); +} + +// size-limit's units are decimal (1 KB = 1000 B). +const toBytes = s => { + const m = String(s) + .trim() + .match(/^([\d.]+)\s*(B|KB|MB)?$/i); + if (!m) throw new Error(`unparseable cap "${s}"`); + const n = parseFloat(m[1]); + const unit = (m[2] ?? "B").toUpperCase(); + return unit === "MB" ? n * 1e6 : unit === "KB" ? n * 1e3 : n; +}; + +const head = JSON.parse(readFileSync(join(here, "floor-caps.json"), "utf8")); +const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: here, + encoding: "utf8" +}).trim(); +const relPath = relative(repoRoot, join(here, "floor-caps.json")); +let baseCaps = {}; +try { + baseCaps = JSON.parse( + execFileSync("git", ["show", `${base}:${relPath}`], { cwd: repoRoot, encoding: "utf8" }) + ); +} catch { + console.log(`floor-caps: ${relPath} absent at ${base}; nothing to compare.`); + process.exit(0); +} + +const exception = /^\s*Size-Exception:\s*\S/m.test(process.env.SIZE_EXCEPTION ?? ""); +let raised = []; +for (const [name, cap] of Object.entries(head)) { + if (!(name in baseCaps)) continue; + const before = toBytes(baseCaps[name]); + const after = toBytes(cap); + if (after > before) raised.push(` ${name}: ${baseCaps[name]} -> ${cap}`); +} + +if (raised.length === 0) { + console.log("floor-caps: no cap raised."); +} else if (exception) { + console.log("floor-caps: cap(s) raised under an explicit Size-Exception:\n" + raised.join("\n")); +} else { + console.error( + "floor-caps: a frozen floor cap was raised without an exception:\n" + + raised.join("\n") + + "\n\nRelocate the retained bytes out of the floor instead (the winning move is a\n" + + "pay-for-use module; see .cursor/rules/signals.mdc), or — if the maintainer has\n" + + "accepted the cost — add a line to the PR body:\n\n" + + " Size-Exception: \n" + ); + process.exit(1); +} diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json new file mode 100644 index 000000000..f6ad4adf1 --- /dev/null +++ b/scripts/size/floor-caps.json @@ -0,0 +1,5 @@ +{ + "signals: core floor (createSignal/Memo/Effect/Root/flush)": "9.94 KB", + "app: render + one signal (the simple-app floor)": "12.78 KB", + "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "21.43 KB" +} diff --git a/scripts/size/lazy-codec.js b/scripts/size/lazy-codec.js new file mode 100644 index 000000000..3640ccfc0 --- /dev/null +++ b/scripts/size/lazy-codec.js @@ -0,0 +1,10 @@ +// Stands in for `@solidjs/web/serialization` and `/decode` in the page +// scenarios. The frames client loads the seroval codec through a dynamic +// import (its `prepareData` hook), so in production it is a separate chunk +// fetched on the first `data` record. size-limit bundles without code +// splitting and would inline that chunk (~5 KB brotli of seroval) into the +// eager number; aliasing the specifiers here keeps the import site and +// leaves the codec's own cost to the serialization tests. +export function createJSONDeserializer() {} +export function createJSONDataTable() {} +export function serializeJSON() {} diff --git a/scripts/size/package-lock.json b/scripts/size/package-lock.json index 61698e567..66babb449 100644 --- a/scripts/size/package-lock.json +++ b/scripts/size/package-lock.json @@ -7,6 +7,7 @@ "name": "@solidjs/size-scenarios", "devDependencies": { "@size-limit/preset-small-lib": "^12.1.0", + "esbuild": "^0.28.1", "size-limit": "^12.1.0" } }, diff --git a/scripts/size/package.json b/scripts/size/package.json index 86aeeb067..d7b98b184 100644 --- a/scripts/size/package.json +++ b/scripts/size/package.json @@ -3,12 +3,15 @@ "private": true, "description": "Tree-shaken import-cost tracking for #2883. Deliberately OUTSIDE the pnpm workspace with its own npm lockfile: adding these dev tools to the workspace graph re-keys pnpm peer instances (vitest/@codspeed/vitest-plugin), which relocates the benchmark harness and produces phantom CodSpeed regressions.", "scripts": { - "size": "size-limit", + "size": "size-limit && node attribute.mjs", + "attribute": "node attribute.mjs", + "check-floor-caps": "node check-floor-caps.mjs", "build": "cd ../.. && pnpm install --frozen-lockfile && pnpm build" }, "devDependencies": { - "size-limit": "^12.1.0", - "@size-limit/preset-small-lib": "^12.1.0" + "@size-limit/preset-small-lib": "^12.1.0", + "esbuild": "^0.28.1", + "size-limit": "^12.1.0" }, "allowScripts": { "esbuild@0.28.1": true diff --git a/scripts/size/sc-base-app.js b/scripts/size/sc-base-app.js new file mode 100644 index 000000000..0c349a03b --- /dev/null +++ b/scripts/size/sc-base-app.js @@ -0,0 +1,23 @@ +// The base server-component page: a hydrating client (no client stores) +// that installs the frames transport and mounts one server component +// through `dynamic()` over a server-function reference. This is the whole +// eager graph such a page ships — signals floor, solid-js hydration, the +// web runtime, the frames client and the server-function transport — with +// the seroval codec left lazy as it is in production. Unlike the +// "frames: eager client consumer" scenario, nothing is external here: this +// measures the page, not the package. +import { hydrate, Show, For, Loading, Errored, dynamic } from "@solidjs/web"; +import { createSignal, createMemo, lazy } from "solid-js"; +import { installServerComponents } from "@solidjs/web/frames"; +import { createServerReference } from "@solidjs/web/server-functions/client"; + +installServerComponents(); +const getStory = createServerReference("story", "getStory"); +const Story = dynamic(() => getStory()); +const [n, setN] = createSignal(0); +const Page = lazy(() => import("./lazy-page.js")); +hydrate(() => { + const d = createMemo(() => n() + 1); + setN(1); + return [d(), Show, For, Loading, Errored, Page, Story]; +}, document.body); diff --git a/scripts/size/sc-live-app.js b/scripts/size/sc-live-app.js new file mode 100644 index 000000000..bf3876f95 --- /dev/null +++ b/scripts/size/sc-live-app.js @@ -0,0 +1,23 @@ +// The live server-component page: sc-base-app plus what a live page reaches +// for — `live(GET(...))` on the server-function reference, an `action` for +// the send path, and `isPending`/`latest` on the client signal (the router +// reads both, so every real page retains the verdict). Still no client +// stores: the store engine on this page, if any, is the frames client's +// container-trace materializer, which is exactly what this scenario keeps +// honest. +import { hydrate, Show, For, Loading, Errored, dynamic } from "@solidjs/web"; +import { createSignal, createMemo, action, isPending, latest, lazy } from "solid-js"; +import { installServerComponents } from "@solidjs/web/frames"; +import { createServerReference, live, GET } from "@solidjs/web/server-functions/client"; + +installServerComponents(); +const getStory = live(GET(createServerReference("story", "getStory"))); +const Story = dynamic(() => getStory()); +const send = action(async () => {}); +const [n, setN] = createSignal(0); +const Page = lazy(() => import("./lazy-page.js")); +hydrate(() => { + const d = createMemo(() => n() + (isPending(n) ? 1 : 0) + latest(n)); + setN(1); + return [d(), Show, For, Loading, Errored, Page, Story, send]; +}, document.body);