The limits of runtime expressions
I like how much $() expressions do with so little machinery. After building a few non-trivial apps I keep running into two limits and adding another operator will not fix either one.
First, $() accepts one syn::Expr which Topcoat transliterates into a JavaScript string. It does not support match, for, if statements, structs, enums, arrays, ?, assignment, or even && and ||. The expression language has one numeric type and integers fail compilation. Today client code cannot express anything that needs a loop or data structure (eg something like a filterable list, grid or state machine).
Second, the Rust and browser implementations can disagree without a compiler error. Each operation available inside $() has two implementations: one in the Rust surrogate types and one in the TypeScript browser surrogates. The compiler checks that the Rust expression typechecks against the surrogate mirror but it never compares that behavior with the browser implementation. When the two implementations differ, the same $() expression evaluates differently on the server and in the browser. The tracker contains five examples:
The individual fixes address each reported bug, but every new operation adds another place where the implementations can disagree.
State also reaches the client as a one-time JSON snapshot. Signals are the only live handles. The handwritten browser runtime scans the DOM once, so HTML inserted or replaced outside the runtime loses its bindings. Third-party JavaScript still requires a script tag and globals.
What I built
I built a spike around a rustc codegen backend. It compiles monomorphized MIR to readable JavaScript for the dom-expressions runtime used by SolidJS. The design follows SolidStart's server and client split, with Rust as the language. Topcoat still runs the server with hyper, view!, and SSR, while client components become Rust functions compiled to JavaScript. The backend emits JavaScript rather than WASM and the users do not need node in their toolchain and the serving path has no bundler.
Today the backend compiles structs, enums, tuples, arrays, slices, closures, dyn and vtables, drop glue, generics, and recursion. It also implements per-width integer semantics with BigInt for i64, u64, and i128, Box, Vec, String, format!, Rc, and Arc, iterators and sorting, async fn and .await and panics with #[track_caller] locations.
The backend builds core and alloc into its sysroot and uses no surrogate types. For unsupported constructs such as byte punning, threads, and std, it uses the reachability-gated error model from rust-gpu. A construct fails the build only when reachable from user code and the diagnostic includes the call chain.
In the spike, view! macro has a third emitter that produces dom-expressions calls. The browser uses solid-js 1.9.14, shipped as one 9.1 KB gzip bundle. The server emitter and compiled client allocate identical hydration keys. A jsdom parity test enforces this by asserting node identity.
#[island] produces one chunk per island and hydrates it lazily. #[procedure] calls procedures from compiled code over a typed serde wire. #[js_extern] declares foreign JavaScript with checked shapes and emits .d.ts declarations.
Debug information supplies names in the generated JavaScript. The code generator uses an expression queue and destination passing to avoid temporary-heavy output, and source maps let DevTools step through the original Rust. One compiled function looks like this:
function area(r) {
return Math.imul(r.w, r.h) | 0;
}
Live demos
The live demos compile their islands from Rust and contain no handwritten JavaScript. The demo source is public.
Benchmarks
I ran the full upstream js-framework-benchmark suite locally across six implementations. Every reference implementation matched its published results. The full tables and methodology include the committed versions and pins. The run is reproducible with bench/setup.sh && bench/run.sh.
| implementation |
geomean vs vanillajs keyed |
transferred, Brotli |
first paint |
| vanillajs keyed |
1.00 |
2.5 KB |
53.7 ms |
| vanillajs non-keyed |
0.85 |
2.4 KB |
58.3 ms |
| topcoat-vanilla, compiled Rust and direct DOM |
0.98 |
9.8 KB |
81.6 ms |
| solid keyed |
1.15 |
4.5 KB |
58.9 ms |
| leptos keyed, WASM |
1.16 |
48.8 KB |
241.1 ms |
topcoat-island, idiomatic view! |
2.76 |
21.2 KB |
47.9 ms |
In this run, topcoat-vanilla scores 0.98 against keyed Vanilla JS at 1.00, compared with 1.15 for Solid and 1.16 for Leptos. It transfers 9.8 KB, about one fifth of Leptos's 48.8 KB, and its 81.6 ms first paint is about one third of Leptos's 241.1 ms.
The server-rendered topcoat-island records the fastest first paint at 47.9 ms. The linked benchmark report attributes its 2.76 geomean to the unkeyed list model's cost on partial update, select, and swap. A counter-only page transfers about 14 KB gzip in total, including the shared runtime.
What changes compared with $()
- Client code can use loops,
match, structs, enums, collections, recursion, and async in the browser. The showcase demonstrates each category.
rustc typechecks client code against core, and the backend emits Rust semantics. There is no TypeScript surrogate implementation to drift from the Rust implementation.
#[js_extern] checks foreign JavaScript shapes and emits .d.ts declarations. #[repr(C)] structs cross the boundary as plain objects without a converter layer.
- Hydration uses
solid-js instead of the one-shot DOM scan. Per-island chunks transfer code only for the islands on a page.
- Generated JavaScript keeps names from debug information, and its source maps point back to the original Rust.
- The compiled path is additive. It leaves the existing runtime and
$() path unchanged.
Limitations
- The backend currently pins one
rustc nightly, following the rust-gpu maintenance model. I have not investigated whether that constraint can be relaxed.
- npm package resolution is not implemented. Only single-file ESM or vendored libraries work. Closures cannot yet cross
#[js_extern], so addEventListener cases use a small handwritten bootstrap. The benchmark's bootstrap is 22 lines.
- Only
panic=abort works. Unwinding does not.
- The architecture rules out byte punning across representations. The affected cases include
f64 through serde, TypeId, and SIMD. Threads and std itself are also out.
Questions
- Would Topcoat consider compiled Rust to JavaScript for client code?
- Would
solid-js be acceptable as the target runtime? I chose it because its signals match Topcoat's model and I could turn its hydration contract into an executable test suite.
- The spike currently coexists with
$(). Should both remain long term, or should they converge?
- What would the distribution model need before a serious upstreaming conversation? Would that require prebuilt backends per release channel, a toolchain-pinning UX, or something else?
The branch, compiler contract, and benchmark results are public:
The limits of runtime expressions
I like how much
$()expressions do with so little machinery. After building a few non-trivial apps I keep running into two limits and adding another operator will not fix either one.First,
$()accepts onesyn::Exprwhich Topcoat transliterates into a JavaScript string. It does not supportmatch,for,ifstatements, structs, enums, arrays,?, assignment, or even&&and||. The expression language has one numeric type and integers fail compilation. Today client code cannot express anything that needs a loop or data structure (eg something like a filterable list, grid or state machine).Second, the Rust and browser implementations can disagree without a compiler error. Each operation available inside
$()has two implementations: one in the Rust surrogate types and one in the TypeScript browser surrogates. The compiler checks that the Rust expression typechecks against the surrogate mirror but it never compares that behavior with the browser implementation. When the two implementations differ, the same$()expression evaluates differently on the server and in the browser. The tracker contains five examples:The individual fixes address each reported bug, but every new operation adds another place where the implementations can disagree.
State also reaches the client as a one-time JSON snapshot. Signals are the only live handles. The handwritten browser runtime scans the DOM once, so HTML inserted or replaced outside the runtime loses its bindings. Third-party JavaScript still requires a script tag and globals.
What I built
I built a spike around a
rustccodegen backend. It compiles monomorphizedMIRto readable JavaScript for the dom-expressions runtime used by SolidJS. The design follows SolidStart's server and client split, with Rust as the language. Topcoat still runs the server with hyper,view!, and SSR, while client components become Rust functions compiled to JavaScript. The backend emits JavaScript rather than WASM and the users do not neednodein their toolchain and the serving path has no bundler.Today the backend compiles
structs,enums,tuples,arrays,slices, closures,dynandvtables, drop glue, generics, and recursion. It also implements per-width integer semantics withBigIntfori64,u64, andi128,Box,Vec,String,format!,Rc, andArc, iterators and sorting,async fnand.awaitand panics with#[track_caller]locations.The backend builds
coreandallocinto its sysroot and uses no surrogate types. For unsupported constructs such as byte punning, threads, andstd, it uses the reachability-gated error model from rust-gpu. A construct fails the build only when reachable from user code and the diagnostic includes the call chain.In the spike,
view!macro has a third emitter that producesdom-expressionscalls. The browser usessolid-js1.9.14, shipped as one 9.1 KB gzip bundle. The server emitter and compiled client allocate identical hydration keys. Ajsdomparity test enforces this by asserting node identity.#[island]produces one chunk per island and hydrates it lazily.#[procedure]calls procedures from compiled code over a typed serde wire.#[js_extern]declares foreign JavaScript with checked shapes and emits.d.tsdeclarations.Debug information supplies names in the generated JavaScript. The code generator uses an expression queue and destination passing to avoid temporary-heavy output, and source maps let DevTools step through the original Rust. One compiled function looks like this:
Live demos
The live demos compile their islands from Rust and contain no handwritten JavaScript. The demo source is public.
Benchmarks
I ran the full upstream js-framework-benchmark suite locally across six implementations. Every reference implementation matched its published results. The full tables and methodology include the committed versions and pins. The run is reproducible with
bench/setup.sh && bench/run.sh.view!In this run,
topcoat-vanillascores 0.98 against keyed Vanilla JS at 1.00, compared with 1.15 for Solid and 1.16 for Leptos. It transfers 9.8 KB, about one fifth of Leptos's 48.8 KB, and its 81.6 ms first paint is about one third of Leptos's 241.1 ms.The server-rendered
topcoat-islandrecords the fastest first paint at 47.9 ms. The linked benchmark report attributes its 2.76 geomean to the unkeyed list model's cost on partial update, select, and swap. A counter-only page transfers about 14 KB gzip in total, including the shared runtime.What changes compared with
$()match, structs, enums, collections, recursion, andasyncin the browser. The showcase demonstrates each category.rustctypechecks client code againstcore, and the backend emits Rust semantics. There is no TypeScript surrogate implementation to drift from the Rust implementation.#[js_extern]checks foreign JavaScript shapes and emits.d.tsdeclarations.#[repr(C)]structs cross the boundary as plain objects without a converter layer.solid-jsinstead of the one-shot DOM scan. Per-island chunks transfer code only for the islands on a page.$()path unchanged.Limitations
rustcnightly, following the rust-gpu maintenance model. I have not investigated whether that constraint can be relaxed.#[js_extern], soaddEventListenercases use a small handwritten bootstrap. The benchmark's bootstrap is 22 lines.panic=abortworks. Unwinding does not.f64through serde,TypeId, and SIMD. Threads andstditself are also out.Questions
solid-jsbe acceptable as the target runtime? I chose it because its signals match Topcoat's model and I could turn its hydration contract into an executable test suite.$(). Should both remain long term, or should they converge?The branch, compiler contract, and benchmark results are public: