Browse docsSolid TSX to Rust

Solid TSX to Rust

MicroTS compiles a Solid TSX view and its TypeScript contract into Rust. The Solid and Vue front ends produce the same View IR. The Rust generator, host input contract and microts runtime consume that IR.

The supported application source is TypeScript: .tsx views and .ts models. Browser and QuickJS builds execute JavaScript emitted from these files. Native AOT executes a Rust model implementing the generated trait. Setting app.model to "compiled" translates the admitted TypeScript model subset into that implementation. The default "rust" mode uses the application's handwritten Rust model; its .ts module supplies browser and guest behavior, or .d.ts declarations supply preview defaults.

See TypeScript support for the differences between ordinary apps, view expressions and compiled model bodies, TypeScript models to Rust for reaction scheduling and tasks, and TypeScript and native boundaries for model ownership, generated values and host integration. JavaScript is a build output and execution-engine format, not an additional AOT source-language contract.

Build a view

The example in apps/solid-aot-lab/ includes props, callbacks, named and scoped slots, generic components, keyed rows, context and instance state. It sets app.aot: true and app.model: "compiled": app.ts supplies model logic and src/lib.rs connects the generated app to the native host.

bun microts/compiler/cli.ts check solid-aot-lab --strict
bun microts/compiler/cli.ts build solid-aot-lab --strict
cargo check --manifest-path apps/solid-aot-lab/Cargo.toml
bun tools/build.ts solid-aot-lab-main --no-config

build writes view modules, compiled model modules and a style table under the app's gen/ directory. Demo gen/ directories are ignored by Git; generate them before running Cargo. check accepts --json for the IR and --board for host capability admission. A TSX native build uses the build subcommand; check runs admission without generating Rust. The earlier cartridge compiler is maintained in a separate repository.

An app opts its browser and guest builds into admission with "app": { "framework": "solid", "aot": true } in pocket.json. The checker follows imports from the manifest entry before the transform cache is read. Ordinary Solid apps use their framework and host contracts without the AOT view and model checks; see execution modes. Add "model": "compiled" to compile model bodies; without it, AOT checks the view contract and leaves the native model implementation to Rust.

Files and ownership

Counter.tsx imports its model from ./Counter. Use one Counter.ts or Counter.d.ts, never both. Child view imports include the .tsx extension. A declaration module supplies signal defaults for browser previews; these mocks do not implement the Rust application's logic.

// Counter.ts
import { createSignal } from "solid-js";
import type { i32 } from "@pocketjs/framework/solid/std";
export const [count, setCount] = createSignal<i32>(0);
// Counter.tsx
import { Text, View } from "@pocketjs/framework/solid/components";
import { count, setCount } from "./Counter";

export default function Counter() {
  return (
    <View class="p-4 bg-slate-100" focusable
      onPress={() => setCount(count() + 1)}>
      <Text>{count()}</Text>
    </View>
  );
}

The generated trait requires count(&self) -> i32 and set_count(&mut self, value: i32). Rust mode requires you to implement these methods; compiled mode generates them from Counter.ts and uses its 0 seed. An Accessor<T> becomes a model getter; a setter is generated when a view writes the signal. A Setter<T> pairs with the matching Accessor<T> by its setName/name naming convention and value type. A plain function remains a method.

These component-body rules apply to the .tsx view. The component file contains imports, type declarations and one default-exported function declaration. The body accepts prop defaults through mergeProps, a model factory with mount-time arguments, context reads, derived expressions, PocketJS lifecycle hooks and one JSX return. State creation and application logic belong to the basename TypeScript module. Rust mode also requires a native model implementation. Compiled models must pass admission and native compilation; the current limits identify forms for which a frontend check alone is insufficient.

Components and expressions

