Four stages, each with one job.
@rbxts/react ─┐ vide ├─► Frontend adapter ─► Scene IR ─► Layout engine ─► DOM renderer ─► Browser DOM luau (later)┘ (TypeScript) (contract) (Rust → WASM) (TypeScript)The Scene IR in the middle is the design. It is a framework-agnostic description of a Roblox GUI
instance tree, mirrored byte-for-byte between a Rust crate, loom-scene, and a TypeScript package,
@loom-dev/scene. The layout engine and the renderer only ever see the IR, so neither knows React
exists. That is how the vide adapter was added without touching either.
1. The frontend adapter
@loom-dev/react is a custom react-reconciler host config. It produces no DOM. It creates and
mutates live loom instances, an in-browser model of Roblox’s Instance tree with real
properties, children and signals, exactly as @rbxts/react-roblox would in Studio.
The vide adapter does the same job with fine-grained signals and no reconciler, and lands on the
same IR.
2. Scene IR
The instance tree is serialized into plain, untagged data: UDim, UDim2, Vector2, Color3 (0–1
channels), enum items as { enumType, name }. Nothing is a class and nothing carries identity, so
it all crosses the WASM boundary cleanly.
3. Layout
@loom-dev/layout is a thin async wrapper over loom-layout-wasm:
await initLayout(); // load the wasm module once (idempotent)const result = computeLayout(scene, viewport); // synchronous after thatThe Rust engine implements Roblox’s own layout rules, not CSS’s: UIListLayout and UIGridLayout
flow, UIPadding, UISizeConstraint and UIAspectRatioConstraint, AutomaticSize, anchor points,
scale-plus-offset sizing, and text measurement. It computes an absolute rect for every node.
This is the reason loom exists: approximating UIListLayout with flexbox gets close enough to be
misleading, and running the real algorithm gets a preview you can trust.
The engine is Rust compiled to WebAssembly and shipped prebuilt. Developing loom itself, a change under crates/loom-layout does nothing until you run pnpm build:native.
4. DOM rendering
@loom-dev/renderer walks the IR alongside the layout result and emits nested,
absolutely-positioned <div>s. It reproduces the layout engine’s id scheme, a positional path
counting only layout-participating children, so nodes and rects line up without a side channel.
Two entry points share the per-node CSS mapping:
renderScene— one-shot full rebuild viareplaceChildren. Used by the vide adapter and anything that only needs a static picture.createDomSession— keyed incremental patching plus pointer-input delegation. Elements persist across frames, so listeners and focus survive a re-render. This is the React path.
Four fidelity rules the renderer honours. The layout root and any LayerCollector are transparent
containers and never painted. Visible = false hides via CSS while the node keeps its rect. Text is
painted in an aligned overlay layer. And UICorner and UIStroke become border-radius and
box-shadow.
Input
Events do not reach your React handlers directly. The renderer hit-tests the DOM event, maps it back
to a live LoomInstance through data-loom-id, and dispatches through the instance’s signals.
Activated, InputBegan and UserInputService fire the way they do in Roblox.
Pointer capture mirrors Roblox’s input sinking rather than the DOM’s: only TextButton,
ImageButton, TextBox, ScrollingFrame and GuiObjects with Active = true sink pointer
events. Active governs sinking, not hearing, so a slider handle that is a plain Frame with an
InputBegan handler still gets its events. Frames and labels with no listeners are click-through,
so a transparent full-screen positioning frame does not swallow clicks meant for the control
underneath. A modal scrim, in practice a TextButton, still blocks.
What the Vite plugin does
None of the above runs without the specifiers being rewritten first. loomPreview() does four jobs,
plus a fifth. With no index.html in the project, it generates one around the detected client
entry, or around the gallery shell when targets is set, in serve and build alike.
-
Aliasing.
@rbxts/react-robloxbecomes the browser client, withcreateRootandcreatePortal.@rbxts/servicesbecomes service singletons backed bygame.GetService.@rbxts/reactbecomes a shim over React that adds theReact.EventandReact.Changekeyed-prop namespaces, and@rbxts/videbecomes the vide adapter. Barereactand both JSX runtimes are pinned to one absolute path, so exactly one React instance exists. -
Globals injection.
installGlobals()runs before your entry, definingUDim2,Color3,Enum,gameand the Luau standard library, which roblox-ts code references with no import. Under the dev server that is a script tag in<head>. Undervite buildthe import is prepended to the page’s entry modules. The same module pulls in the engine’s typefaces. -
Import-equals rewriting. roblox-ts sources use
import X = require("m"), which esbuild lowers to a barerequire()that throws in a browser. A pre-transform rewrites it toimport * as X from "m", in serve and build alike. -
Luau-main fallback. A roblox-ts package points
"main"at compiled Luau. The plugin redirects such packages to theirsrc/index.ts(x)before resolution, so it works even when the package was never compiled. That is what lets loom consume a source-only workspace.
What it does not do is compile roblox-ts: esbuild transpiles the TSX and discards types, with no
rbxtsc in the loop and no Luau in the browser.
Next step
Scope and status — what this pipeline covers today, and what it does not.