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).
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
- 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. Thehtml!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). ExprAST: 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, andpanic!/todo!/unimplemented!/unreachable!/throw!(see Errors). Anything unsupported => clear compile error with span info.
- Solid 2.0.0-rc.1 architecture (verified from the real .d.ts, 2026-08-20):
solid-js@2.0.0-rc.1core 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-pluginships by default) — no Babel in the pipeline.
- Emission strategy (decided): the Rust→JS transpiler emits JSX source code
(a
.jsxmodule per component +main.jsxentry callingrender(() => <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 rawtemplate()/insert()calls would be fragile.- Build pipeline (verified):
node build.mjs→ (1)@dom-expressions/compilertransform()rewrites eachdist/js/*.jsxinto a per-target directory (sibling imports reference./Name.jsxexplicitly, so the transformed code must stay at the same path), (2) esbuild (JS API) bundles the entry →dist/main.js. The compiler'smoduleName: "@solidjs/web"option makes it emit@solidjs/webimports directly, so no esbuild alias is needed (the old@oxc-solid-js/compilerhardcodedsolid-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_signal→createSignalfromsolid-js,merge_props→mergePropsfrom@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 || expr→onClick={() => expr}(camelCase, delegated by the renderer).
- Build pipeline (verified):
- Output: one JSX module per component file +
main.jsxentry. Deterministic, readable output (this is a dev tool; pretty-print). - Code splitting —
lazy!(Comp)/client_only!(Comp)in a module-level const. They name a component, not a path: every component is emitted as its ownComp.jsx, so the specifier is derived. Desugars tolazy(() => import("./Comp.jsx"), { export: "Comp" }, "./Comp.jsx")— the third argument is Solid'smoduleUrl, normally injected by a bundler plugin, and doubles as the SSR asset-manifest key.Expr::DynImportis 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 undermode = "ssr"once built with the official@dom-expressions/compiler(itsdom/ssrtargets allocate identical hydration ids). It requires a<Loading>boundary. The old@oxc-solid-js/compilerallocated different ids (an eagerssrRunInScopeper interpolation consumed extra root-level ids), which is whylazy!was once a compile error under SSR pointing atclient_only!— see BUGS-2.md.- The old
@oxc-solid-js/compileralso dropped every prop of a component that is a direct JSX child of another component inside a host element, inssrmode only. The official compiler does not, so the former workaround (wrapping such children in{…}) was removed — see BUGS-1.md.
- 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.jssuppliesRequest,Response,Headers,URL,URLSearchParams,FormData,Blob/File,ReadableStream/WritableStream/TransformStream,TextEncoder/TextDecoderandbtoa/atob, evaluated before the bundle. Every class sits behind ahas(name)guard, so the same file is inert under Node — which is what letssrc/web.test.jsrun 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;GETwith a body likewise;get("set-cookie")joins like every other header;Response.jsonmust set its content type before construction). - The request event needs no
AsyncLocalStorage. Solid reads it fromglobalThis[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 arun/finallypolyfill, which would clear the event at the firstawait. httpStatus/httpHeaderare retracted on owner disposal. They write toevent.response.headersimmediately but register anonCleanupthat undoes the write, so awaitingrenderToStreaminto a string discards the whole response head — cookies readable, nothing writable. The entry hands the stream tocreateSSRResponseand splices the template around the result.- Precompiled:
solid-rs buildwritesdist/server.qjs(QuickJS bytecode) next toserver.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.qjsis framed with a magic + FNV-1a hash ofserver.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
RendererPoolis a set of threads, each holding aRenderWorker(one persistentRuntime, a freshContextper 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.rsreproduces 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, not1/2/3. Reproducing that means reimplementing Solid's reactive graph; any drift and the client refuses to claim the markup.
-
solid-rs install— the application's own npm packages, declared insolid-rs.tomland installed into the project'snode_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.jsonis 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 buildnever installs: it checks a stamp innode_modules/against a hash of the tables and fails with "runsolid-rs install" if they disagree, because a build that reaches the network unasked is a build that is not reproducible. The project'snode_modulesgoes first in esbuild'snodePaths, ahead of the toolchain's.Reached from Rust with an
externblock:#[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 installalso writesbindings/<pkg>.rs: a generatedexternblock per export subpath, produced by importing each installed package and reading its real exports. Reference material rather than compiled source (solid-rs parsessrc/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, becauseProgress.Trackexists whileProgress.Rootdoes not and that is not guessable. Regenerate withsolid-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'sonChangereceives the new state). Applying the element rule to components rejects every callback prop in the ecosystem. -
on:clickis 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, soon:MyEventreaches 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 ownaddEvent(el, name, handler)with delegation off, bound through the element'sref— the only hook the transform offers onto the element. A userrefwritten 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:clickis 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.rsandsolid-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>/(seecrates/solid-rs/src/toolchain.rs), provisioned bynpm installon 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.mjsis a&strconst inproject.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.htmlis generated, and a project-supplied one is used verbatim instead. The generated document links/styles.csswhen the project has a stylesheet, and its<title>is a placeholder: the app sets the real one withuse_head. Under SSR the render produces a fragment, so Solid finds no</head>to splice head tags into and hands them to theonHeadcallback — 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.cssanddata//public/are opt-in by existing. A stylesheet is copied todist/styles.cssverbatim, or — if its first directive is@import "tailwindcss"— compiled by@tailwindcss/clirun from the project's node_modules vianpx --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 ownpackage.json(seeexamples/comparison).--no-installis 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 previewruns 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 givennodePaths: [<cache>/node_modules].
- The JS toolchain lives in
-
Bundling (VERIFIED working pipeline, do NOT use esbuild --jsx=automatic):
@solidjs/web@2.0.0-rc.1exports NOjsx/jsxsfactory (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(thenexttag; 1.9.x emits imports from the removedsolid-js/websubpath — must pin 2.0.0-rc.1). Verified pipeline (tested 2026-08-20, 74KB bundle with full runtime):- codegen writes dist/js/*.jsx
- 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 (emitsimport { template, insert, createComponent, delegateEvents } from "@solidjs/web") 2b. the client build runs withsplitting: true+metafile: true+outdir(soentryNames: "[name]"pinsdist/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 todist/manifest.jsonand baked intodist/server/__manifest.js— the SSR realm has no filesystem, andlazy()throws server-side without a manifest. The server build stays unsplit on purpose: QuickJS has no module loader, and esbuild rewrites dynamicimport()toPromise.resolve().then(...)when splitting is off. - 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 }) - copy index.html -> dist/ with script src rewritten to /main.js
build.mjs is owned by the CLI (the
BUILD_MJSconst inproject.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 -> runnode build.mjs(node must be installed; clear error if missing) -> dist/main.js + dist/index.html. -
Mount modes (
[project] modein solid-rs.toml, or--mode):render(default): client-only;render(() => <App/>, root).hydrate: client compiled withhydratable: true, entry callshydrate, and the_$HYbootstrap (fromgenerateHydrationScript) is injected into index.html. Without both of those hydration silently re-renders / throws.ssr: the above plus a second compile withgenerate: "ssr", bundled todist/server.jsfor 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 asdist/index.template.html.renderPageawaitsrenderToStream, notrenderToString: awaiting the stream resolves with the fully settled HTML, so every<Loading>boundary (alazy!component, an async memo) is resolved before the page is served.renderToStringis synchronous and serializes the fallback. Verified working inside QuickJS via the thenable form; the chunk-at-a-timepipe/readableforms 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
renderPageinstallsonErrorand rethrows — otherwise a broken render ships a silently truncated page.
-
Backend-generated initial state:
#[server_state] [async] fn name() -> Tcompiles to a shareddist/js/__state.jsx. The server bundle awaits and evaluates the body,server.jsxserializes the result into the page aswindow.__SOLID_RS_STATE__, and components callname()to read it. The body never reaches the browser. Requiresmode = "ssr".- VERIFIED skew:
@solidjs/web@2.0.0-rc.1does not exportssrRunInScope, which the oxc compiler emits in SSR mode. build.mjs shims it asscope(fn)()—scopeISssrScope.
- VERIFIED skew:
-
Async in a component body: a
#[component]is emitted as a plain function, so a bare.awaitin 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 throwsNotReadyErroruntil it settles, suspending the nearest<Loading>boundary.create_signal(async move || …)works the same way.async move ||closures keep theirasyncthrough parse → IR → emit (Closure.is_async,MemoDecl.is_async—create_memotakes 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 toregisterServerReferenceand wrapped bycreateServerReference) and__server.client.jsx(fetch-backed references, no bodies).build.mjstreatsX.client.jsxas a replacement forX.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 readingconst API_KEYwould 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]), somod#namewould dispatch asmodand collapse every function in a module onto one id. Names are therefore app-global, and validation enforces it. asyncis 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. Emittingasync functionin 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 byhandleServerFunctionRequestin 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 aDatein a return value arrives as aDate. The host passes bodies through byte for byte and must not interpret them. - A thrown redirect answers
200with aLocationheader, deliberately, for the client runtime to act on. A host that promoted it to a 3xx would make the browser's ownfetchfollow 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
Errorbefore serialization, so a driver error's failing query or connection string cannot ship.mark_safe_erroropts out.
- Codegen writes a pair —
-
Errors.
panic!("bad {}", x)lowers tothrow 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
throwis a statement but Rust'spanic!is!-typed and legal in value position, so it is emitted as a barethrow x;wherever a statement slot exists (block statements, block tails, component-body statements,matcharms) and as(() => { throw x; })()otherwise.Error(msg)withoutnewis deliberate — it is spec-equivalent and needs nonewsupport 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. NotReadyErroris<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 withtypeof 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
onErrorfires only viafailRender, i.e. for errors that escaped every boundary, and those are the 500. #[server]errors are results, not transport failures: the runtime answers 200 withX-Server-Function-Errorand 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 inNODE_ENV, so the generated server entry callssetServerFunctionsDev(true)behind aNODE_ENVcheck that esbuild folds away in production. - No
Result/?.Ok/Err/Some/Noneand?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.
- Statement vs expression. JS
-
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 emitsasync 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. propsis the slot-props proxy, not data. Every key virtually exists, andslot!(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
transformDirectResultrenders it inline in the page, inside adx-frameboundary, with the client's own content rendered server-side in the slot ranges — the one hydration-time exception. After hydration the same call isPOST /_serveranswering 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. ServerComponentPluginis 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_$SCregistry instead, so the markup travels once — as markup.installServerComponents()is the entire client binding, and must be an explicit call:@solidjs/webissideEffects: 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. frameTransformResultis reimplemented, not called. Upstream derives the frame id from aWeakMapthat is written only inside@solidjs/web/server-functionsand 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.
- The body ends in
-
Transactional mutations:
#[action] [async] fn name(args…) -> Tcompiles toexport const name = action(async function* (args) { … }), emitted into the module's<module>.consts.jsxand imported by name like any other module-level binding.actiontakes a generator function, which is not an expression — hence an item form rather thanlet x = action(…).yieldis a reserved Rust keyword, sosynparses 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.
.awaitgets a typed result, but the runtime has no hook into an async generator's internal await continuations, so writes after anawaitescape the transaction — put a bareyieldbefore 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_SCOPEguard. - Actions are module-level, so component state reaches them as arguments
(typically a setter).
create_optimisticvalues revert when the transaction settles; plain signals commit. - VERIFIED skew: under Node's
nodeexport conditionsolid-jsresolves todist/server.js, whereactionis 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 --devproduces the same output without serving.- Dev builds go to
<out>-dev/(defaultdist-dev/), never todist/. 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
transformRefreshpass (bundler: "standard",importSource: "solid-js/refresh",granular: true), run on the JSX before the JSX transform, and the patching logic is upstream'ssolid-js/refresh— so this is build plumbing, not codegen. - Dev builds must use the
developmentexport condition: the refresh runtime checks solid-js'sDEVflag 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 devunusable. The three that matter in practice:REACTIVE_WRITE_IN_OWNED_SCOPE— writing reactive state during render or inside a computation. Declare it withcreate_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 thatflush()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_effectcallback'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.mjsasserts 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_write→ownedWrite), the same treatment the function name gets, for arguments the registry marksArgKind::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.datamust be undefined on first load: the runtime hands its registry forward withdata[X] = current ? current[X] : registry, so an empty object there (being truthy) storesundefinedand 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; andsplittinghoists 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
/_hmrrather 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 ofdist/*.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).
- Dev builds go to
-
Client routes and the document fallback. A path with no file behind it is served
index.html(200) by bothdevandpreview, 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 anAcceptasking fortext/html. Both signals are needed — a script tag sends*/*, and an asset URL typed into the address bar sendstext/html— and getting it wrong answers a missing script with HTML, which surfaces as a syntax error at<!doctyperather than as a 404.The status is 200 and not 404, and there is no
404.htmlconvention: this branch is reached by every client route, so serving an error page here would make/hello/:nameanswer 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/; whendist/server.jsexists it renders the document per request through solid-rs-ssr instead of serving a static file (--ssr falseto opt out,--ssr-threads Nto size the pool; default one per core). Reloads the pool when the bundle's mtime changes, sodevrebuilds land without a restart. -
solid-rs wasm new <name>— scaffold a wasm-bindgen crate underwasm/<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 builddoes 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.
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.tomlthat exists is an intent already expressed, and a second place to repeat it is a second place to get it wrong. Same principle ascss/styles.css. - Two tools in sequence, because that is what wasm-bindgen is:
cargo build --target wasm32-unknown-unknownproduces a.wasmwhose exports speak raw numbers, andwasm-bindgen --target webreads the metadata rustc left in it to generate the JS that turns those into strings and objects. Output goes to<out>/wasm/<name>/.wasm-packwraps 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-bindgenmust 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 exactcargo install/cargo update --precisecommands instead of describing them. - In app code, the same
externblock the npm bindings use:An empty#[wasm("analyze")] extern "C" { fn primes_below(n: i32) -> i32; fn checksum(text: String) -> String; }
#[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 webmodule is its initialiser, which the generated loader calls. Names are app-global, as for npm imports.
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:Named imports rather than a namespace object, so declaring an export the crate does not have is a build error and not animport init, { checksum, primes_below } from "/wasm/analyze/analyze.js"; await init(); export { checksum, primes_below };
undefinedfound 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_serverguards 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 alazy!component. - The glue is left external to the bundle (
external: ["/wasm/*"]): it finds<name>_bg.wasmrelative to its ownimport.meta.url, so bundling would move it away from the binary it loads, and esbuild cannot embed a.wasmanyway.
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-Flightto every mutation. This needs no wiring from solid-rs at all, which also means the header goes out whether or not anything answers it. - Server —
createFlightDataCollector(routerInstance)from@solidjs/router/server. It re-runs the matched routes'preloads for the URL the client will show after the mutation, and returns theirqueryresults 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
foldedHeadersonto the outcome, so a preload re-run after aset-cookiesees 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/serverimports@solidjs/web/storage, whose first line isimport { AsyncLocalStorage } from "node:async_hooks"— unresolvable in an SSR bundle builtplatform: "neutral". The build now writes the request-event provider todist/server/__storage.jsand 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.createEventis read fromhandleServerFunctionRequest'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, sohttp_header,http_statusand everyset-cookiewritten 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.
#[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/framesresolves 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.jsxsubstitution 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.
cargo testgreen: 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 --checkor 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).
- 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).