Correction (measured, 2026-10-07 12:30)
The original claim below — that action is statically reached by the router core — holds for the flat default build (dist/index.js, built with inlineDynamicImports: true): there the lazy server-form fallback is inlined, which turns import("./serverForms.js") into a static edge to actionImpl → signals action, installRouterIntegrations and the flight consumer. That is what solidjs/solid#3838's scenarios measured, and it overstates the router by +8,333 min / +2,590 br versus what a Vite app ships. #657 (opened independently) found the same and splits the flat build.
Under the solid export condition — what a Vite app actually resolves (dist/index.jsx + per-module output) — action is already lazy. Measured on examples/hackernews's real client entry through solidjs/solid's size harness:
|
base + router |
hackernews |
flat default build, stock |
184,378 / 57,001 |
181,895 / 56,084 |
flat, action's reach removed |
−11,727 / −3,613 |
−7,523 / −2,265 |
solid condition, stock (what Vite ships) |
176,045 / 54,411 |
176,581 / 54,557 |
solid, server-form fallback dropped (the import-driven shape; #657 keeps it) |
−3,454 / −1,068 |
−2,196 / −764 |
| scroll restoration alone |
−1,141 / −379 |
−1,141 / −344 |
What stays in every variant, and why: verdictValue, dissolveLane, laneRead, applyGuesses, isPending, latest, onSettled (≈ 7.6 K min of @solidjs/signals) are retained by the navigation core, not by action: isRouting / intent / the pending target read isPending(source) and latest(source), query reads isPending, and createIntegration uses onSettled. So the remaining question for a read-only app is whether the router's navigation-pending primitive needs the optimistic-lane machinery at all — a different design question from action's tree-shakeability, and the one this issue should now be about.
The router's real marginal on a server-component page is +30,446 min / +9,547 br (not +38,779 / +12,137 as first stated).
Original report follows, kept for context.
Summary
A read-only app (no forms, no mutations — examples/hackernews in solidjs/solid is the canonical case) still bundles the router's action machinery: the form-submit interception in setupNativeEvents, the submission state wired in createRouterContext, action's own glue, and — through it — the optimistic-lane machinery from @solidjs/signals. None of it is reachable from the app's code, but the router core references it statically, so tree-shaking cannot remove it.
Proposal: make action self-installing. The core keeps a tiny seam (a submit hook + a submissions slot); import { action } from "@solidjs/router" registers the form path and the lane-backed submission state into that seam at import time. An app that never imports action bundles none of it. No laziness, no async — plain import-driven tree-shaking, the same shape Solid uses for its own opt-in features.
Measurements
Taken with solidjs/solid's size harness (scripts/size, Rolldown bundle, brotli) on @solidjs/router@2.0.0-next.35 against solid-js / @solidjs/web 2.0.0-rc.13 — see solidjs/solid#3838 (page: base + router scenario). Local macOS / Node 26; minified bytes are exact, brotli ±tens of bytes vs Linux CI.
|
minified |
brotli |
| router standalone (solid/web external) |
32,430 |
11,216 |
| router's marginal cost on a server-component page |
+38,779 |
+12,137 |
Of the +38,779 min marginal:
- router's own code: 28,008 —
createRouterContext 3,029 · setupNativeEvents 2,398 (includes the form-action path) · query cache 1,814 · setupLinkClaims 1,486 · action glue ≈ 2,360 · scroll restoration 788 · browserHistory 594 · …
- retained from
@solidjs/signals: +8,583 — pulled by action: the optimistic lanes (verdictValue, dissolveLane, laneRead), isPending / latest, createEffect, onSettled
- retained from the server-function client: +1,188 (
GET / decodeResponse — query's, legitimately)
- retained from
@solidjs/web: +1,137 (takeHydrationValue, registerElementClaim)
- retained from
solid-js: +364
For a read-only app the action-attributable part is roughly the action glue + the form path inside setupNativeEvents + the signals lane machinery — on the order of 11–13 K minified, ≈ 3–4 KB brotli, carried for a feature the app never imports.
For scale: the router's standalone 11.2 KB br is larger than the entire eager frames (server-components) client after this week's size pass (10.9 KB), and about two-thirds of the whole solid-js + @solidjs/web hydrating runtime.
What "done" looks like
- An app importing
Router / routes / A / useNavigate / query / preload but not action bundles no form-submit interception, no submission state, and none of signals' lane machinery (verdictValue, dissolveLane, laneRead, isPending, latest absent from the bundle unless the app uses them itself).
- An app that imports
action behaves exactly as today (form interception, submissions, optimistic state) — the registration is at import time, synchronous, no behaviour change.
- Measurable: a variant of solidjs/solid's
page: base + router scenario (or an examples/hackernews scenario) with and without import { action }; expected delta ≈ 3–4 KB brotli on the read-only variant.
Secondary candidates (same shape, smaller)
Scroll restoration (788 min) and the form path in setupNativeEvents could follow the same import-driven pattern. query / preload is the read side and should stay in the core.
Method, for reproduction
The attribution came from solidjs/solid's scripts/size/attribute.mjs over the harness bundle (per-function minified bytes; module-level reachability via the bundler's graph). Removing action's reach is measurable on an edited copy of the router's dist/ through the same bundler before any source change — happy to share the scripts.
Correction (measured, 2026-10-07 12:30)
The original claim below — that
actionis statically reached by the router core — holds for the flatdefaultbuild (dist/index.js, built withinlineDynamicImports: true): there the lazy server-form fallback is inlined, which turnsimport("./serverForms.js")into a static edge toactionImpl → signals action,installRouterIntegrationsand the flight consumer. That is what solidjs/solid#3838's scenarios measured, and it overstates the router by +8,333 min / +2,590 br versus what a Vite app ships. #657 (opened independently) found the same and splits the flat build.Under the
solidexport condition — what a Vite app actually resolves (dist/index.jsx+ per-module output) —actionis already lazy. Measured onexamples/hackernews's real client entry through solidjs/solid's size harness:defaultbuild, stockaction's reach removedsolidcondition, stock (what Vite ships)solid, server-form fallback dropped (the import-driven shape; #657 keeps it)What stays in every variant, and why:
verdictValue,dissolveLane,laneRead,applyGuesses,isPending,latest,onSettled(≈ 7.6 K min of@solidjs/signals) are retained by the navigation core, not byaction:isRouting/ intent / the pending target readisPending(source)andlatest(source),queryreadsisPending, andcreateIntegrationusesonSettled. So the remaining question for a read-only app is whether the router's navigation-pending primitive needs the optimistic-lane machinery at all — a different design question fromaction's tree-shakeability, and the one this issue should now be about.The router's real marginal on a server-component page is +30,446 min / +9,547 br (not +38,779 / +12,137 as first stated).
Original report follows, kept for context.
Summary
A read-only app (no forms, no mutations —
examples/hackernewsin solidjs/solid is the canonical case) still bundles the router'sactionmachinery: the form-submit interception insetupNativeEvents, the submission state wired increateRouterContext,action's own glue, and — through it — the optimistic-lane machinery from@solidjs/signals. None of it is reachable from the app's code, but the router core references it statically, so tree-shaking cannot remove it.Proposal: make
actionself-installing. The core keeps a tiny seam (a submit hook + a submissions slot);import { action } from "@solidjs/router"registers the form path and the lane-backed submission state into that seam at import time. An app that never importsactionbundles none of it. No laziness, no async — plain import-driven tree-shaking, the same shape Solid uses for its own opt-in features.Measurements
Taken with solidjs/solid's size harness (
scripts/size, Rolldown bundle, brotli) on@solidjs/router@2.0.0-next.35againstsolid-js/@solidjs/web2.0.0-rc.13— see solidjs/solid#3838 (page: base + routerscenario). Local macOS / Node 26; minified bytes are exact, brotli ±tens of bytes vs Linux CI.Of the +38,779 min marginal:
createRouterContext3,029 ·setupNativeEvents2,398 (includes the form-action path) ·querycache 1,814 ·setupLinkClaims1,486 ·actionglue ≈ 2,360 · scroll restoration 788 ·browserHistory594 · …@solidjs/signals: +8,583 — pulled byaction: the optimistic lanes (verdictValue,dissolveLane,laneRead),isPending/latest,createEffect,onSettledGET/decodeResponse—query's, legitimately)@solidjs/web: +1,137 (takeHydrationValue,registerElementClaim)solid-js: +364For a read-only app the
action-attributable part is roughly theactionglue + the form path insidesetupNativeEvents+ the signals lane machinery — on the order of 11–13 K minified, ≈ 3–4 KB brotli, carried for a feature the app never imports.For scale: the router's standalone 11.2 KB br is larger than the entire eager frames (server-components) client after this week's size pass (10.9 KB), and about two-thirds of the whole
solid-js+@solidjs/webhydrating runtime.What "done" looks like
Router/ routes /A/useNavigate/query/preloadbut notactionbundles no form-submit interception, no submission state, and none of signals' lane machinery (verdictValue,dissolveLane,laneRead,isPending,latestabsent from the bundle unless the app uses them itself).actionbehaves exactly as today (form interception, submissions, optimistic state) — the registration is at import time, synchronous, no behaviour change.page: base + routerscenario (or anexamples/hackernewsscenario) with and withoutimport { action }; expected delta ≈ 3–4 KB brotli on the read-only variant.Secondary candidates (same shape, smaller)
Scroll restoration (788 min) and the form path in
setupNativeEventscould follow the same import-driven pattern.query/preloadis the read side and should stay in the core.Method, for reproduction
The attribution came from solidjs/solid's
scripts/size/attribute.mjsover the harness bundle (per-function minified bytes; module-level reachability via the bundler's graph). Removingaction's reach is measurable on an edited copy of the router'sdist/through the same bundler before any source change — happy to share the scripts.