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
Activity
Measured, on edited copies of
@solidjs/router@2.0.0-next.35'sdist/through solidjs/solid's size harness (Rolldown 1.2.11, brotli 11; signals/web dists from solidnext@49a8dca84+ solidjs/solid#3838's harness; local macOS, Node 26 — minified exact, brotli ±tens of bytes vs Linux CI). Two scenarios:page: base + router(the hand-written server-component page +createRouter/two routes/preload/useNavigate, noquery) andpage: hackernews—examples/hackernews's real client entry, the issue's canonical read-only app: three routes underquery, fourdynamic()server components, one client component — which solidjs/solid#3879 adds to the harness. The numbers below are the target; #657 had already found the mechanism by the time this landed, so they are phrased against it.The leak is the flat bundle, not the seam
exports["."]is{ solid: "./dist/index.jsx", default: "./dist/index.js" }. A Vite +@solidjs/vite-pluginapp resolves thesolidcondition (the plugin puts it first):index.jsx+ the per-module output, wheredata/events.js'simport("./serverForms.js")is a real lazy chunk andaction.tsis already out of a read-only app's eager graph. The flatdist/index.js(default— Rolldown/esbuild/webpack without the condition, and the harness) is built withinlineDynamicImports: true, which turns that import intoPromise.resolve().then(() => serverForms): one static edge fromsetupNativeEventstosubmitServerForm → createServerFormAction → actionImpl → action$1,installRouterIntegrations, the flight consumer, the sf transport's flight exports. That edge is the whole of what this issue measured. Same page, same build, both artifacts:scenario flat dist/index.jssolidconditionflat overstates by page: base + router 184,378 / 57,001 176,045 / 54,411 +8,333 min / +2,590 br page: hackernews 181,895 / 56,084 176,581 / 54,557 +5,314 / +1,527 The router's marginal on the base page is +38,779 / +12,137 on the flat bundle (what #3838 recorded and this issue quotes) and +30,446 / +9,547 on the
solidcondition. Thesolid-condition lazy chunk (data/serverForms+data/action+ signals'action, anddata/querywhen the app has noquery) is 2,360 br on HN, 3,565 on the base page.action's reach removed (the issue's design), min / brVariant A: the inlined fallback in
handleFormSubmitdeleted; theformHandlerslot stays,actioninstalls into it — the self-installing shape, on each artifact (dist/index.js, ordist/data/events.jsunder thesolidcondition). C = A + the submit listener,delegateEvents(["submit"])and the form claim leave the core too (the form path alone). B = scroll restoration not wired bycreateRouter. All = A + B + C.base + router Δ hackernews Δ flat, stock 184,378 / 57,001 181,895 / 56,084 flat, A (no actionreach)172,651 / 53,388 −11,727 / −3,613 174,372 / 53,819 −7,523 / −2,265 flat, C (+ form path) 172,509 / 53,401 −142 / +13 vs A 174,230 / 53,785 −142 / −34 vs A flat, B alone (no scroll restoration) 183,237 / 56,622 −1,141 / −379 180,754 / 55,740 −1,141 / −344 flat, all 171,368 / 52,984 −13,010 / −4,017 173,089 / 53,386 −8,806 / −2,698 solid, stock (what Vite ships)176,045 / 54,411 176,581 / 54,557 solid, A (fallback dropped)172,591 / 53,343 −3,454 / −1,068 174,385 / 53,793 −2,196 / −764 solid, C172,449 / 53,302 −142 / −41 vs A 174,243 / 53,812 −142 / +19 vs A solid, B alone174,904 / 54,018 −1,141 / −393 175,442 / 54,179 −1,139 / −378 solid, all171,308 / 52,901 −4,737 / −1,510 173,102 / 53,432 −3,479 / −1,125 Where flat-A's −11,727 on the base page goes: router −6,221, signals −2,418, sf −1,327, frames −836, web −477, solid −443. On HN: router −5,661, sf −1,027, signals −820, web/solid/frames −18 together — HN uses
query, sotakeHydrationValue/isResponseEnvelope/the cache stay for it. Thesolid-A row is the part #657 decided not to take (keeping the fallback in the core so server-component forms bound straight to server functions stay enhanced): on the artifact a Vite app ships, dropping it is worth −2.2 K min / −0.76 KB br on HN, −3.5 K / −1.07 KB on the base page — on HN: the fallback body inevents.js(router −830), the sf flight exports the eager chunk keeps for the lazy chunk (subscribeFlightData,decodeResponsePayload,parseServerFunctionActionUrl: sf −1,042), and ≈−320 across signals/web/solid/frames that the lazy chunk's imports kept exported from it.Units — presence in the eager chunk, by declaration, after compress-only minify (dead
isServerbranches gone, names kept)unit flat stock flat A solidstocksolidAsolidallsignals action, routeractionImpl/toAction/handleFormAction/findAction/createServerFormAction/submitServerForm/installRouterIntegrations/markFormBusy/claimBusyForm/setupFlightDataConsumer/applyResponse*present left absent (lazy) absent absent sf subscribeFlightData/decodeResponsePayload/parseServerFunctionActionUrlpresent left present (kept for the lazy chunk) left left verdictValue,dissolveLane,laneRead,applyGuesses(core/lanes.js5,051 B,core/verdict.js2,575 B on HN)present stays present stays stays isPending,latestpresent stays present stays stays onSettledpresent stays present stays stays createEffectpresent stays present stays left (B) createScrollRestoration/withScrollRestorationpresent stays present stays left (B) handleFormSubmitpresent stays (the slot) present stays left (C) useSubmissions,initSubmissions, flash decodingabsent absent absent absent absent So of the issue's "done" list, the lanes do not leave with
action: the router core readsisPending(source)/latest(source)itself —routingPending,isRouting,transitionIntent,pendingNavigationandnavigateFromRoute'sheaded/navigationDepthincreateRouterContext;queryreads them of the location — andcreateIntegrationregistersonSettledon navigation commit.isPending/latestbringverdict.js, which bringslanes.js: 7.6 K minified of the "+8,583 signals" is the navigation core's, ~0.7 K (core/action.js) isaction's. The base page without a router has none of them, so it is the router'sisPending/latestthat retains them — removing that is deriving routing state another way, a separate design question (as #657 also concluded).createEffectis scroll restoration's (its restore effect), notaction's.Secondary candidates
- Scroll restoration: −1,141 min / −344…−393 br on either artifact (
routers/scrollRestoration.js835 + its wiring infactory), and it is what takescreateEffectout. It is on by default whenever nohistoryis passed, so import-driven means a default change — fix(build): keep action out of the flat build's eager graph (#655) #657 deferred it on that ground; the number is above if it is wanted. - The form path alone (listener,
submitdelegation, the claim slot, on top of A): −142 min, brotli within noise. Not worth a seam of its own.
Method
Exact-string edits on copies of
dist/(a missing anchor throws, so each variant is exactly as described), the harness's ownbundle()for the numbers, and a second unminified render of the same graph compress-minified withmangle: falsefor the presence table. Scripts and thesolid-condition wiring are in solidjs/solid#3879's harness (compileofindex.jsx+ per-module output by the measured checkout's compiler;hackernewsAliasinscenarios.js); the variant maker and the measurer are scratch files I can attach if useful.— Claude via Cursor
- Scroll restoration: −1,141 min / −344…−393 br on either artifact (
- changed the title
[-]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)[/+]on 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.- changed the title
[-]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)[/+]on Oct 7, 2026 Reopening — per the maintainer — in its correct form. Measured on
examples/hackernewsthrough solidjs/solid's size harness,@solidjs/router@2.0.0-next.35under thesolidexport condition (what Vite ships), onnext@dafad1db3with the frames tier stack merged.The hold is already the core's.
navigate()is a plain signal write (write(next)oncreateSignal); there is noaction/startTransition/batch inrouting.js,factory.jsxorhistory.js. Nothing in the router keeps the old route on screen — the core's hold model does. The router'sisPending/latestreads observe and report that hold; they do not implement it.Where the router reads them, and what each buys
site reads implements verdict routing.js:691routingPendingmemoisPendingovermatches()/search/hashthe memo half of isRoutingobservation routing.js:702isRoutingroutingPending‖isPending(source)user-facing useIsRoutingand an internal gateown logic, not a re-export routing.js:703transitionIntent→getIntent→query.js:147,components.jsx:13,liveQueryisPending,latest(…)._navigationwhich navigation is asking → querycache policyrouter bookkeeping; derivable from inflightrouting.js:708pendingNavigation→pendingTargetisRouting(),latestin-flight target for data-pendingUI affordance; derivable from inflightrouting.js:801headed = latest(source)latestheading for resolve / leave-guard / redirect no-op bookkeeping routing.js:837redirect depthisPending(source)‖inflight === headedMAX_REDIRECTS hop detection already half router-side factory.jsx:146createIntegrationonSettledhistory.setat the landingessential — and costs 0 B of lanes/verdict claims.js:79,127,scrollRestoration.js:99,useLinkState().pendingrouter.isRouting()data-pending, anchor re-sweep, hold restore until commit, per-link pendingdefault-on affordances HN never calls
isRouting/useIsRouting/useLinkState; the router's ownquery→intent, claims, scroll,pendingTargetandheadedexercise the reads on every page.Why it costs what it costs.
isPending/latestare genuine closure on the lane engine's read side in@solidjs/signals(a verdict reader's pass becomes work of the holder's verdict lane; every later read goes through_laneRead) — ≈ 2.9 K min oflanes.js+ all ofverdict.js— plus ≈ 1.9 K of write-side co-location that a signals-side split would shed (separate item, solidjs/solid side).onSettledreaches none of it. So the router pays ≈ 6.4–6.7 K min (≈ 3 KB br) on every page becauseisRoutingis a context property read by default-on claims/scroll, whether or not the app ever asks.Measured (HN, Δ min / Δ br;
page: base + routerin parentheses)onSettledstubbed: +21 / +68 (+12 / +35) — signals 0 B. Not the cost.- internal reads stubbed,
isRoutingintact: −370 / −86 (−370 / −62) — onlylatest's share leaves. isRoutingas a bare re-export, still a context property: −678 / −153 —lanes.js4,763 +verdict.js1,645 intact.isRoutingproperty removed (mechanism check, not a proposal): −8,872 / −4,431 → 136,451 / 43,349 (−7,291 / −2,331). Of that ≈ −3.1 KB br is code; the rest is the two-chunk graph collapsing to one.
Pay-for-use shape
isRoutingas an opt-in export the app imports (useIsRoutingkeeps working for apps that use it), not a context property every default reads.transitionIntent/pendingTarget/headed/ redirect depth derived frominflightwidened to every write — the router's own bookkeeping, noisPending/latestread.- claims and scroll restoration consume the opt-in read when the app imports it, or drop the probe (scroll's "hold restore until commit" looks redundant under the core's hold model — needs a router test to confirm).
createIntegration'sonSettledstays as is.
Ceiling for an HN-like app: ≈ −3.1 KB br of code, ≈ −4.4 KB as bundled. The hold model is untouched; apps that read
isRoutingpay exactly what they read.Full write-up with the per-function closure and all cuts:
documentation/plans/is-pending-latest-closure.mdon solidjs/solid branchaudit/is-pending-latest@2ee20f21f.— Claude via Cursor
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.Fixed by #660 on
next, shipping in the next2.0.0-nextrelease.What changed:
- Read-only apps (no pending UI) drop about 2.8 KB brotli, and Solid's verdict/optimistic code is no longer in their bundle.
- Routing coordination (query/preload intent, redirect hops, inherited
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.
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.