Skip to content
Closed
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
44 changes: 43 additions & 1 deletion scripts/size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ compiled-template scenarios — a compiled floor and a JSX todo app in CSR and
hydrating form — the frames client as a package, two server-component PAGES:
base and live, their two compiled counterparts — the same pages as JSX with
the templates a real page has — the same two hand-written pages under
`@solidjs/router`, and two server-entry floors: `getRequestEvent`/`isServer` and
`@solidjs/router`, the client entry of `examples/hackernews` as the example's
build ships it, and two server-entry floors: `getRequestEvent`/`isServer` and
`renderToString`) with hard brotli limits on the eager entry chunk. CI fails
when a scenario exceeds its limit and grew more than a small minified
allowance over its base (see [The gate](#the-gate)) — that means tree-shaking regressed, or a deliberate
Expand Down Expand Up @@ -175,6 +176,41 @@ pages' rendered `web.js`). The hand-written pages stay as the frames-only
floor (their caps are the frozen ones); the compiled pages are the number a
real page moves by.

## The example scenario

`page: hackernews` bundles a real application: the client entry of
`examples/hackernews` (the server-components HackerNews) as its `vite build`
ships it. The fixture, `fixtures/hackernews/entry.jsx`, is
`@solidjs/vite-plugin`'s generated client entry written out —
`installServerComponents()`, then `hydrate()` of the plugin's document shell
and default error boundary (the other two files beside it) around the app —
and the app is the example's own `src/`, reached unchanged through its `~`
alias and compiled hydratable like the other compiled scenarios. Two things
the harness does for it that the plugin does in a Vite build: `.tsx`
compiles like `.jsx`, with the type annotations the JSX compiler leaves in
place stripped by Rolldown as Vite strips them, and the example's
`"use server"` modules go through the native directive pass in client mode
(`compile.serverFunctions` names the example's root), so each exported view
becomes a `createServerReference` proxy and the server-only code is gone —
the example's `api.ts` is its whole server-function surface. Its `.css`
import loads as an empty module (Vite extracts it to a stylesheet). The
router is this directory's pinned `@solidjs/router`, not the version the
example's own `package.json` installs under `examples/hackernews/node_modules`
(aliased, so the scenario does not move with the workspace install), and it
is resolved the way the example's build resolves it: the `solid` export
condition `@solidjs/vite-plugin` puts first — `dist/index.jsx` plus the
router's per-module output, its JSX compiled by the measured checkout's
compiler like the app's own. That is a different artifact from the flat
`dist/index.js` the two router scenarios resolve (the `default` condition,
the fallback for consumers without a Solid compiler): the flat bundle is
built with `inlineDynamicImports`, so `data/events`' lazy
`import("./serverForms")` — the server-form fallback, and through it
`action` and the flight consumer — is eager there and a lazy chunk here,
as it is in a Vite build. The example's modules report as the `app`
package; the router's as `other`. Not a floor: an inline cap. The example
itself is not changed for the scenario; if it changes, the scenario moves,
which is the point.

## Frozen floor caps

The three floor scenarios — the signals floor, the simple app, and the
Expand Down Expand Up @@ -250,3 +286,9 @@ breaks.
`--minified-only` mode from CI's measurement of `next` @ fff1615ee (Size
run 37443080193); every cap unchanged. The base comparison remains the fail-safe
for a cap without a recorded minified.
- **2026-10-07 — the example scenario.** `page: hackernews` bundles
`examples/hackernews`'s client entry (above). `bundle.mjs` compiles `.tsx`
as well as `.jsx`, runs the `"use server"` directive pass in client mode
under a scenario's `compile.serverFunctions` root, loads `.css` as empty,
and attributes an example's modules as `app`. No other scenario's number
moved.
95 changes: 81 additions & 14 deletions scripts/size/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,23 @@
// module it reaches are compiled; the output is handed to Rolldown as plain
// JS. The compiler sees only each file's basename, so no host path can reach
// the output and the numbers are the same on every machine.
//
// Example applications (2026-10-07): a compiled scenario may bundle one of
// the repo's examples/ as an application ships it — its `.tsx` sources
// (compiled like `.jsx`; the type annotations the JSX compiler leaves in
// place are stripped by Rolldown, as Vite strips them) and, with
// `compile.serverFunctions` naming the example's root, its `"use server"`
// modules as the client build sees them: the native directive pass in
// client mode replaces each exported function with a `createServerReference`
// proxy and drops the server-only body and its imports, exactly what
// @solidjs/vite-plugin does before the JSX compile. Function IDs hash the
// root-relative path, so that output is host-independent too. A `.css`
// import loads as an empty module (Vite extracts it to a stylesheet; none
// of it is in the script). An example's own modules are the `app` package.

import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { basename, dirname, isAbsolute, join, relative } from "node:path";
import { basename, dirname, extname, isAbsolute, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { brotliCompressSync, constants } from "node:zlib";
import { rolldown } from "rolldown";
Expand All @@ -61,6 +74,13 @@ export const packagesRoot = process.env.SIZE_PACKAGES_ROOT
// package in every report: the compiled app's own bytes, separable from
// the runtime's.
export const fixturesRoot = join(here, "fixtures");
// The repo's example applications; a `page: <example>` scenario bundles one
// through its fixture entry. Like fixtures/, an example's own modules are
// the `app` package. The example comes from this checkout even when
// SIZE_PACKAGES_ROOT measures another's dists: the compare job measures the
// base's runtime under the head's harness and fixtures, and the example is
// a fixture here.
export const examplesRoot = join(here, "..", "..", "examples");

const resolvePath = p =>
p.startsWith("../../packages/")
Expand All @@ -73,15 +93,48 @@ const resolvePath = p =>
// (Linux CI and macOS alike), falling back to the installed platform
// package — so no path here depends on the host.
let compiler;
const compile = (id, hydratable) => {
compiler ??= require(join(packagesRoot, "compiler", "index.js"));
return compiler.transform(readFileSync(id, "utf8"), {
const loadCompiler = () => (compiler ??= require(join(packagesRoot, "compiler", "index.js")));
const compile = (code, id, hydratable) =>
loadCompiler().transform(code, {
filename: basename(id),
generate: "dom",
hydratable,
dev: false
}).code;

// The `"use server"` pass, client mode, with the Vite plugin's defaults: the
// runtime is `@solidjs/web/server-functions` (the page alias routes it to
// the client dist), the ID hash is of the path relative to `root`, the
// production posture. A module without the directive comes back `valid:
// false` and is left as it is; the substring test is the plugin's own fast
// path.
const SERVER_FUNCTIONS_RUNTIME = "@solidjs/web/server-functions";
const compileDirectives = (code, id, root) => {
if (!code.includes("use server")) return code;
const result = loadCompiler().transformDirectives(code, {
filename: id,
root,
mode: "client",
env: "production",
register: { kind: "named", name: "registerServerReference", source: SERVER_FUNCTIONS_RUNTIME },
create: { kind: "named", name: "createServerReference", source: SERVER_FUNCTIONS_RUNTIME }
});
return result.valid ? result.code : code;
};

// What the compile plugin loads itself: JSX/TSX everywhere (the compiler),
// and under the server-functions root every script module (the directive
// pass; the plugin's include is `src/**/*.{jsx,tsx,ts,js,mjs,cjs}`). The
// output's module type tells Rolldown whether types remain to strip.
const SCRIPT_TYPES = {
".js": "js",
".mjs": "js",
".cjs": "js",
".jsx": "js",
".ts": "ts",
".tsx": "ts"
};
const JSX_EXTENSIONS = new Set([".jsx", ".tsx"]);

export const brotli = buf =>
brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length;
Expand All @@ -102,17 +155,19 @@ const ENTRY = "\0scenario-entry";

/**
* Maps a module id to the package that shipped it; a compiled scenario's own
* sources (fixtures/) are "app", anything else outside packages/ is "other".
* sources (fixtures/, and the examples/ an example scenario bundles) are
* "app", anything else outside packages/ is "other".
*/
export function packageOf(id) {
if (id.startsWith(fixturesRoot)) return "app";
if (id.startsWith(fixturesRoot + sep) || id.startsWith(examplesRoot + sep)) return "app";
const m = id.match(/packages\/([^/]+)\/(?:([^/]+)\/)?dist\//);
if (!m) return "other";
const [, pkg, sub] = m;
return sub && sub !== "dist" ? `${pkg}/${sub}` : pkg;
}
export function moduleOf(id) {
if (id.startsWith(fixturesRoot)) return `app:${relative(fixturesRoot, id)}`;
if (id.startsWith(fixturesRoot + sep)) return `app:${relative(fixturesRoot, id)}`;
if (id.startsWith(examplesRoot + sep)) return `app:${relative(join(here, "..", ".."), id)}`;
return id.replace(/^.*packages\//, "").replace(/\/dist\/(prod\/|observe\/)?/, ":");
}

Expand Down Expand Up @@ -145,17 +200,29 @@ export async function bundle(scenario) {
resolveId: id => (id === ENTRY ? ENTRY : null),
load: id => (id === ENTRY ? synthetic : null)
},
// Compiled scenarios: every `.jsx` module is compiled before Rolldown
// parses it, and arrives as plain JS so Rolldown's own JSX transform
// never runs on it.
// Compiled scenarios: every `.jsx`/`.tsx` module is compiled before
// Rolldown parses it, and arrives as JS (or type-annotated JS, which
// Rolldown strips) so Rolldown's own JSX transform never runs on it.
// Under `compile.serverFunctions` the directive pass runs first.
...(scenario.compile
? [
{
name: "scenario-compile",
load: id =>
id.endsWith(".jsx")
? { code: compile(id, !!scenario.compile.hydratable), moduleType: "js" }
: null
load: id => {
if (id.endsWith(".css")) return { code: "", moduleType: "empty" };
const ext = extname(id);
const type = SCRIPT_TYPES[ext];
const jsx = JSX_EXTENSIONS.has(ext);
const root = scenario.compile.serverFunctions
? resolvePath(scenario.compile.serverFunctions)
: null;
const directives = root && type && id.startsWith(root + sep);
if (!jsx && !directives) return null;
let code = readFileSync(id, "utf8");
if (directives) code = compileDirectives(code, id, root);
if (jsx) code = compile(code, id, !!scenario.compile.hydratable);
return { code, moduleType: type };
}
}
]
: [])
Expand Down
18 changes: 18 additions & 0 deletions scripts/size/fixtures/hackernews/document.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// @solidjs/vite-plugin 3.0.0-next.35's built-in document shell
// (`virtual:solid-ssr-document`, SSR start mode) as the plugin emits it:
// minimal, hydration-ready. The client entry script is injected into <head>
// by the handler, not rendered here.
import { HydrationScript } from "@solidjs/web";

export default function Document(props) {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<HydrationScript />
</head>
<body>{props.children}</body>
</html>
);
}
30 changes: 30 additions & 0 deletions scripts/size/fixtures/hackernews/entry.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// `page: hackernews`: the client entry of examples/hackernews as
// @solidjs/vite-plugin 3.0.0-next.35 generates it for that example's config
// (`solid({ start: {}, ssr: true, serverFunctions: { components: true } })`,
// the default `errorBoundary`, no devtools) — `virtual:solid-ssr-entry-client`
// written out: install the server-component transport, then hydrate the
// document with the app inside the plugin's default error boundaries. The
// app is the example's own `src/app.tsx` (through its `~` alias), reached
// unchanged; `document.jsx` and `error-boundary.jsx` beside this file are
// the plugin's other two generated modules. Together they are what the
// example's `vite build` hands the browser as its one eager script.
import { hydrate } from "@solidjs/web";
import { installServerComponents } from "@solidjs/web/frames";
import { DefaultErrorBoundary } from "./error-boundary.jsx";
import Document from "./document.jsx";
import App from "~/app";

installServerComponents();

hydrate(
() => (
<DefaultErrorBoundary>
<Document>
<DefaultErrorBoundary>
<App />
</DefaultErrorBoundary>
</Document>
</DefaultErrorBoundary>
),
document
);
20 changes: 20 additions & 0 deletions scripts/size/fixtures/hackernews/error-boundary.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// @solidjs/vite-plugin 3.0.0-next.35's default error boundary
// (`virtual:solid-ssr-error-boundary`), emitted into production builds
// unless `start.errorBoundary: false`; the generated entry wraps the
// document and the app in it.
import { Errored } from "solid-js";
import { httpStatus, isServer } from "@solidjs/web";

function ErrorFallback(props) {
console.error(props.error());
httpStatus(500);
return (
<span style="font-size:1.5em;text-align:center;position:fixed;left:0;bottom:55%;width:100%">
{isServer ? "500 | Internal Server Error" : "Error | Uncaught Client Exception"}
</span>
);
}

export function DefaultErrorBoundary(props) {
return <Errored fallback={error => <ErrorFallback error={error} />}>{props.children}</Errored>;
}
86 changes: 86 additions & 0 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,29 @@ const pageAlias = {
...alias
};

// examples/hackernews as the harness bundles it (2026-10-07): the example's
// `~` alias is its src/, and `@solidjs/router` is this directory's pinned
// copy (`2.0.0-next.35`), not the `2.0.0-next.29` the example's own
// package.json installs under examples/hackernews/node_modules — the
// harness's router is the one whose upgrade is a recorded re-base, and the
// scenario must not move with the workspace install. It resolves the way
// the example's Vite build resolves it: @solidjs/vite-plugin puts the
// `solid` export condition first, which is `dist/index.jsx` plus the
// router's per-module output, JSX compiled by the app's compiler (here the
// measured checkout's, hydratable, like the app's own `.tsx`). The router
// scenarios above resolve the `default` condition, the flat `dist/index.js`
// fallback for consumers without a Solid compiler — a different artifact:
// its build inlines `data/events`' `import("./serverForms")`, so the
// server-form fallback and, through it, `action` and the flight consumer
// are eager there and a lazy chunk here. The rest is the page alias: the
// example's `solid-js` / `@solidjs/web` / frames / server-function imports
// route to the dists.
const hackernewsAlias = {
"~": "../../examples/hackernews/src",
"@solidjs/router": "node_modules/@solidjs/router/dist/index.jsx",
...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 @@ -4539,6 +4562,69 @@ module.exports = [
capMinified: 148668,
alias: pageAlias
},
{
name: "page: hackernews (examples/hackernews client entry)",
// A real application's eager script: the client entry of
// examples/hackernews — the server-components HackerNews — as its
// `vite build` ships it. The fixture (fixtures/hackernews/entry.jsx) is
// @solidjs/vite-plugin 3.0.0-next.35's generated client entry written
// out (`installServerComponents()`, `hydrate()` of the plugin's document
// shell and default error boundary around the app); the app is the
// example's own src/, unchanged, compiled hydratable by the measured
// checkout's compiler: `createRouter` with three `defineRoute`s (six
// feed paths, `/stories/:id`, `/users/:id`), each with a `preload`, the
// instance as the root with a render-prop layout, two `<Loading>`
// boundaries, four server components mounted with `dynamic()` (the nav
// directly; the three routes through the router's `query` — the
// example's `"use server"` views compile, client side, to
// `createServerReference` proxies: the one `api.ts` module is the
// example's whole server-function surface), and the one client
// component (`Toggle`: a signal, a delegated click, `class`/`style`
// effects). No client stores, no `action`, no element spread. The
// router is resolved as the example's build resolves it (the `solid`
// condition; see hackernewsAlias), so its server-form fallback —
// `data/serverForms`, `data/action`, signals' `action` — is the lazy
// chunk it is in the example's build, reported and not counted. The
// router's own modules report as `other`; the example's as `app`.
// The example mounts with `dynamic()`, not #3870's `dynamicComponent`
// (which is not on this base); it carries `dynamic`'s string-tag
// branch and the spread runtime it retains, as #3870 measured.
// Not a floor: an inline cap.
// Landing (2026-10-07, next @ 49a8dca84 + #3838): measured locally at
// 54,557 B (176,581 B minified): signals 64,308, frames 35,009, router
// 22,616 (`routing.js` 6,616, `data/query.js` 3,260, `utils.js` 2,767,
// `routers/factory.jsx` 2,584, `data/events.js` 1,997, components
// 1,327, claims 1,296, history 1,045, scroll restoration 835, …), web
// 20,630, solid 18,097, sf 13,510, app 2,412 (the example's own
// modules 1,651 — `app.tsx` 505, `toggle.tsx` 426 — and the generated
// entry/document/error boundary 761). Lazy: the codec 6,074 B br, the
// router's `serverForms.js` 2,360 (`data/serverForms`, `data/action`,
// signals' `action`). On the flat `dist/index.js` the same page is
// 181,895 / 56,084: +5,314 / +1,527 for the inlined server-form
// fallback and what it reaches. Against `next` @ 3c1d51267 measured
// with this harness, 177,417 / 54,976: −836 / −419, which is the
// thirteen commits `next` took after this branch's base (#3849 and the
// signals/web fixes beside it), not this branch's. Cap at local
// measured + 10 B rounded up to 0.01 KB; to be confirmed against CI's
// measurement (Node 24) + 10 B.
// Re-based (2026-10-07, next @ d231b9911 — #3838 and #3860's frames
// tiers merged): 47,849 B (145,471 B minified): signals 41,036 (the
// store engine — `store/store.js`, reconcile, projection — is in the
// lazy `trace.js` now, 8.19 KB br; `store/utils.js` 3,425 and
// `store/types.js` 789 stay eager for `dynamic()`'s element arm, the
// lanes 5,343 + verdict 2,682 for the router's navigation core),
// frames 26,950, router 23,376, web 20,344, solid 17,309, sf 13,967,
// app 2,491. The −31,110 / −6,708 against the landing figure is
// `next`'s (the tiers), not this scenario's. The example still mounts
// with `dynamic()`; its move to `dynamicComponent` (now on `next`) is
// the example's own change and will show here when it lands. Cap at
// local measured + 10 B rounded up to 0.01 KB, same caveat.
path: "fixtures/hackernews/entry.jsx",
compile: { hydratable: true, serverFunctions: "../../examples/hackernews" },
limit: "47.86 KB",
capMinified: 145471,
alias: hackernewsAlias
},
{
name: "server: floor (getRequestEvent + isServer)",
// What a server module that only asks "am I on the server / which
Expand Down
Loading