Skip to content
Merged
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
17 changes: 16 additions & 1 deletion .github/workflows/size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
324 changes: 324 additions & 0 deletions documentation/plans/size-reduction-audit.md

Large diffs are not rendered by default.

61 changes: 58 additions & 3 deletions scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
},
{
Expand Down Expand Up @@ -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
},
{
Expand Down Expand Up @@ -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
},
{
Expand Down Expand Up @@ -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
}
];
40 changes: 34 additions & 6 deletions scripts/size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,47 @@

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:
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
Expand Down
98 changes: 98 additions & 0 deletions scripts/size/attribute.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
79 changes: 79 additions & 0 deletions scripts/size/check-floor-caps.mjs
Original file line number Diff line number Diff line change
@@ -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 <base-ref>
// Compares floor-caps.json at HEAD with the same file at <base-ref>. 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 <base-ref>");
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: <why this floor cost is accepted>\n"
);
process.exit(1);
}
5 changes: 5 additions & 0 deletions scripts/size/floor-caps.json
Original file line number Diff line number Diff line change
@@ -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"
}
10 changes: 10 additions & 0 deletions scripts/size/lazy-codec.js
Original file line number Diff line number Diff line change
@@ -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() {}
1 change: 1 addition & 0 deletions scripts/size/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading