Skip to content

size: examples/hackernews scenario - #3879

Closed
ryansolid wants to merge 1 commit into
nextfrom
size/hackernews-scenario
Closed

ryansolid wants to merge 1 commit into
nextfrom
size/hackernews-scenario

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

One size scenario: the client entry of examples/hackernews — the server-components HackerNews — as its vite build ships it, bundled through the harness. Scenario, fixture and harness tooling only — nothing under packages/*/src, the example is not changed, no changeset. Re-based onto next @ d231b99 (2026-10-07): #3838 (size/page-router-scenario, the @solidjs/router wiring this stacked on) and #3860 (the frames tiers) are merged; the one commit is the scenario, with its cap re-measured on that base.

Summary

The page scenarios are compositions the harness wrote for itself. This one is an application someone wrote to be deployed: three routes under @solidjs/router, four server components, one client component, hydrating. It measures what the stack costs a real read-only server-component app, and attributes it by package. Measured locally on next @ d231b99 at 145,471 B minified / 47,849 B brotli; the router is 23,376 of the minified, the app's own code 2,491. (The landing measurement, on 49a8dca84 + #3838, was 176,581 / 54,557; the −31,110 / −6,708 between them is next's — #3860's frames tiers moved the store engine the container-trace materializer builds on into the lazy trace.js — not this scenario's.)

While wiring it: the router the two existing router scenarios measure is not the artifact a Vite app ships — see the router's two artifacts below. This PR leaves those scenarios alone and flags it.

What the scenario bundles

fixtures/hackernews/entry.jsx is @solidjs/vite-plugin@3.0.0-next.35's generated client entry for the example's config (solid({ start: {}, ssr: true, serverFunctions: { components: true } }), default errorBoundary) written out: installServerComponents(), then hydrate() of the plugin's document shell (document.jsx) and default error boundary (error-boundary.jsx) around the app. Those two files are the plugin's other generated modules, verbatim. The app is the example's own src/, unchanged, reached through its ~ alias:

  • createRouter with three defineRoutes (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; lib/api.ts is the example's whole server-function surface.
  • One client component, Toggle: a signal, a delegated click, class/style effects, two templates.
  • No client stores, no action, no element spread.

The router is this directory's pinned 2.0.0-next.35 (not the next.29 the example's package.json installs — the harness's router is the one whose upgrade is a recorded re-base), resolved the way the example's build resolves it: the solid export condition @solidjs/vite-plugin puts first, i.e. dist/index.jsx plus the router's per-module output, its JSX compiled by the measured checkout's compiler like the app's own .tsx. Its data/events import("./serverForms.js") is a real lazy chunk here, as in a Vite build.

Compiled hydratable: true, dev: false, generate: "dom". Router modules report as other, the example's as app.

Numbers

Local, macOS, Node 26. Head = this branch's fresh build (pnpm install --frozen-lockfile, compiler rebuilt, turbo --force), one commit on next @ d231b99. This branch carries no runtime change — the packages it measures are next's — so head and next are the same bytes for every existing scenario (each matches next's measurement in a second worktree on the same commit to the byte, e.g. page: compiled base SC 109,461 / 35,146 on both), and page: hackernews on next IS this number; there is no delta to attribute.

min br lazy (reported, not counted)
page: hackernews — head (= next @ d231b99) 145,471 47,849 trace.js 8,194 br (the store engine + the container-trace materializer, the frames traces tier) · codec decode.js 6,237 · router serverForms.js 2,388 (data/serverForms, data/action, signals' action) · bind.js 1,831 · server.js 1,023 (behind a folded isServer) · wire.js 932 · regions.js 791 · assets.js 783
page: hackernews — at landing (49a8dca84 + #3838) 176,581 54,557 codec 6,074 · serverForms.js 2,360 · server.js 1,023
delta re-base − landing −31,110 −6,708 next's: #3860's tiers (the engine and the materializer to trace.js), #3838's re-base, and the fixes since

The eager graph is two files on this page — the entry 60,843 min / 20,438 br and the eager client.js 84,628 / 27,411 Rolldown hoists for the modules the lazy chunks share with the entry — measured and counted as bundle.mjs counts them (each brotli'd on its own). The module-by-module attribution of the signals bytes, and what retains each, is in documentation/plans/hackernews-signals-attribution.md (on audit/hn-signals-attribution; measured on this scenario).

For scale against the hand-written compositions on this base: page: base + router 144,238 / 45,996, page: base 105,413 / 33,910. HN is a real app and lands below the hand-written router fixture — the fixture keeps Show/For/useNavigate alive and measures the flat router bundle (below).

Attribution by package (head, minified)

package bytes of the 145,471 what
signals 41,036 28.2% core/core.js 8,581, scheduler 5,793, core/lanes.js 5,343, async 4,060, store/utils.js 3,425 (the merge/omit view readers, retained by dynamic()'s element arm — spread), boundaries 3,184, core/verdict.js 2,682, owner 1,543, effect 1,140, heap 996, graph 861, store/types.js 789, … store/store.js / reconcile / projection 0 (the lazy trace.js), core/action.js 0 (lazy)
web/frames 26,950 18.5% client.js
@solidjs/router (other) 23,376 16.1% routing.js 6,616 · data/query.js 3,260 · utils.js 2,767 · routers/factory.jsx 2,584 · data/events.js 1,997 · routers/components.jsx 1,327 · claims.js 1,296 · routers/history.js 1,045 · routers/scrollRestoration.js 835 · paths.js 457 · serverRouteShared.js 432
web 20,344 14.0% web.js — dynamic (string-tag branch + the spread runtime it retains; the example has not moved to dynamicComponent, see below), hydration, templates
solid 17,309 11.9% solid.js 17,246 (hydration, flow components) + internal.js 63
web/server-functions 13,967 9.6% client.js — createServerReference, the transport, GET/decode; subscribeFlightData/decodeResponsePayload/parseServerFunctionActionUrl are retained in the eager chunk for the lazy serverForms.js chunk, which imports them from it
app 2,491 1.7% the example's own modules (app.tsx 522, toggle.tsx 440, routes/stories.tsx 224, story.tsx, lib/views.tsx — four createServerReferences — user.tsx, lib/api.ts) plus the generated entry 205, document 232, error boundary 349

The lanes (lanes.js + verdict.js, 8,025 apportioned — 6,683 exact by source map in hackernews-signals-attribution.md, on the same runtime one signals fix earlier) are on this page because the router's navigation core reads isPending(source)/latest(source) (isRouting, intent, pending target in createRouterContext; query reads them of the location) and onSettled in createIntegration — not because of action, which is lazy here. The base page without a router has none of them.

Caps — CI must confirm

Inline limit: "47.86 KB" / capMinified: 145471 — local measured (47,849 / 145,471) + 10 B at the 0.01 KB step, re-set from the re-base (the landing cap was 54.57 KB / 176,581 on the pre-tiers base). Confirm against CI's measurement (Linux, Node 24) + 10 B — never higher; if CI measures lower, lower them in this PR before merging. Now that the PR targets next, the Size workflow runs on it. No floor-caps.json entry. check-floor-caps origin/next: no cap raised. npm test in scripts/size passes; node size.mjs — the new scenario within its cap; the existing scenarios read exactly what next @ d231b99 reads on this machine (the known local brotli-layout lines over by 16–36 B at +15 B minified against their recorded size are next's own since the signals fixes after the caps were seeded, and gate.mjs passes them within the minified allowance).

The router's two artifacts (flagged for #3838)

@solidjs/router's exports["."] is { solid: "./dist/index.jsx", default: "./dist/index.js" }. The solid condition is index.jsx + the per-module tsc output, which @solidjs/vite-plugin resolves (it puts solid first in resolve.conditions); dist/index.js is the flat Rollup bundle for consumers without a Solid compiler, built with inlineDynamicImports: true, which turns data/events.ts's lazy import("./serverForms.js") — the server-form fallback — into Promise.resolve().then(() => serverForms): a static edge from setupNativeEvents to submitServerForm → createServerFormAction → actionImpl → action$1 (signals), installRouterIntegrations, the flight consumer and the sf transport's flight exports. page: base + router and page: live + router resolve the bare specifier with Rolldown's default conditions, so they measure the flat bundle.

Same page, same build, both artifacts (measured at landing; the gap is the router's and does not move with next):

scenario flat dist/index.js solid condition flat overstates by
page: hackernews (at landing, 49a8dca84 + #3838) 181,895 / 56,084 176,581 / 54,557 +5,314 / +1,527
page: base + router (same base) 184,378 / 57,001 (the scenario) 176,045 / 54,411 +8,333 / +2,590

So the router's marginal cost on the base page that #3838's body records (+38,779 min / +12,137 br) is +30,446 / +9,547 for a Vite app. solidjs/solid-router#657 (open) splits the flat build so both paths get the same graph; when a router with that lands, the flat numbers move down to about the solid-condition ones and the two router scenarios re-base. This PR does not change them (their caps are being confirmed in #3838); the HN scenario measures the solid condition from the start, which is what the example ships. The measurement behind this is on solid-router#655 — comment linked below.

Router without action, measured

Posted as a comment on solid-router#655. Headline, on this head: removing action's one static edge from the flat bundle saves −7,523 min / −2,265 br on HN and −11,727 / −3,613 on page: base + router; on the solid condition action is already lazy, and dropping the fallback entirely (the issue's import-driven shape) is a further −2,196 / −764 on HN. The signals lanes stay in every variant (the navigation core's isPending/latest/onSettled); createEffect leaves only with scroll restoration (−1,141 min / −344…−379 br); the form path's own wiring is 142 min.

Harness accommodations (bundle.mjs)

Needed to bundle the example without changing it:

  • .tsx compiles like .jsx. The native compiler leaves type annotations in place, so the compiled module is handed to Rolldown as moduleType: "ts" and Rolldown strips them (as Vite's own TS strip does after the Solid plugin). Plain .ts (lib/api.ts) Rolldown already handled.
  • compile.serverFunctions: <root> runs the compiler's "use server" directive pass in client mode on the modules under that root that contain the directive, with the Vite plugin's defaults (@solidjs/web/server-functions runtime — the page alias routes it to the client dist; IDs hash the root-relative path, so output is host-independent; env: "production"). Replaces each exported view with a createServerReference proxy and drops the server-only body and its ./hn import — exactly what the plugin does before the JSX compile. Modules without the directive are left to Rolldown.
  • .css loads as an empty module (app.css; Vite extracts it to a stylesheet).
  • examples/ modules report as app (packageOf/moduleOf), with examplesRoot beside fixturesRoot. The example comes from the measured harness's checkout even under SIZE_PACKAGES_ROOT, like the fixtures.
  • Aliases for the scenario: ~ → examples/hackernews/src; @solidjs/router → node_modules/@solidjs/router/dist/index.jsx (the solid condition, and the harness's pin rather than the example's).

No example change. The example still mounts with dynamic(); dynamicComponent (#3870, merged with #3860) is on next now, so the example CAN move to it — that move is the example's own change (the demos pass), not this scenario's, and this scenario will show the saving when it lands: on HN, dynamic()'s element arm is what retains the spread runtime and the merge/omit view readers (store/utils.js 3,425 + store/types.js 789 + web's spread/assign); hackernews-signals-attribution.md measured the switch at −7,987 min / −2,339 br on this page.

Verification

Fresh build of this head on next @ d231b99 (pnpm install --frozen-lockfile, compiler rebuilt, turbo --force, scripts/size npm ci), cross-checked against a second fresh build of the same commit; npm test in scripts/size; node size.mjs; check-floor-caps.mjs origin/next; Prettier clean.

Public API changes

None.

…t entry)

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 <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the size/hackernews-scenario branch from 23c67f7 to 2388946 Compare October 8, 2026 01:50
@changeset-bot

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2388946

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

This PR includes changesets to release 12 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch
todos-server-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.43 KB 0 B 0 B +15 B 7.45 KB ✅
signals: + createStore 14.69 KB 0 B 0 B 0 B 14.70 KB ✅
signals: + isPending/latest 9.63 KB 0 B 0 B +15 B 9.65 KB ✅
app: render + one signal (the simple-app floor) 9.92 KB 0 B 0 B +15 B 9.93 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB 0 B 0 B +15 B 17.91 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.17 KB 0 B 0 B +55 B 29.19 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.92 KB 0 B 0 B +15 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.53 KB 0 B 0 B +15 B 14.53 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.85 KB 0 B 0 B +15 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB 0 B 0 B +15 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.22 KB 0 B 0 B +55 B 25.24 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.16 KB 0 B 0 B 0 B 31.17 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.11 KB 0 B 0 B 0 B 11.13 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.91 KB 0 B 0 B +161 B 33.92 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.61 KB 0 B 0 B +15 B 37.59 KB ⚠️ over by 20 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.15 KB 0 B 0 B +15 B 35.13 KB ⚠️ over by 16 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.18 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.65 KB 0 B 0 B +15 B 40.66 KB ✅ eager (counted): web.js 22.02 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.00 KB 0 B 0 B +15 B 46.02 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.33 KB 0 B 0 B +15 B 47.29 KB ⚠️ over by 36 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, trace.js 8.20 KB, wire.js 0.94 KB
page: hackernews (examples/hackernews client entry) 47.85 KB 0 B 0 B 0 B 47.86 KB ✅ eager (counted): client.js 27.41 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, server.js 1.02 KB, serverForms.js 2.39 KB, trace.js 8.19 KB, wire.js 0.93 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

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

  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 20 B; minified 117,456 B vs 117,441 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 16 B; minified 109,461 B vs 109,446 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 36 B; minified 148,683 B vs 148,668 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@ryansolid
ryansolid marked this pull request as ready for review October 8, 2026 02:29
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37717454713

Coverage remained the same at 76.43%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.3 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 188 untouched benchmarks


Comparing size/hackernews-scenario (2388946) with next (7932287)

Open in CodSpeed

@ryansolid

Copy link
Copy Markdown
Member Author

Closing per the maintainer: the example is a demo, not a size fixture, and a scenario that reaches into examples/hackernews/src/app.tsx couples the harness to it while another pass is reshaping the example. The compiled server-component page scenarios (#3875, in next) are the measured reference for a real SC page; examples/hackernews can be measured ad hoc through the harness when a number is wanted.

The measurements this PR produced stand on their own (see documentation/plans/hackernews-signals-attribution.md on audit/hn-signals-attribution and is-pending-latest-closure.md on audit/is-pending-latest). Branch size/hackernews-scenario kept.

— Claude via Cursor

@ryansolid ryansolid closed this Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants