Skip to content

Proposal: compile client code from real Rust to JavaScript instead of runtime expressions (working spike + benchmarks) #275

Description

@GoldStrikeArch

The problem: runtime expressions have a structural ceiling

I love how far $() expressions get with so little machinery, but after building a few non-trivial islands I keep hitting the same two walls, and I think they are structural rather than incremental.

The surface is an expression subset, not a language. A runtime expression is one syn::Expr transliterated to a JavaScript string. There is no match, no for, no if statements, no structs or enums, no arrays, no ?, no assignment, not even &&/||. Integers are a compile error; there is one number type. Anything with a loop or a data structure (a filterable list, a grid, a state machine) cannot be written client-side today.

Correctness is by naming convention, checked nowhere. Every operation in the shared vocabulary is implemented twice: once in the Rust surrogate types and once in the TypeScript browser surrogates. The compiler never checks that the two sides agree; it only checks that the Rust side typechecks against the surrogate mirror. Whenever the two implementations disagree, the same $() expression evaluates differently on the server and in the browser. The tracker already shows this bug class:

Each of these got fixed individually, but the design keeps generating them: every new vocabulary method is a new chance for the two implementations to drift.

On top of those two walls: state crosses to the client as one-time JSON snapshots (signals are the only live handles), the hand-written browser runtime scans the DOM once so HTML swapped in by anything else silently loses its bindings, and third-party JavaScript is a script tag plus globals.

What I tried

I spent some time on a spike: a rustc codegen backend that compiles monomorphized MIR to readable JavaScript, targeting the dom-expressions runtime that SolidJS is built on. The idea is the solid-start shape with Rust as the language: the server stays exactly today's Topcoat (hyper, view!, SSR), and client components become real Rust functions compiled to JS. No WASM, no Node in the user's toolchain, no bundler in the serving path.

What compiles today: structs, enums, tuples, arrays, slices, closures, dyn and vtables, drop glue, generics, recursion, per-width integer semantics (BigInt for i64/u64/i128), Box, Vec, String, format!, Rc/Arc, iterators and sort, real async fn/.await, and panics with #[track_caller] locations. It builds the real core and alloc into a sysroot; there are no surrogate types. Unsupported constructs (byte punning, threads, std) use a reachability-gated error system borrowed from rust-gpu: they only fail your build if your code actually reaches them, with a call chain in the message.

On the framework side: view! gained a third emitter that produces dom-expressions calls, the client runtime is the real solid-js 1.9.14 (one 9.1 KB gzip bundle), and the server emitter and the compiled client allocate identical hydration keys, enforced by a jsdom parity harness that asserts node identity. #[island] gives per-island chunks with lazy hydration, #[procedure] works from compiled code over a typed serde wire, and #[js_extern] declares foreign JavaScript with checked shapes and .d.ts emission.

The emitted JavaScript is meant to be read: names come from debug info, the output goes through an expression queue and destination-passing codegen so it does not look machine generated, and source maps step back through the original Rust in DevTools. Example of real compiled output:

function area(r) {
  return Math.imul(r.w, r.h) | 0;
}

Live demos

Everything below is served at https://goldstrikearch.github.io/topcoat/ and compiled from Rust, so the islands contain zero hand-written JavaScript. Source code is here

Benchmarks

I ran the full upstream krausest js-framework-benchmark harness locally over six implementations, with every reference implementation matching its published numbers. Full tables and methodology are in bench/RESULTS.md; the run is reproducible via bench/setup.sh && bench/run.sh with committed pins.

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, 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

Compiled Rust runs at hand-written JavaScript speed: topcoat-vanilla lands ahead of Solid and of Leptos on the geometric mean, at a fifth of Leptos's wire weight and a third of its first paint. And topcoat-island posts the fastest first paint of the entire field because the server already rendered the page; its 2.76 geomean is the known cost of the unkeyed list model on three specific operations (partial update, select, swap), discussed under limitations. A counter-only page pays about 14 KB gzip total including the shared runtime.

What this buys over today

  • Client code gets the whole language: loops, match, structs, enums, collections, recursion and async all run in the browser (the showcase programs are the proof)
  • One semantics instead of two: rustc typechecks client code against the real core, and the compiler implements Rust semantics in the output. Those bug classes above disappears by construction because there is no second implementation to drift.
  • An FFI story: #[js_extern] with checked shapes and .d.ts output replaces call-a-global-and-hope; #[repr(C)] structs cross as plain objects with no converter layer.
  • Real hydration: the real Solid runtime (yes, it is a real solid-js runtime :D) replaces the one-shot DOM scan, and per-island chunks mean a page pays only for the islands it has.
  • Debuggability: the emitted JS is reviewable and source maps step through the original Rust (you can also see it in demos).
  • It is additive: the existing runtime and $() path are untouched; the spike coexists with them.
  • The performance is superior to what you will write in JS by hand (as we can make way more optimizations)

Limitations

  • The backend pins one rustc nightly forever, the rust-gpu maintenance model. I didn't dig too much into it, whether it can be solved or not
  • npm package resolution is not built; only single-file ESM or vendored libraries work. Closures cannot yet cross #[js_extern] (a small hand-written bootstrap covers addEventListener cases; the benchmark entry's is 22 lines)
  • panic=abort only, no unwinding yet
  • Permanently out by architecture: byte punning across representations (f64 through serde, TypeId, SIMD), threads, std itself.

Questions

  1. Is compiled-Rust-to-JavaScript a direction Topcoat wants for the client story, in principle?
  2. Any objection to solid-js as the target runtime? It was chosen because signals match Topcoat's model and its hydration contract could be extracted into an executable test suite.
  3. The spike is additive today. Is coexistence with $() expressions the right long-term shape, or would maintainers want convergence?
  4. What would the distribution story need to look like (prebuilt backends per release channel, toolchain pinning UX) before a serious upstreaming conversation makes sense?

Happy to go as deep as anyone wants on any of this. The branch, the compiler spec and the benchmark harness are all public:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions