Skip to content

Latest commit

 

History

History
807 lines (762 loc) · 51 KB

File metadata and controls

807 lines (762 loc) · 51 KB

solid-rs — Rust framework for frontend apps, compiled to JavaScript

Vision

A Rust framework for building frontend applications. Developers write Rust against a Solid-like API. The solid-rs CLI parses the Rust source (syn) and transpiles it to plain JavaScript that runs on the SolidJS 2.0 runtime (solid-js@2.0.0-rc.1, pinned). No WASM for the mainline: the browser runs generated JS. WASM is an optional side-load escape hatch for heavy compute modules.

IMPORTANT: the user's Rust is source, not something compiled by rustc for the browser. The framework's codegen reads the Rust AST and emits JavaScript. cargo check is only used to validate that the user's Rust is well-formed Rust (nice-to-have, not required).

Architecture (cargo workspace)

solid-rs/
├── Cargo.toml              # workspace
├── crates/
│   ├── solid-rs-core/      # IR types + Rust API types + syn parsing
│   ├── solid-rs-codegen/   # IR -> JavaScript emitter
│   └── solid-rs/       # `solid-rs` binary (clap): init, build, dev, preview, wasm
├── examples/
│   ├── comparison/        # feature lab: one section per group of Solid 2.0 APIs
│   ├── ssr/                # SSR lab: server_state, async memo, lazy!, request event
│   ├── tasks/              # "Momentum" — a client-side task manager (store, fetch, Portal, lazy!)
│   └── blog/               # "Dispatch" — a server-rendered blog (server_state, async memo, lazy!)
└── docs/
    └── api.md              # the Rust API reference

crates/solid-rs-core

  • IR types (serde-serializable): App, Component, ComponentBody, JsNode (Element / Text / Interpolation / Show / For / Repeat / Switch / ComponentCall / Fragment), Attr, Expr (a conservative AST of the Rust expressions we support in emitted JS), SignalDecl, EffectDecl, MemoDecl, LetDecl/LetTupleDecl, ConstDecl, Closure.
  • The Rust API surface (what users write):
    use solid_rs::prelude::*;
    
    #[component]
    fn Counter() -> Jsx {
        let (count, set_count) = create_signal(0);
        let double = create_memo(move || count() * 2);
        create_effect(move || println!("count is {}", count()));
        html! {
            <div class="counter">
                <p>Count: {count()}</p>
                <p>Double: {double()}</p>
                <button onclick=move || set_count(count() + 1)>+</button>
                {if count() > 5 { <span>big</span> } else { <span>small</span> }}
                {for item in items() { <li>{item}</li> }}
            </div>
        }
    }
  • Parsing: syn-based. The html! macro body is parsed from token stream into IR. #[component] is recognized by the parser (we do NOT ship a proc-macro crate; the attribute is syntax the codegen understands).
  • Expr AST: literals, paths, calls, binary/unary ops, indexing, method calls, field access, move || ... closures, let-bindings inside closures/bodies, if/else, match (limited: 2-3 arm simple matches), string interpolation in println, and panic!/todo!/unimplemented!/unreachable!/throw! (see Errors). Anything unsupported => clear compile error with span info.

crates/solid-rs-codegen

  • Solid 2.0.0-rc.1 architecture (verified from the real .d.ts, 2026-08-20):
    • solid-js@2.0.0-rc.1 core NO LONGER models the DOM. It exports: createSignal, createMemo, createEffect, createRenderEffect, createStore, createResource, createRoot, createContext/useContext, children, For, Show, Switch, Match, Repeat, Reveal, Loading, Errored, createComponent, lazy, dynamic, clientOnly.
    • The DOM renderer is @solidjs/web@2.0.0-rc.1 (peer of solid-js 2.0). It exports: render(code: () => JSX.Element, element, init?, options?), hydrate, Portal, Dynamic, dynamic, clientOnly, mergeProps, isServer, isDev, plus the low-level DOM ops: template(html), scope, effect, memo, untrack, insert, createComponent, spread, assign, setAttribute, claimElement, claimElementTree, delegateEvents, registerDelegatedRoot.
    • JSX is transformed by the Solid 2.0 JSX transform, which emits calls to those low-level ops. We use the official oxc compiler @dom-expressions/compiler (the same one @solidjs/vite-plugin ships by default) — no Babel in the pipeline.
  • Emission strategy (decided): the Rust→JS transpiler emits JSX source code (a .jsx module per component + main.jsx entry calling render(() => <App/>, document.getElementById("root"))), which is then compiled by the oxc Solid compiler and bundled with esbuild. This is how every Solid 2.0 app works; hand-emitting raw template()/insert() calls would be fragile.
    • Build pipeline (verified): node build.mjs → (1) @dom-expressions/compiler transform() rewrites each dist/js/*.jsx into a per-target directory (sibling imports reference ./Name.jsx explicitly, so the transformed code must stay at the same path), (2) esbuild (JS API) bundles the entry → dist/main.js. The compiler's moduleName: "@solidjs/web" option makes it emit @solidjs/web imports directly, so no esbuild alias is needed (the old @oxc-solid-js/compiler hardcoded solid-js/web, which is why an alias was once required). The transpiler's job: Rust AST → JSX + plain JS expressions. The runtime registry (crates/solid-rs-core/src/runtime.rs) lists every Solid export callable from Rust; a snake_case name is camel-cased and auto-imported from its module (create_signalcreateSignal from solid-js, merge_propsmergeProps from @solidjs/web). APIs removed in the 2.0 RC (create_resource, create_computed, batch, on, split_props) are hard errors that name the replacement. for x in xs { <li>{x}</li> }<For each={xs}>{x => <li>{x}</li>}</For> (props: each, fallback, keyed, children). if c { A } else { B }<Show when={c} fallback={B}>{A}</Show> (props: when, keyed, fallback, children). Event handlers: onclick=move || expronClick={() => expr} (camelCase, delegated by the renderer).
  • Output: one JSX module per component file + main.jsx entry. Deterministic, readable output (this is a dev tool; pretty-print).
  • Code splittinglazy!(Comp) / client_only!(Comp) in a module-level const. They name a component, not a path: every component is emitted as its own Comp.jsx, so the specifier is derived. Desugars to lazy(() => import("./Comp.jsx"), { export: "Comp" }, "./Comp.jsx") — the third argument is Solid's moduleUrl, normally injected by a bundler plugin, and doubles as the SSR asset-manifest key. Expr::DynImport is a walker leaf, which is what keeps the target out of the caller's static imports — a static import would fold the chunk back into the main bundle.
    • The const's name MUST differ from the component's (enforced by the existing both-a-component-and-a-const check), for exactly that reason.
    • lazy! works under mode = "ssr" once built with the official @dom-expressions/compiler (its dom/ssr targets allocate identical hydration ids). It requires a <Loading> boundary. The old @oxc-solid-js/compiler allocated different ids (an eager ssrRunInScope per interpolation consumed extra root-level ids), which is why lazy! was once a compile error under SSR pointing at client_only! — see BUGS-2.md.
    • The old @oxc-solid-js/compiler also dropped every prop of a component that is a direct JSX child of another component inside a host element, in ssr mode only. The official compiler does not, so the former workaround (wrapping such children in {…}) was removed — see BUGS-1.md.

crates/solid-rs-ssr

  • Executes the SSR bundle in-process via embedded QuickJS (rquickjs), so serving a solid-rs app needs no Node.js. Renderer::load("dist") then .respond(template, &HttpRequest) -> HttpResponse; a fresh realm per render, so #[server_state] is recomputed per request and nothing leaks between them. .rpc(&HttpRequest) dispatches one #[server] call through the same pool and the same realm discipline.
  • QuickJS ships no Fetch API. src/web.js supplies Request, Response, Headers, URL, URLSearchParams, FormData, Blob/File, ReadableStream/WritableStream/TransformStream, TextEncoder/ TextDecoder and btoa/atob, evaluated before the bundle. Every class sits behind a has(name) guard, so the same file is inert under Node — which is what lets src/web.test.js run under both engines and check its assertions against the real platform. That dual-engine run caught four places where the shim had invented semantics (a body on a null-body status must throw, not drop; GET with a body likewise; get("set-cookie") joins like every other header; Response.json must set its content type before construction).
  • The request event needs no AsyncLocalStorage. Solid reads it from globalThis[Symbol.for("solid.RequestContext")].getStore(). One realm serves exactly one request, so the emitted entry parks a plain value there and never restores it: for one request per realm that is not an approximation of async context, it is equivalent — and strictly better than a run/finally polyfill, which would clear the event at the first await.
  • httpStatus/httpHeader are retracted on owner disposal. They write to event.response.headers immediately but register an onCleanup that undoes the write, so awaiting renderToStream into a string discards the whole response head — cookies readable, nothing writable. The entry hands the stream to createSSRResponse and splices the template around the result.
  • Precompiled: solid-rs build writes dist/server.qjs (QuickJS bytecode) next to server.js, so the server never parses ~120 KB of generated JS at request time. Measured 4.15 ms -> 0.92 ms per render, i.e. parsing was ~78% of the cost. server.qjs is framed with a magic + FNV-1a hash of server.js; a mismatch (or any load failure — QuickJS version-checks its own payload) falls back to source, so the bytecode is an optimisation, never a correctness dependency.
  • Realm pool: QuickJS runtimes are single-threaded, so RendererPool is a set of threads, each holding a RenderWorker (one persistent Runtime, a fresh Context per render + run_gc). This amortises runtime construction, caps concurrent renders (and therefore peak JS heap), and scales: 1.0k renders/s at 1 thread -> 7.6k/s at 8.
  • Provides the globals a bare realm lacks (setTimeout/console); timers are queued and drained deterministically alongside the microtask queue, which is what lets a render complete synchronously.
  • examples/bench.rs reproduces both numbers.
  • WHY NOT emit HTML from the Rust IR directly: hydration keys are allocated from the runtime reactive owner tree (sharedConfig.getNextContextId() -> getNextChildId(owner)), not from markup shape — e.g. sibling <For> rows get _hk=600/610/620, not 1/2/3. Reproducing that means reimplementing Solid's reactive graph; any drift and the client refuses to claim the markup.

crates/solid-rs

  • solid-rs install — the application's own npm packages, declared in solid-rs.toml and installed into the project's node_modules:

    [dependencies]           # imported by the app; ends up in the bundle
    "chart.js" = "^4.5.0"
    [dev-dependencies]       # build/test only: a CSS compiler, a headless DOM
    "@tailwindcss/cli" = "^4.0.0"

    package.json is generated from those tables — npm has no other input format — and is not a second place to edit; it and its lockfile are gitignored. solid-rs build never installs: it checks a stamp in node_modules/ against a hash of the tables and fails with "run solid-rs install" if they disagree, because a build that reaches the network unasked is a build that is not reproducible. The project's node_modules goes first in esbuild's nodePaths, ahead of the toolchain's.

    Reached from Rust with an extern block:

    #[js("chart.js/auto")]
    extern "C" {
        fn Chart(el: JsValue, cfg: JsValue) -> JsValue;   // import { Chart }
        #[js(default)] fn Confetti();                     // import Confetti
        #[js(namespace)] fn all();                        // import * as all
    }
    #[js("some-polyfill/register")]
    extern "C" {}                                         // import for effect
  • solid-rs install also writes bindings/<pkg>.rs: a generated extern block per export subpath, produced by importing each installed package and reading its real exports. Reference material rather than compiled source (solid-rs parses src/ only), because binding a package's whole surface would collide with names the app and Solid already own. Names that cannot be imported — a Solid API solid-rs auto-imports, a Rust keyword — stay in the file as comments saying why. Wildcard subpath exports ("./*") are expanded against the filesystem, since a package can publish nothing else; and an export that is a namespace of components has its members listed, because Progress.Track exists while Progress.Root does not and that is not guessable. Regenerate with solid-rs bindings.

  • Component tags may be dotted (<Tabs.List>): a member expression on the imported value, so only the head is imported. Elements may not — no HTML element name contains a dot, so reading one there would turn a typo into a silent member access.

  • on* means different things on an element and on a component. On an element it is a DOM event handler and must be a zero-arg closure. On a component it is an ordinary prop, and the component decides what it is called with (Kobalte's onChange receives the new state). Applying the element rule to components rejects every callback prop in the ecosystem.

  • on:click is the non-delegated listener, expanded at emit. Solid's spelling for a listener that is not delegated: the name after the colon is taken verbatim, so on:MyEvent reaches a custom event that delegation — which matches a fixed set case-insensitively — can never see. The JSX transform cannot be trusted with it: it does not strip the namespace, so it would listen for an event named :click, and it lowercases the name on the way. solid-rs therefore expands it itself, to the runtime's own addEvent(el, name, handler) with delegation off, bound through the element's ref — the only hook the transform offers onto the element. A user ref written alongside is folded in and called first. Refs do not run under SSR, which is right: a native listener has nothing to bind to there, exactly as a delegated one has nothing to delegate to. On a component, on:click is just a prop of that name — the component decides what to do with it. Every other namespaced attribute (class:, style:, prop:, attr:, use:, bool:) is passed through verbatim.

  • A bare event closure has no delimiters, so where it ends and the next attribute begins is settled by taking the longest prefix that parses as an expression — not by reading to the tag's >, which swallowed every attribute written after a handler.

    wasm-bindgen's shape, and extern "C" because it is the only foreign-block form Rust's grammar accepts — but there is no ABI behind it: solid-rs transpiles, so an imported function is just a function and nothing is type-checked against the package. What the declaration buys is that the import exists; an undeclared name is emitted verbatim and fails at run time. Names are app-global (an import is re-emitted into every module that uses it, and only those), so a name may not be claimed by two packages, nor collide with a component, a const, or a registry name that solid-rs already auto-imports. An empty block binds nothing, so its side-effect import goes into the entry module, first.

  • solid-rs init <dir> — scaffold a project: src/main.rs and solid-rs.toml, and nothing else. No package.json, no index.html, no build.mjs, no node_modules. Those are all supplied by the compiler:

    • The JS toolchain lives in ~/.cache/solid-rs/toolchain/<version>-<pins>/ (see crates/solid-rs/src/toolchain.rs), provisioned by npm install on first build and keyed by the pin set, so changing solid-rs versions makes a new directory rather than mutating a shared one. The four packages (solid-js, @solidjs/web, @dom-expressions/compiler, esbuild) are a property of the compiler — a given solid-rs emits JSX for exactly one Solid release — so a per-project manifest was only ever something to drift out of step.
    • build.mjs is a &str const in project.rs, written into the cache beside the packages and run from there. It is rewritten on every build so a recompiled solid-rs cannot run a stale copy.
    • index.html is generated, and a project-supplied one is used verbatim instead. The generated document links /styles.css when the project has a stylesheet, and its <title> is a placeholder: the app sets the real one with use_head. Under SSR the render produces a fragment, so Solid finds no </head> to splice head tags into and hands them to the onHead callback — the server entry passes one and splices them itself, dropping the generated <title> when the app supplied one (the browser keeps the first <title>, so leaving both in would let the placeholder win).
    • css/styles.css and data/ / public/ are opt-in by existing. A stylesheet is copied to dist/styles.css verbatim, or — if its first directive is @import "tailwindcss" — compiled by @tailwindcss/cli run from the project's node_modules via npx --no-install. Nothing about CSS is pinned in the toolchain: unlike the Solid packages, a stylesheet compiler is the application's choice, so it belongs in the application's own package.json (see examples/comparison). --no-install is deliberate — a missing dependency is reported, never silently fetched at a version the project did not choose. Node.js (>= 18) and npm are still required to build. Nothing is required to serve: solid-rs preview runs the SSR bundle in embedded QuickJS.
    • esbuild resolves bare imports relative to the importing file, which lives in the project's dist/ — a directory with no node_modules above it — so both esbuild invocations are given nodePaths: [<cache>/node_modules].
  • Bundling (VERIFIED working pipeline, do NOT use esbuild --jsx=automatic): @solidjs/web@2.0.0-rc.1 exports NO jsx/jsxs factory (108 exports, none is a JSX factory), so esbuild's automatic JSX runtime FAILS with "No matching export for import jsxs". The Solid 2.0 JSX transform is a babel plugin: babel-preset-solid@2.0.0-rc.1 (the next tag; 1.9.x emits imports from the removed solid-js/web subpath — must pin 2.0.0-rc.1). Verified pipeline (tested 2026-08-20, 74KB bundle with full runtime):

    1. codegen writes dist/js/*.jsx
    2. node build.mjs: for each dist/js/.jsx run @babel/core transformSync(code, { presets: [["babel-preset-solid", { generate: "dom" }]], babelrc: false, configFile: false }) -> dist/js/.js (emits import { template, insert, createComponent, delegateEvents } from "@solidjs/web") 2b. the client build runs with splitting: true + metafile: true + outdir (so entryNames: "[name]" pins dist/main.js, chunkNames: "chunk-[hash]" names the rest). The metafile is converted to a Vite-shaped asset manifest keyed by module specifier (./Comp.jsx), written to dist/manifest.json and baked into dist/server/__manifest.js — the SSR realm has no filesystem, and lazy() throws server-side without a manifest. The server build stays unsplit on purpose: QuickJS has no module loader, and esbuild rewrites dynamic import() to Promise.resolve().then(...) when splitting is off.
    3. esbuild JS API (NOT the CLI — the CLI wrapper misbehaves when spawned from node with stdio inherit): esbuild.build({ entryPoints: [dist/js/main.js], bundle: true, format: "esm", outfile: dist/main.js })
    4. copy index.html -> dist/ with script src rewritten to /main.js build.mjs is owned by the CLI (the BUILD_MJS const in project.rs) and run from the toolchain cache; it is not a file in the user's project.
  • solid-rs build [--mode render|hydrate|ssr] [--out <dir>] — parse src/.rs -> emit dist/js/.jsx -> run node build.mjs (node must be installed; clear error if missing) -> dist/main.js + dist/index.html.

  • Mount modes ([project] mode in solid-rs.toml, or --mode):

    • render (default): client-only; render(() => <App/>, root).
    • hydrate: client compiled with hydratable: true, entry calls hydrate, and the _$HY bootstrap (from generateHydrationScript) is injected into index.html. Without both of those hydration silently re-renders / throws.
    • ssr: the above plus a second compile with generate: "ssr", bundled to dist/server.js for a bare JS engine and executed by solid-rs itself (see crates/solid-rs-ssr) to render index.html. build.mjs keeps the unrendered document as dist/index.template.html.
      • renderPage awaits renderToStream, not renderToString: awaiting the stream resolves with the fully settled HTML, so every <Loading> boundary (a lazy! component, an async memo) is resolved before the page is served. renderToString is synchronous and serializes the fallback. Verified working inside QuickJS via the thenable form; the chunk-at-a-time pipe/readable forms are not wired to the HTTP response yet.
      • The stream resolves rather than rejects on a render error, handing back whatever HTML it produced, so renderPage installs onError and rethrows — otherwise a broken render ships a silently truncated page.
  • Backend-generated initial state: #[server_state] [async] fn name() -> T compiles to a shared dist/js/__state.jsx. The server bundle awaits and evaluates the body, server.jsx serializes the result into the page as window.__SOLID_RS_STATE__, and components call name() to read it. The body never reaches the browser. Requires mode = "ssr".

    • VERIFIED skew: @solidjs/web@2.0.0-rc.1 does not export ssrRunInScope, which the oxc compiler emits in SSR mode. build.mjs shims it as scope(fn)()scope IS ssrScope.
  • Async in a component body: a #[component] is emitted as a plain function, so a bare .await in its body is a compile error (it would be a JS syntax error in generated code). Async lives in a closure instead, which is Solid 2.0's own shape: create_memo(async move || fetch().await) is an async memo whose read throws NotReadyError until it settles, suspending the nearest <Loading> boundary. create_signal(async move || …) works the same way. async move || closures keep their async through parse → IR → emit (Closure.is_async, MemoDecl.is_asynccreate_memo takes a parser path that decomposes the closure, so it carries the flag separately).

  • Server functions (RPC): #[server] async fn name(args…) -> T. The only construct emitted differently per target, and the reason the build has a client/server variant split at all:

    • Codegen writes a pair — __server.jsx (real bodies, each passed to registerServerReference and wrapped by createServerReference) and __server.client.jsx (fetch-backed references, no bodies). build.mjs treats X.client.jsx as a replacement for X.jsx: the browser build compiles it under the base name, so importers name one file and never learn which half they got. Bodies stay out of the client bundle because the client build is never handed them — not because a bundler shook them out.
    • Consts reachable only from a #[server] body move into the server half. Excluding bodies is not enough on its own: a body reading const API_KEY would otherwise leave the key in a shared consts module that both bundles import. The rule is deliberately conservative — anything a component, #[server_state], #[action] or another const also names stays shared, because hiding a const the browser needs is a missing binding at run time.
    • Dispatch ids are <module>/<name>. Never #: the runtime treats it as a reserved discriminator and truncates there (reference.split("#")[0]), so mod#name would dispatch as mod and collapse every function in a module onto one id. Names are therefore app-global, and validation enforces it.
    • async is required. The runtime keeps a synchronous body synchronous on the in-process path while the client always gets a promise, so the same source would resolve differently per build. Emitting async function in both makes the halves agree.
    • Two dispatch paths, one body: during the document render the caller is already on the server, so it is an in-process call under a derived request event; from the browser it is POST /_server, handled by handleServerFunctionRequest in the same Rust process.
    • The encoding is chosen per value, by the runtime: plain JSON when the value is JSON-safe, seroval's framed format (;0x…;{…}) otherwise — so a Date in a return value arrives as a Date. The host passes bodies through byte for byte and must not interpret them.
    • A thrown redirect answers 200 with a Location header, deliberately, for the client runtime to act on. A host that promoted it to a 3xx would make the browser's own fetch follow it and resolve the call with a page of HTML.
    • Errors are sanitized outside the dev build: a plain thrown value is replaced with a generic Error before serialization, so a driver error's failing query or connection string cannot ship. mark_safe_error opts out.
  • Errors. panic!("bad {}", x) lowers to throw Error(\bad ${x}`)andthrow!(expr)throws a value as it stands;todo!/unimplemented!/ unreachable!arepanic!with std's messages. All four are one IR node,Expr::Throw`, because the difference between them is a parse-time lowering.

    • Statement vs expression. JS throw is a statement but Rust's panic! is !-typed and legal in value position, so it is emitted as a bare throw x; wherever a statement slot exists (block statements, block tails, component-body statements, match arms) and as (() => { throw x; })() otherwise. Error(msg) without new is deliberate — it is spec-equivalent and needs no new support in the emitter, which has none.
    • A component whose body ends in a throw needs no html!: it never returns.
    • What Solid catches. Component bodies, memos, and effect effect-phase throws reach the nearest <Errored>. Effect compute-phase errors do not — they are logged and the run is skipped. A throw from a DOM event handler is not caught either; route it through a signal so the failure happens inside a computation.
    • With no boundary anywhere, Solid calls haltReactivity() and the scheduler stops permanently. There is no partial-failure mode.
    • NotReadyError is <Loading>'s pending-read protocol, not an error. Never catch broadly around user code, or a pending read becomes a hang.
    • <Errored fallback> arity is load-bearing. Solid chooses the render-prop path with typeof f === "function" && f.length, so a zero-parameter closure is treated as a value and rendered as-is. solid-rs rejects that at compile time; it is the one prop rule enforced on a built-in.
    • SSR. A boundary-caught error is serialized into the hydration payload against the boundary's id and re-thrown on the client's first pass, so the fallback matches the served markup. The page is a normal 200 — the SSR entry's onError fires only via failRender, i.e. for errors that escaped every boundary, and those are the 500.
    • #[server] errors are results, not transport failures: the runtime answers 200 with X-Server-Function-Error and the serialized error, and the client transport rejects the caller's promise. Read the call through an async memo so it lands in a boundary — a bare .then(…) yields an unhandled rejection instead.
    • Outside development every unmarked thrown error is replaced with Error("Internal Server Error") before serialization, so a backend message cannot leak; throw!(mark_safe_error(e)) opts out. That flag lives in the runtime, not in NODE_ENV, so the generated server entry calls setServerFunctionsDev(true) behind a NODE_ENV check that esbuild folds away in production.
    • No Result/?. Ok/Err/Some/None and ? are compile errors that name the replacement. They previously parsed into references to JS globals that do not exist — broken output rather than a rejected program.
  • Server components: #[server_component] async fn Name(args…) -> Jsx. Registered exactly like a #[server] function and dispatched through the same endpoint — the only difference is what the body returns, and that difference is the whole protocol:

    • The body ends in html!, so codegen emits async function (args) { … return (props) => (JSX); }. A function result is what the runtime streams as markup; anything else serializes as a value. The leading statements run once per call, on the server, and may .await; the markup is what the returned component renders.
    • props is the slot-props proxy, not data. Every key virtually exists, and slot!(name) reads one — the read is what places a client-fillable marker range. The props a client passes never reach the server; the server's inputs are the function's own arguments. So neither side sees the other's content: the server emits a position, the client fills a range.
    • Two dispatch paths, one component. During the document render the call is in-process and transformDirectResult renders it inline in the page, inside a dx-frame boundary, with the client's own content rendered server-side in the slot ranges — the one hydration-time exception. After hydration the same call is POST /_server answering with a frame stream (length-prefixed JSON chunks of HTML, slots and data) that morphs into that same boundary.
    • The rendered data never ships. This is the security property from #[server] restated for markup: the browser receives HTML and never learns the shape of what produced it, so the rows behind a server component are as absent from the bundle as an API key.
    • ServerComponentPlugin is required on the document render. A server component reaching the hydration data is a component, which seroval rejects outright. The plugin makes it serialize as a reference into the _$SC registry instead, so the markup travels once — as markup.
    • installServerComponents() is the entire client binding, and must be an explicit call: @solidjs/web is sideEffects: false, so an import for effect alone would be shaken out and the page would hydrate with dead boundaries.
    • Identity is split: content is keyed per (function, arguments), mounts belong to the call site. Changing arguments delivers a new address into the mounted instance rather than remounting it, so client-owned slot content survives a server morph. Only node identity shows the difference — the markup looks the same either way.
    • frameTransformResult is reimplemented, not called. Upstream derives the frame id from a WeakMap that is written only inside @solidjs/web/server-functions and read only inside the separately built @solidjs/web/frames; the two prebuilt dists never share it, so upstream can only ever produce the empty id, and no bundler can merge them. solid-rs reads the dispatch id off the request instead. For the streaming path the id is cosmetic — the client remaps every chunk onto an address it computes itself, because boundary identity belongs to the client — but single-flight keeps the producer's id when it is empty, so it would matter there.
  • Transactional mutations: #[action] [async] fn name(args…) -> T compiles to export const name = action(async function* (args) { … }), emitted into the module's <module>.consts.jsx and imported by name like any other module-level binding.

    • action takes a generator function, which is not an expression — hence an item form rather than let x = action(…). yield is a reserved Rust keyword, so syn parses it natively; it is rejected outside an #[action] body (it would land in a non-generator function).
    • Each call is one transaction batching every write between yields. .await gets a typed result, but the runtime has no hook into an async generator's internal await continuations, so writes after an await escape the transaction — put a bare yield before them.
    • Call actions from event handlers, never during render: the body writes signals, and a write in an owned scope trips Solid's dev-mode REACTIVE_WRITE_IN_OWNED_SCOPE guard.
    • Actions are module-level, so component state reaches them as arguments (typically a setter). create_optimistic values revert when the transaction settles; plain signals commit.
    • VERIFIED skew: under Node's node export condition solid-js resolves to dist/server.js, where action is a passthrough stub (function action(fn) { return fn; }). Only the browser build drives the generator — so an action must never be invoked during SSR.
  • solid-rs dev — build + watch (notify crate) + tiny static server, with hot module replacement: an edited component is swapped in place and the rest of the app keeps running, including its state. solid-rs build --dev produces the same output without serving.

    • Dev builds go to <out>-dev/ (default dist-dev/), never to dist/. A dev build is not deployable — development Solid, one file per module, the hot-reload client — and sharing the directory would leave those sitting where the last production build was, for a deploy to pick up.
    • The wrappers come from the official compiler's transformRefresh pass (bundler: "standard", importSource: "solid-js/refresh", granular: true), run on the JSX before the JSX transform, and the patching logic is upstream's solid-js/refresh — so this is build plumbing, not codegen.
    • Dev builds must use the development export condition: the refresh runtime checks solid-js's DEV flag and declines to patch (falling back to a full reload) if it was loaded against a production build.
    • That also turns on Solid's development invariants, which the production build silently allows. They are not advisory — an uncaught one halts the reactive system, so a component that trips one makes solid-rs dev unusable. The three that matter in practice:
      • REACTIVE_WRITE_IN_OWNED_SCOPE — writing reactive state during render or inside a computation. Declare it with create_signal(v, SignalOptions { owned_write: true }) when it is deliberate. A store has no equivalent: its cells are created internally, so there is nothing to flag, and a store simply may not be written during render. Note also that flush() drains the whole queue, so calling it mid-render runs other components' queued effects inside the current owner and reports their ordinary writes.
      • STRICT_READ_UNTRACKED — a reactive value read outside a tracking scope. The fix is a memo, not a suppression: the warning is reporting code that renders once and then never updates.
      • An effect/on_settled/create_tracked_effect callback's return value is its cleanup, so a one-expression body that returns a setter's result hands Solid a non-function to call on teardown. Use a block body. examples/comparison/e2e/dev.mjs asserts a development build of the lab produces no diagnostics at all — warnings included, since those are the ones that report silently-not-updating code.
    • Options keys are camel-cased (owned_writeownedWrite), the same treatment the function name gets, for arguments the registry marks ArgKind::Options. Ordinary struct literals are deliberately not rewritten: they become plain objects whose keys are read back by field access, which is not rewritten, so renaming one half would break the other.
    • The refresh pass targets module.hot, which no browser defines. A per-module prelude supplies one, keyed by the module's source name — the thread the refresh runtime follows across reloads to find the registry to patch into. hot.data must be undefined on first load: the runtime hands its registry forward with data[X] = current ? current[X] : registry, so an empty object there (being truthy) stores undefined and every later patch silently finds nothing to patch.
    • Dev does not bundle the app. Each compiled module is written out as its own ES module under a stable name, and the framework is built separately into dist/vendor/. Bundling breaks HMR twice over: content-hashed chunk names mean an edited module renames every module that imports it, up to the entry, re-executing every ancestor; and splitting hoists a module reachable from two entries into a shared chunk, so even an unedited module gets rewritten. Vendoring separately also prevents a second copy of Solid being loaded, which would give two reactive graphs and patches applied to a tree nobody watches.
    • The dev server long-polls at /_hmr rather than using server-sent events: tiny_http buffers a response and flushes it on completion, so a stream that never ends would never send. The reply names the modules whose contents changed (FNV hashes of dist/*.js, compared across the rebuild); the client re-imports only those, since re-importing an unchanged module would discard its state for nothing.
    • What survives an edit is everything except the edited component's own local state: Solid creates a component's signals in its body, so re-running the body necessarily makes new ones. (axum or tiny_http + SSE; keep deps light — tiny_http is fine).
  • Client routes and the document fallback. A path with no file behind it is served index.html (200) by both dev and preview, so a client-rendered app's deep links survive a refresh. Conditional, not blanket: only for a readable method, a path that names no asset extension, and an Accept asking for text/html. Both signals are needed — a script tag sends */*, and an asset URL typed into the address bar sends text/html — and getting it wrong answers a missing script with HTML, which surfaces as a syntax error at <!doctype rather than as a 404.

    The status is 200 and not 404, and there is no 404.html convention: this branch is reached by every client route, so serving an error page here would make /hello/:name answer 404 and the app never boot. A client-rendered server cannot tell a route it does not know from one that does not exist. A real 404 status needs server-side routing, i.e. mode = "ssr", where the document request goes through the render and the app sets the status itself.

  • solid-rs preview — serve dist/; when dist/server.js exists it renders the document per request through solid-rs-ssr instead of serving a static file (--ssr false to opt out, --ssr-threads N to size the pool; default one per core). Reloads the pool when the bundle's mtime changes, so dev rebuilds land without a restart.

  • solid-rs wasm new <name> — scaffold a wasm-bindgen crate under wasm/<name>. Its Cargo.toml carries an empty [workspace] so a project that is itself in a cargo workspace does not pull the crate in and build it for the host too.

  • solid-rs wasm build [--out D] [--dev] — compile the crates alone, without the JS pipeline. solid-rs build does it too, so this is for iterating on the Rust.

  • Config: solid-rs.toml (entry, out dir, solid version pin). Wasm crates are not listed there — see below.

WASM side-loading

The mainline is a transpiler, which is the right trade for a UI and the wrong one for a sieve. This is the other door: an ordinary wasm-bindgen crate that rustc really compiles — type checked, borrow checked, free to depend on crates.io — reached from app code that is none of those things.

  • Autodetected, not declared: a wasm/<name>/Cargo.toml that exists is an intent already expressed, and a second place to repeat it is a second place to get it wrong. Same principle as css/styles.css.
  • Two tools in sequence, because that is what wasm-bindgen is: cargo build --target wasm32-unknown-unknown produces a .wasm whose exports speak raw numbers, and wasm-bindgen --target web reads the metadata rustc left in it to generate the JS that turns those into strings and objects. Output goes to <out>/wasm/<name>/. wasm-pack wraps both but wants to own the manifest and publish to npm, which is a larger opinion than this needs.
  • The CLI and the crate's wasm-bindgen must be the same version, not merely compatible ones — they share a schema that changes between patch releases. Checked before invoking the CLI, against the version cargo resolved (from the crate's Cargo.lock), so the error can print the exact cargo install / cargo update --precise commands instead of describing them.
  • In app code, the same extern block the npm bindings use:
    #[wasm("analyze")]
    extern "C" {
        fn primes_below(n: i32) -> i32;
        fn checksum(text: String) -> String;
    }
    An empty #[wasm(…)] block is an error (a wasm module has no side effects to import it for), and so is #[js(default)]/#[js(namespace)] on an item: the default export of a --target web module is its initialiser, which the generated loader calls. Names are app-global, as for npm imports.

Client only, and the two loaders

There is no server half, and this is not a gap to be filled later: solid-rs renders on the server inside an embedded QuickJS, which implements no WebAssembly object at all (typeof WebAssembly === "undefined", verified, not assumed). Supporting it would mean embedding a wasm interpreter in solid-rs-ssr and shimming the whole WebAssembly JS API over it, including host functions that call back into QuickJS for wasm-bindgen's glue.

So the codegen emits two loaders per crate, and the existing .client.jsx substitution (the same mechanism that keeps #[server] bodies out of the client bundle) picks between them:

  • __wasm_<name>.client.jsx — read by the browser build:
    import init, { checksum, primes_below } from "/wasm/analyze/analyze.js";
    await init();
    export { checksum, primes_below };
    Named imports rather than a namespace object, so declaring an export the crate does not have is a build error and not an undefined found by calling it.
  • __wasm_<name>.jsx — read by the server build: the same names as stubs that throw, naming the export and the fix. A stub rather than a missing module, because the failure then arrives at the call, where the fix is.

The server bundle therefore contains no glue at all, not merely unused glue: the client build is never given the base module and the server build is never given the client one, so there is nothing for a bundler flag to get wrong.

Consequences:

  • A page using wasm still prerenders. Importing an export is fine anywhere; only calling one during the server render is not. An event handler is the natural place, since it only ever runs in the browser; is_server guards a call in a component body.
  • A #[server] body naming a wasm export is a compile error. A component may or may not reach the call, so a throwing stub is the right answer there; a server function runs on the server and nowhere else, so the call is unreachable by construction. It also has a better answer available — a server function is already Rust running natively.
  • The await init() is top-level, which is what lets the Rust call site stay a plain synchronous call. Top-level await propagates, so a big module belongs behind a lazy! component.
  • The glue is left external to the bundle (external: ["/wasm/*"]): it finds <name>_bg.wasm relative to its own import.meta.url, so bundling would move it away from the binary it loads, and esbuild cannot embed a .wasm anyway.

Single-flight mutations (#[router])

A mutation usually costs two round trips: the call, then a re-read of whatever it invalidated. Single-flight collapses them — the mutation's response carries the mutation's return value and the refreshed query data, and the client seeds its cache from it.

Solid's protocol has two pluggable holes and the router supplies both:

  • Client — the router registers itself as the transport's flight-data consumer when it starts. Subscribing is the opt-in: while registered, the transport adds X-Single-Flight to every mutation. This needs no wiring from solid-rs at all, which also means the header goes out whether or not anything answers it.
  • ServercreateFlightDataCollector(routerInstance) from @solidjs/router/server. It re-runs the matched routes' preloads for the URL the client will show after the mutation, and returns their query results keyed by cache key.

The only thing missing was a way for the generated server entry to name the router: nothing about createRouter(…) as an expression says which const is the instance. #[router] on a module-level const says it.

#[router]
const Routes: JsValue = createRouter(RouterConfig { routes:});

The entry then emits collectFlightData: createFlightDataCollector(Routes) into configureServerFunctionsServer, plus transformFlightResult: frameTransformFlightResult when the app also has server components — that one is what lets a component among the collected data travel back as a frame stream rather than reaching a serializer that would reject it. With no server components the runtime's plain { value, data } fold is already right, and the frames chunk stays out of the bundle.

Three things this turned up:

  • The collector runs with the mutation's own cookies folded in. The runtime pre-digests foldedHeaders onto the outcome, so a preload re-run after a set-cookie sees the value that was just written, not the one it replaced. Getting this wrong is invisible in the headers and shows up as a UI one click behind.
  • @solidjs/router/server imports @solidjs/web/storage, whose first line is import { AsyncLocalStorage } from "node:async_hooks" — unresolvable in an SSR bundle built platform: "neutral". The build now writes the request-event provider to dist/server/__storage.js and aliases that specifier to it, so third-party server code asking for the real thing gets this realm's one ambient request. The entry imports the provider from there too: one notion of "the current request", whoever asks.
  • createEvent is read from handleServerFunctionRequest's own options and from nowhere else — unlike every other hook, it does not fall back to the configured value. Configuring it globally left every #[server] body with a bare { request, locals } and no response stub, so http_header, http_status and every set-cookie written in one were silently dropped. It is now passed per call. This was a pre-existing bug; single-flight only made it visible, because the collector depends on those cookies.

#[router] is rejected in render mode: it wires the server entry, and there is no server entry to wire. The router itself works in either mode — only the attribute is SSR's.

Frames below the dispatch

#[server_component] is the paved path and covers the case where a frame's source is a server function: the runtime picks the transport, renders inline in the document at t = 0, keys content per (function, arguments) and mounts per call site. Below it, @solidjs/web/frames is nameable from Rust for the case dispatch cannot express — a boundary you own, under an id you chose, so that any response carrying that id replaces its contents in place.

Server: server_component_response, render_to_frame_stream, is_frame_stream_response. Client: create_frame, create_frame_host, create_frame_element, get_frame_host, apply_frame_response.

Two constraints that shape how this is written:

  • The two dists have disjoint exports. @solidjs/web/frames resolves to a client build and a server build with no overlap, and every component compiles into both bundles under SSR. So naming a client-only export from a component broke the server build at the import, in a generated file the user never wrote. Those five now route through a generated two-halves loader — __frames.jsx / __frames.client.jsx, the same .client.jsx substitution the #[server] split uses — whose server half is a throwing stub per name. The failure lands on the call, where the fix is.
  • A frame boundary cannot be built during SSR, because it is a DOM element plus a client-side registry and the server render has neither. So the boundary is built on demand, in an event handler. That also means the served page has an empty slot and the client agrees: nothing to reconcile.

The host is not optional in practice, whatever the shape suggests: a frame registers itself under its id with the host, and that registration is the whole link between the two. Pass only an id and the element appears, the fetch succeeds, and the response is applied to nothing at all — silently.

Owning the boundary means owning the transport too. The generated client stub for a #[server] function decodes the body as a serialized value, and a frame stream is not one; a hand-built frame is fetched with a plain fetch and drained with apply_frame_response.

Quality bar

  • cargo test green: parser tests (good + bad inputs with error messages), codegen snapshot tests (golden JS files), CLI integration test (build comparison, assert dist output exists and contains expected JS).
  • Counter example builds end-to-end and the emitted JS is syntactically valid (verify with node --check or esbuild parse in tests).
  • README.md: what it is, quickstart, API overview, wasm side-load section, honest limitations section.
  • No network calls in unit tests (npm/esbuild only in the CLI integration test, behind a feature flag or ignored-by-default test).

Constraints

  • Rust edition 2021, stable toolchain (we have nightly 1.96 but code must build on stable).
  • Keep dependency tree small: syn, quote, proc-macro2, serde, serde_json, clap, toml, notify, tiny_http (or axum), thiserror, anyhow.
  • Pin solid-js@2.0.0-rc.1 in generated package.json.
  • All codegen errors must point at the offending Rust span (file:line:col).