Import Show, Switch, Match, mergeProps, createMemo and useContext from solid-js. Import host elements, keyed For and lifecycle hooks from @pocketjs/framework/solid/*. Numeric types and built-ins use @pocketjs/framework/solid/std; button constants use @pocketjs/framework/input.

A view-local createMemo and a model memo have different compilation rules. A view-local memo from solid-js defines an expression expanded at each read. In a compiled .ts model, import createMemo, createEffect and on from @pocketjs/framework/solid/reactive; these participate in the compiled reaction schedule and memo cache. The model checker rejects those primitives imported from solid-js.

Form Behavior
props.label Borrowed prop; prop destructuring is rejected
onSaved: (id: i32) => void Required callback, named saved in the IR
props.onSaved?.(value) Optional callback; arguments are skipped without a listener
children?: JSX.Element Default slot, rendered with props.children
badge?: JSX.Element Named slot, rendered with props.badge
row: (props: { item: Accessor<T> }) => JSX.Element Scoped slot; pass the accessor and read it in the parent
const { presses, press } = createRow() One model per mounted child instance
const width = createMemo(() => count() * 12) Expression expanded at each read; no extra Rust getter
<Show when={count() > 0}> Boolean branch; false unmounts its children
<Switch><Match when={...}> Ordered boolean branches

View-local derived expressions read signals, props and built-ins. They cannot call model methods. Compiled model functions and memos have the separate model expression rules. JSX text uses Solid's line whitespace rules. Text expressions accept numbers, strings, enums and their optional forms; render booleans through a string ternary. Write literal Unicode characters instead of HTML entities.

Keyed rows retain their model when an object is replaced under the same key. The item and index are accessors. Keys must be unique among siblings. The key function reads its argument, constants and built-ins.

<For each={items()} by={item => item.id}>
  {(item, index) => <Text>{index() + 1}. {item().label}</Text>}
</For>

A root context provider passes an accessor: <ThemeContext.Provider value={theme}>. Children use useContext(ThemeContext)! and read theme(). The provider wraps the complete root view; nested overrides are rejected.

Static class strings and ternaries with full class literal leaves become style IDs. A StyleClass prop forwards a compiled class selection to a child. style accepts an object of numeric host properties. See TypeScript support for numeric types, units and arithmetic, and the expression comparison for built-ins and optional array indexing.

Input and lifecycle

ActionHandler takes a static button={BTN.CROSS}, an optional boolean active and bare latched. AxisHandler takes axis="primary" or axis="secondary"; onDelta receives signed i32 millidegrees through the hardware-neutral relative-axis contract.

View handlers admit model calls, signal writes, callback emissions and blocks of expression statements or if statements. An emission ends its handler or a branch of its final if. Optional callback arguments run only when a listener is present. These handler-body rules do not restrict the statements in compiled model functions.

One frame's input dispatch is one batch. Handlers run in document order; structural updates follow the dispatch. Before each row handler, the bridge resolves its enclosing keys against the current data, skips a missing key and freezes those row values for that handler. The next handler sees prior writes.

Use onMount and onCleanup from @pocketjs/framework/solid/lifecycle on roots and children with a model factory. Each hook calls a zero-argument model method. A child model that registers hooks must satisfy Rust's 'static bound; it can own its state or refer to static data. After a structural update, cleanup runs in reverse creation order and mount runs in creation order, in one batch. A round that ran hooks triggers another update. Root disposal runs outstanding cleanup hooks without another frame.

With a compiled model, each frame first resumes ready tasks from the host's readiness snapshot, then dispatches input, runs reactions and settles memos. View updates and lifecycle rounds follow. Host commands drain after those rounds; asynchronous host results are delivered at a later frame boundary. Unmounting a factory cancels its tasks and clears its node references. A model reference created by createNodeRef from @pocketjs/framework/animation can bind to <View ref={target} />; it names a host node for animation and does not expose a DOM element or a device SDK handle.

Native code generation consumes View IR format 1. Other versions are rejected. The shared format includes statement sequences, conditions, lifecycle hooks, style props, optional callback emissions and literal array constants.