From 23889464ebea92a465e2a9559c8cbc6422f26c10 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 7 Oct 2026 12:27:40 -0700 Subject: [PATCH] chore(size): add page: hackernews scenario (examples/hackernews client entry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client entry of examples/hackernews as its vite build ships it, through the harness: the generated entry (installServerComponents + hydrate of the plugin's document shell and default error boundary) around the example's own src/, compiled hydratable by the measured checkout's compiler, with the router resolved the way the example's build resolves it (the solid export condition: dist/index.jsx + per-module output), so the router's server-form fallback — and action behind it — is the lazy chunk it is in a Vite build. Harness: bundle.mjs compiles .tsx like .jsx (Rolldown strips the types the JSX compiler leaves), runs the native "use server" directive pass in client mode under a scenario's compile.serverFunctions root, loads .css as empty, and attributes examples/ modules as app. No other scenario's number moved (every recorded minified matches to the byte). Measured locally: 176,581 B minified / 54,557 B brotli; cap at measured + 10 B rounded up to 0.01 KB, to be confirmed against CI. Co-authored-by: Cursor --- scripts/size/README.md | 44 ++++++++- scripts/size/bundle.mjs | 95 ++++++++++++++++--- scripts/size/fixtures/hackernews/document.jsx | 18 ++++ scripts/size/fixtures/hackernews/entry.jsx | 30 ++++++ .../fixtures/hackernews/error-boundary.jsx | 20 ++++ scripts/size/scenarios.js | 86 +++++++++++++++++ 6 files changed, 278 insertions(+), 15 deletions(-) create mode 100644 scripts/size/fixtures/hackernews/document.jsx create mode 100644 scripts/size/fixtures/hackernews/entry.jsx create mode 100644 scripts/size/fixtures/hackernews/error-boundary.jsx diff --git a/scripts/size/README.md b/scripts/size/README.md index 432f3b418..999f0fe49 100644 --- a/scripts/size/README.md +++ b/scripts/size/README.md @@ -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 @@ -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 @@ -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. diff --git a/scripts/size/bundle.mjs b/scripts/size/bundle.mjs index 315c9e439..31a91fc45 100644 --- a/scripts/size/bundle.mjs +++ b/scripts/size/bundle.mjs @@ -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"; @@ -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: ` 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/") @@ -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; @@ -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\/)?/, ":"); } @@ -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 }; + } } ] : []) diff --git a/scripts/size/fixtures/hackernews/document.jsx b/scripts/size/fixtures/hackernews/document.jsx new file mode 100644 index 000000000..c757ff569 --- /dev/null +++ b/scripts/size/fixtures/hackernews/document.jsx @@ -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 +// by the handler, not rendered here. +import { HydrationScript } from "@solidjs/web"; + +export default function Document(props) { + return ( + + + + + + + {props.children} + + ); +} diff --git a/scripts/size/fixtures/hackernews/entry.jsx b/scripts/size/fixtures/hackernews/entry.jsx new file mode 100644 index 000000000..337be52cf --- /dev/null +++ b/scripts/size/fixtures/hackernews/entry.jsx @@ -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( + () => ( + + + + + + + + ), + document +); diff --git a/scripts/size/fixtures/hackernews/error-boundary.jsx b/scripts/size/fixtures/hackernews/error-boundary.jsx new file mode 100644 index 000000000..fb942e3ba --- /dev/null +++ b/scripts/size/fixtures/hackernews/error-boundary.jsx @@ -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 ( + + {isServer ? "500 | Internal Server Error" : "Error | Uncaught Client Exception"} + + ); +} + +export function DefaultErrorBoundary(props) { + return }>{props.children}; +} diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 09deea08e..ac24aff2f 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -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 @@ -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 `` + // 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