Skip to content

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

@ryansolid

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.

Activity

  1. ryansolid commented on Oct 7, 2026

    @ryansolid
    MemberAuthor

    Measured, on edited copies of @solidjs/router@2.0.0-next.35's dist/ through solidjs/solid's size harness (Rolldown 1.2.11, brotli 11; signals/web dists from solid next @ 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, no query) and page: hackernews — examples/hackernews's real client entry, the issue's canonical read-only app: three routes under query, four dynamic() 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-plugin app resolves the solid condition (the plugin puts it first): index.jsx + the per-module output, where data/events.js's import("./serverForms.js") is a real lazy chunk and action.ts is already out of a read-only app's eager graph. The flat dist/index.js (default — Rolldown/esbuild/webpack without the condition, and the harness) is built with inlineDynamicImports: true, which turns that import into Promise.resolve().then(() => serverForms): one static edge from setupNativeEvents to submitServerForm → 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.js solid condition flat 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 solid condition. The solid-condition lazy chunk (data/serverForms + data/action + signals' action, and data/query when the app has no query) is 2,360 br on HN, 3,565 on the base page.

    action's reach removed (the issue's design), min / br

    Variant A: the inlined fallback in handleFormSubmit deleted; the formHandler slot stays, action installs into it — the self-installing shape, on each artifact (dist/index.js, or dist/data/events.js under the solid condition). C = A + the submit listener, delegateEvents(["submit"]) and the form claim leave the core too (the form path alone). B = scroll restoration not wired by createRouter. All = A + B + C.

    base + router Δ hackernews Δ
    flat, stock 184,378 / 57,001 181,895 / 56,084
    flat, A (no action reach) 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, C 172,449 / 53,302 −142 / −41 vs A 174,243 / 53,812 −142 / +19 vs A
    solid, B alone 174,904 / 54,018 −1,141 / −393 175,442 / 54,179 −1,139 / −378
    solid, all 171,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, so takeHydrationValue/isResponseEnvelope/the cache stay for it. The solid-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 in events.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 isServer branches gone, names kept)

    unit flat stock flat A solid stock solid A solid all
    signals action, router actionImpl / toAction / handleFormAction / findAction / createServerFormAction / submitServerForm / installRouterIntegrations / markFormBusy / claimBusyForm / setupFlightDataConsumer / applyResponse* present left absent (lazy) absent absent
    sf subscribeFlightData / decodeResponsePayload / parseServerFunctionActionUrl present left present (kept for the lazy chunk) left left
    verdictValue, dissolveLane, laneRead, applyGuesses (core/lanes.js 5,051 B, core/verdict.js 2,575 B on HN) present stays present stays stays
    isPending, latest present stays present stays stays
    onSettled present stays present stays stays
    createEffect present stays present stays left (B)
    createScrollRestoration / withScrollRestoration present stays present stays left (B)
    handleFormSubmit present stays (the slot) present stays left (C)
    useSubmissions, initSubmissions, flash decoding absent absent absent absent absent

    So of the issue's "done" list, the lanes do not leave with action: the router core reads isPending(source) / latest(source) itself — routingPending, isRouting, transitionIntent, pendingNavigation and navigateFromRoute's headed/navigationDepth in createRouterContext; query reads them of the location — and createIntegration registers onSettled on navigation commit. isPending/latest bring verdict.js, which brings lanes.js: 7.6 K minified of the "+8,583 signals" is the navigation core's, ~0.7 K (core/action.js) is action's. The base page without a router has none of them, so it is the router's isPending/latest that retains them — removing that is deriving routing state another way, a separate design question (as #657 also concluded). createEffect is scroll restoration's (its restore effect), not action's.

    Secondary candidates

    • Scroll restoration: −1,141 min / −344…−393 br on either artifact (routers/scrollRestoration.js 835 + its wiring in factory), and it is what takes createEffect out. It is on by default whenever no history is 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, submit delegation, 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 own bundle() for the numbers, and a second unminified render of the same graph compress-minified with mangle: false for the presence table. Scripts and the solid-condition wiring are in solidjs/solid#3879's harness (compile of index.jsx + per-module output by the measured checkout's compiler; hackernewsAlias in scenarios.js); the variant maker and the measurer are scratch files I can attach if useful.

    — Claude via Cursor

  2. 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
  3. ryansolid commented on Oct 7, 2026

    @ryansolid
    MemberAuthor

    Closing: action is already tree-shakeable in the supported setup. Solid projects are meant to be bundled with the solid export condition, which builds the router from source. There, an app that doesn't import action drops the form-submit handler, submission state and signals' action; we checked this on 2.0.0-next.35. The numbers in this issue come from bundling the prebuilt dist/index.js without that condition.

    The bulk of the size attributed to action here, the signals lane code behind isPending/latest, is used by the router core itself (isRouting and navigation intent), so it ships regardless of action.

    We tried splitting the prebuilt build in #657, but it would make apps that use action slightly bigger to help a setup we don't recommend, so we closed it.

  4. 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
  5. ryansolid commented on Oct 7, 2026

    @ryansolid
    MemberAuthor

    Reopening — per the maintainer — in its correct form. Measured on examples/hackernews through solidjs/solid's size harness, @solidjs/router@2.0.0-next.35 under the solid export condition (what Vite ships), on next @ dafad1db3 with the frames tier stack merged.

    The hold is already the core's. navigate() is a plain signal write (write(next) on createSignal); there is no action/startTransition/batch in routing.js, factory.jsx or history.js. Nothing in the router keeps the old route on screen — the core's hold model does. The router's isPending / latest reads 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:691 routingPending memo isPending over matches()/search/hash the memo half of isRouting observation
    routing.js:702 isRouting routingPending ‖ isPending(source) user-facing useIsRouting and an internal gate own logic, not a re-export
    routing.js:703 transitionIntent → getIntent → query.js:147, components.jsx:13, liveQuery isPending, latest(…)._navigation which navigation is asking → query cache policy router bookkeeping; derivable from inflight
    routing.js:708 pendingNavigation → pendingTarget isRouting(), latest in-flight target for data-pending UI affordance; derivable from inflight
    routing.js:801 headed = latest(source) latest heading for resolve / leave-guard / redirect no-op bookkeeping
    routing.js:837 redirect depth isPending(source) ‖ inflight === headed MAX_REDIRECTS hop detection already half router-side
    factory.jsx:146 createIntegration onSettled history.set at the landing essential — and costs 0 B of lanes/verdict
    claims.js:79,127, scrollRestoration.js:99, useLinkState().pending router.isRouting() data-pending, anchor re-sweep, hold restore until commit, per-link pending default-on affordances

    HN never calls isRouting / useIsRouting / useLinkState; the router's own query → intent, claims, scroll, pendingTarget and headed exercise the reads on every page.

    Why it costs what it costs. isPending / latest are 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 of lanes.js + all of verdict.js — plus ≈ 1.9 K of write-side co-location that a signals-side split would shed (separate item, solidjs/solid side). onSettled reaches none of it. So the router pays ≈ 6.4–6.7 K min (≈ 3 KB br) on every page because isRouting is a context property read by default-on claims/scroll, whether or not the app ever asks.

    Measured (HN, Δ min / Δ br; page: base + router in parentheses)

    • onSettled stubbed: +21 / +68 (+12 / +35) — signals 0 B. Not the cost.
    • internal reads stubbed, isRouting intact: −370 / −86 (−370 / −62) — only latest's share leaves.
    • isRouting as a bare re-export, still a context property: −678 / −153 — lanes.js 4,763 + verdict.js 1,645 intact.
    • isRouting property 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

    • isRouting as an opt-in export the app imports (useIsRouting keeps working for apps that use it), not a context property every default reads.
    • transitionIntent / pendingTarget / headed / redirect depth derived from inflight widened to every write — the router's own bookkeeping, no isPending / latest read.
    • 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's onSettled stays 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 isRouting pay exactly what they read.

    Full write-up with the per-function closure and all cuts: documentation/plans/is-pending-latest-closure.md on solidjs/solid branch audit/is-pending-latest @ 2ee20f21f.

    — Claude via Cursor

  6. ryansolid commented on Oct 7, 2026

    @ryansolid
    MemberAuthor

    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 reads latest(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 own untrack(getIntent) has no effect. In test/navigation-pending.spec.tsx on exp/settled-routing, the tracked read comes from the harness's fetch log (getIntent() inside the fetcher). With that call wrapped in untrack, unmodified next already moves to B at B's flush, as do all the transitionIntent variants. So part of the snapshot diff on that branch measures the harness, not the router change.

  7. ryansolid commented on Oct 8, 2026

    @ryansolid
    MemberAuthor

    Fixed by #660 on next, shipping in the next 2.0.0-next release.

    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 plus onSettled instead of isPending/latest.
    • data-pending on plain anchors is opt-in: createRouter({ routes, links: pendingLinks }). aria-current and data-active are still automatic, and useLinkState().pending works without the plugin.
    • RouterContext no longer has isRouting or pendingTarget. Use useIsRouting(); the README shows how to read the in-flight destination with isPending/latest.

    The timing question is tracked in solidjs/solid#3884.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions