Repository navigation
isRouting is a default-on context read: every app pays ~3 KB br of signals' lane/verdict read path whether or not it asks (pay-for-use isRouting; derive intent/pendingTarget/headed from inflight) #655
Description
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 —
createRouterContext3,029 ·setupNativeEvents2,398 (includes the form-action path) ·querycache 1,814 ·setupLinkClaims1,486 ·actionglue ≈ 2,360 · scroll restoration 788 ·browserHistory594 · … - retained from
@solidjs/signals: +8,583 — pulled byaction: 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/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). - An app that imports
actionbehaves 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 + 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 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.
Activity
ryansolid commentedon Oct 7, 2026
[-]action is not tree-shakeable: read-only apps bundle the form path and signals' optimistic lanes (~3–4 KB br)[/-][+]Read-only apps retain signals' optimistic-lane machinery via the navigation core (isPending/latest/onSettled); flat default build also inlines the lazy action fallback (#657)[/+]ryansolid commentedon Oct 7, 2026
Closing:
actionis already tree-shakeable in the supported setup. Solid projects are meant to be bundled with thesolidexport condition, which builds the router from source. There, an app that doesn't importactiondrops the form-submit handler, submission state and signals'action; we checked this on2.0.0-next.35. The numbers in this issue come from bundling the prebuiltdist/index.jswithout that condition.The bulk of the size attributed to
actionhere, the signals lane code behindisPending/latest, is used by the router core itself (isRoutingand navigation intent), so it ships regardless ofaction.We tried splitting the prebuilt build in #657, but it would make apps that use
actionslightly bigger to help a setup we don't recommend, so we closed it.[-]Read-only apps retain signals' optimistic-lane machinery via the navigation core (isPending/latest/onSettled); flat default build also inlines the lazy action fallback (#657)[/-][+]isRouting is a default-on context read: every app pays ~3 KB br of signals' lane/verdict read path whether or not it asks (pay-for-use isRouting; derive intent/pendingTarget/headed from inflight)[/+]ryansolid commentedon Oct 7, 2026
ryansolid commentedon Oct 7, 2026
Filed solidjs/solid#3884: a timing inconsistency found while auditing the coordination sites. When navigation B supersedes a parked navigation A, the time at which
isPending(location)/latest(location)readers (isRouting,useLinkState().pending) move to B depends on whether the route's data computation also readslatest(location)(tracked) itself. With that read they move after A's data resolves; without it they move at B's flush. The hook code is the same in both cases.For this audit: on rc.13 it is the tracked read that matters.
query's ownuntrack(getIntent)has no effect. Intest/navigation-pending.spec.tsxonexp/settled-routing, the tracked read comes from the harness's fetch log (getIntent()inside the fetcher). With that call wrapped inuntrack, unmodifiednextalready moves to B at B's flush, as do all thetransitionIntentvariants. So part of the snapshot diff on that branch measures the harness, not the router change.test: characterize initial-load and back-navigation scroll restore ti…
fix: the initial scroll restore is an owned onSettled registered on b…
docs: document the data-pending opt-in, useIsRouting as the routing-s…
ryansolid commentedon Oct 8, 2026
Fixed by #660 on
next, shipping in the next2.0.0-nextrelease.What changed:
replace/scroll, the leave-guard destination, scroll restoration) uses the router's own location writes plusonSettledinstead ofisPending/latest.data-pendingon plain anchors is opt-in:createRouter({ routes, links: pendingLinks }).aria-currentanddata-activeare still automatic, anduseLinkState().pendingworks without the plugin.RouterContextno longer hasisRoutingorpendingTarget. UseuseIsRouting(); the README shows how to read the in-flight destination withisPending/latest.The timing question is tracked in solidjs/solid#3884.
Require solid-js / @solidjs/web 2.0.0-rc.15