Status: RULED 2026-10-24. Where the production compiler lives and what it
reuses. ref/ is the executable spec, not the product; this document says
what the product is so that ref/ is built to be its conformance suite.
Ruling: a separate Rust crate on top of Solid's solidjs-compiler crate.
ReactiveScript owns parsing, checking and modules; Solid's crate owns the
JSX lowering. The alternative considered and declined: an .rx front end
inside @solidjs/compiler, beside TSRX. Earlier messages in the design
record argued each way; this is the decision.
-
CP-1a (ReactiveScript's crate). Parses
.rxand.d.rx(07), loads the module graph (09), checks the program (02–06, 08: rows, failures, regions, signatures, contexts), and lowers it: the reactive declarations to calls on the runtime helpers (__rt, the APIref/compile/rt*.tsdefines) and the views to JSX — as anoxc_ast::Programin an OxcAllocator, never as text. -
CP-1b (Solid's crate).
solidjs-compilerlowers that program for a target —dom,ssr,universal,dynamic, hydratable or not — with the shared back ends every Solid source goes through. Hydration keys, marker regions, which holes are scoped and which fragment entries are memos (documentation/hole-owner-id-matrix.mdonnext) are its decisions; ReactiveScript makes none of them. -
CP-1c (The seam). One entry point Solid's crate does not yet export: compile from a
Programinstead of source text.compile_innerinpackages/compiler/src/compiler.rsalready has the shape — the TSRX route hands a loweredProgram<'a>into the samematch options.generatethe text route reaches afterparse_program— so the entry is that function with the parse step split off:pub fn compile_program<'a>( allocator: &'a Allocator, program: Program<'a>, source: &str, options: &CompileOptions, ) -> Result<CompileOutput, CompileError>
sourceis the authored.rxtext, which the back ends only use for spans; ReactiveScript's lowering keeps authored spans on the nodes it clones and leaves generated nodes unspanned, as the TSRX front end does (frontend-notes.md, "Stage 3"), so source maps and diagnostics come out on.rxpositions. This is the only change the ruling asks of Solid; it is additive and shared with any other front end that produces an AST.Found in porting (2026-10-27): positions are not only for diagnostics. Solid's SSR props hoisting (
ssr/props.rs, on by default) finds a component's props literal by its element's span, and constructs it ahead only from locals whose declaration ends before the element in the source (declared_before). An unspanned declaration counts as declared before everything, and.rxpositions order the view after the declarations where the lowered module orders it first — either way the server output differs from what the same module gets as text. So the crate's program carries the lowered module's layout instead (decision 5): spans in print order on what that rule compares,sourceblanks of that length..rxpositions come from a map the crate keeps from layout to.rx: each layout position records the.rxline it was lowered from (a declaration's, a markup's), so Solid's source map is rewritten onto the.rxfile (that line, at its first non-blank column) and a position in a Solid error is reported asfile.rx:line(decision 6). -
CP-1d (Styles: a second seam). Scoped styles (07 §6b) are TSRX's engine,
compile_styleinpackages/compiler/src/tsrx/style/, which "deliberately has no dependency on the TSRX/Oxc frontend; the frontend only needs to translate its template intoElementvalues". It is a private module today. The second additive change asked of Solid is to export it (compile_style,StyleInput,Element,StyleOutput), so ReactiveScript's crate hands it a.rxview's elements and gets back the stylesheet, its hash and the elements to annotate — the same bytes a.tsrxfile gets. (ref/reaches the engine by compiling a synthetic.tsrxfragment of the view.)
transform()is Solid's public contract: one source module in, one module out, no state between calls. ReactiveScript's checks are whole- program — a signature is computed from the exporting module (MO-3), a failure is discharged through an imported component's summary (R-f-5), cycles are refused (MO-5),.d.rxfiles declare JS modules (MO-8). A per-module slot beside TSRX cannot hold them without a second, multi-module contract inside Solid's compiler, which would exist for one front end.- TSRX fits that slot because it has no semantic layer: it is syntax that desugars to Solid's control flow and leaves every binding as authored. ReactiveScript is the semantic layer; the compiler is most of the language.
ref/compile/jsx.tsemits JSX text and callstransform(). That is the right cost for the spec and the wrong one for the product: Solid's own runtime compilation moved off text projection ("no generated Solid-JSX text is parsed",frontend-notes.md) because a projection loses positions. A diagnostic on a.rxline and a source map to it need the AST route.
-
CP-4a (The package). The reactive helpers ship as a small runtime package over
@solidjs/signalsandsolid-js, in a client and a server build —ref/compile/rt-web.tsandrt-server.tsare their reference (rt.tsis the client's over the test renderer). Each build exportscreateApp(options); a compiled module's default export is a function of the app (main(__rt), ormodule(__rt)for a dependency, MO-1), so the helpers below are properties of__rt. It is@reactivescript/runtime(runtime/), built from the reference itself, so the package runs what every scenario runs; the export condition picks the build, as@solidjs/web's does. The two builds create the same owners in the same order (10, Implementation); where the client creates one the server has no counterpart for, the server reserves its ids. A value-level catch primitive that Solid provided on both sides would retire the last of those reservations. Nothing in it adds run-time behaviour beyond Solid: each helper is a Solid primitive, a composition of them, or plain JavaScript the compiler would otherwise inline. -
CP-4b (The names, pinned 2026-10-26). What compiled code calls. The Rust crate emits exactly these; a change is a change to both.
__rt.lowers over source(name, init)source x := e(ST-1, ST-2a)createSignal; structured:createStorebehind a signal slotquestion(name, fn, opts, mode, key?)source x = e(ST-5), with a guess (AC-3) or path writescreateSignal(fn),createOptimistic,createStore(fn),createOptimisticStorememo(name, fn, opts)an equation (E-1), a hoisted await(R-1)createMemodeferred(name, fn, opts)deferred x = e(H-1)createDeferredkeyed(name, key, fn)x key k = e(ST-4)createMemo+createProjectiondraft(seed, body)x = draft(pre x -> s) { … }(ST-3)createProjectionpre(get),preOr(v, init)pre y -> iof another node (C-2)createMemofirstPaint(f)await e -> v(R-7)the loadingValueoptionclientQuestion(opts)an awaited call into clientJS (SV-7b)the ssrSource: "client"optionawaited(v)await e— the flight the memo returns or the action yieldsidentity on a promise hoisted(name, get)an equation's hoisted awaits, which refreshre-asks with it— effect(n, inputs, apply)effect (…) { … }(EF, E-12)createEffect(compute, { effect, error })action(gen)action f(…) { … }(AC-1, AC-7); an inferred action,(ps) => action(function* () { … })()(AC-1b)actionover a generatorrefresh(name, get),affects(get),until(p)AC-4, AC-5, AC-6 refresh,affects,until(until: client build only)snapshot(get)an imperative read (H-10, R-f-7) untrackuntrack,latest,isPendingpeek(E-5),latest(R-12),pending?(R-9)the same eq(a, b),fmt(v)==through a store (ST-2), a template part— errorClass(name, params, msg),isA(e, classes),PANICerror C(…)(R-f-1), a typed catch,Panic(R-f-10)a subclass of Errorthrow(e)throwas an expression (R-f-3)— catchMemo(f, match, alt)x = await … catch …(R-f-4 over an equation's flight)createErrorBoundarycatchRead(f, match, alt)e catch …in reactive scope (R-f-4)createErrorBoundarytryCatch(f, match, alt)e catch …in imperative scopetrycontext(name, dflt),provide(c, entry),use(c, owner),useGet(c, owner),owner()contexts (RG-7) createContext,setContext,getContext,getOwnermodule(key, factory)an .rxdependency, once per app (MO-1);keyis the file's name in a sibling-file build, the factory itself when modules are imported by source for a bundler (rxc app), so a module two programs share is one key— serverRef(name, fn),serverCall(name, gen, args)a server fn as the client calls it, and its body (12 SF-2, SF-6, SF-7) Solid's "use server"functionsshow(v),scopedClass(v, hash){e}(a value formatted, a view inserted), a dynamic class with the view's scope (CSS-2)— TypedErrored,Portalerrored fallback(e: A)(R-f-4),portal(RG-8)Errored,PortalBthe language's built-in functions ( len,str,map,filter,sum,every,indexOf) andlog— The app itself:
mount(view, hydrating)in the client build,stream(view, sink)in the server's. -
CP-4c (The harness's, not the package's).
reg,lookup,dget,dset(a scenario'shandlerrows reach any live binding by name),apiandjsError(./apiand its declared errors as test flights, where a program imports the real module),B.fetchandB.fail(the scenario prelude), andflights,lastKey,log,asyncError,frame(what the runners observe). Compiled code calls them only in a conformance build — the compiler's option for runningscenarios/(CP-5);ref/compile/emit.tsalways emits one.
- The Rust crate is correct when it passes
scenarios/through the runners that execute compiled code — the compiled runner's string-tree renderer, and the server runner's served, hydrated and browser-only tables — and through the types runner (13), with its checker's verdicts equal toref/src/check.ts's on every program and rejected variant.ref/has five runners: those, plus the interpreter and the oracle, which the crate is diffed against rather than run through.ref/keeps the TypeScript implementation as the specification of each pass and as the oracle the crate is diffed against; it is not the thing being shipped.
Taken by default 2026-10-27 (issue #1, agreed by Ryan 2026-10-26) — veto here. The checker's resolution, kept as facts, so that lowering (and any other analysis) reads what the checker decided instead of recognising source spellings again. Compile-time only; nothing of it reaches run time.
- CP-6a (the facts). Per module: its regions (03: the program, each
component, an imported module's alias, an instantiating
fn, eachthen/elsebranch,forrow,loading/portal/erroredboundary and component children), its symbols (every name a declaration binds except constants: sources with whether they have a question, equations, props, fns, actions, components, imports, error classes, contexts, server fns) and its computations: an equation's memo (deferred/keyedwhere declared so), a source's question and seed, each reader of a view, each prop argument and handler, an effect's inputs and itsapply, an action or server fn body, afnbody, a provision (RG-7) and a context's default. A computation lists its reads, writes and calls, and the computation it is nested in. - CP-6b (read modes). A read of a source, equation, props or context
(through
use) istrackedin reactive scope (SC-1);snapshotin imperative scope (SC-2), underpeek(E-5), in a source's seed (E-4) and as a value parameter's argument (E-10); orpre(C-2). Underlatest/pending?it carries the probe (R-9, R-12); a read of one field of props or a store records the field. - CP-6c (writes and calls). A write is
x := e(W-1, with whether it writes a path),guess(AC-3),refresh(AC-4) oraffects(AC-5);use p := eis recorded for the diagnostic it gets (O-4). A call names a fn, action, server fn or JS import called by name. - CP-6d (one walk, two implementations). The facts are recorded by the
checker's own walk, at the point where it resolves each name, in
ref/src/model.ts(filled byref/src/check.ts) and in the crate'scompiler/src/model.rs(filled bycheck.rs).ref/model.ts file.rxandrxc model file.rxprint it. - CP-6e (not a format). No one outside the crate depends on it: it is not a stable IR and not a commitment to a second front end (issue #1). Ids are indices within one module's model.
compiler/ is CP-1a's crate, reactivescript (binary rxc): its parse,
module, check and signature stages and CP-6's model, a port of
ref/src/{parse,modules,content,signature,check,model}.ts, and the lowering,
a port of ref/compile/{emit,awaits}.ts that hands each module to
solidjs-compiler's compile_program as an oxc_ast::Program (CP-1c) and
its views' elements to compile_style (CP-1d). The Solid dependency is a git
pin in compiler/Cargo.toml that must equal ref/SOLID_NEXT.
CP-5 is ref/crate.ts (npm --prefix ref run crate): for every program in
scenarios/ (programs, variants, rejected and types variants) and
examples/, both sides load the modules, parse, check, print signatures and
build the model, and must agree exactly — the AST, every diagnostic with its
message, each module's printed .d.rx and the model. With --lower, every
program the checker accepts (each scenario table with its handler rows, as
compile/run.ts compiles it) is also lowered by both for the universal, dom
and ssr targets, and each module's output must be the same JavaScript
(comments dropped, both printed by esbuild). With RX_CRATE=1, the runners
take the crate's output instead of the reference's (ref/compile/lowered.ts):
compiled runs the scenarios on its universal target, and ssr serves its
ssr target and hydrates with its dom target. The "use server" extraction
(12 SF-7a) is not the compiler's: it is Solid's transformDirectives, a
bundler pass over the compiled module, which the runner applies to either
output alike. rxc compile app.rx --out dir [--target …] writes each
module with its source map onto its .rx (CP-1c) and the stylesheet;
ref/crate.ts --lower also checks every map: each mapped line is the
module's, each source and equation declared once is declared at a position
mapped to its line, and on dom every element's line is mapped.
In a Vite app, .rx goes through Solid's own Vite plugin with no option:
its frontends hook (solidjs/solid-vite-plugin, with the compiler change
solidjs/solid#4320 so the secondary passes take any extension) has .rx
built in, and loads its front end from the project's
@reactivescript/compiler/vite the first time an .rx module is compiled
(decision 7). That package is the crate's Node-API build
(compiler/src/node.rs, what rxc app does), one binary per platform as
@solidjs/compiler ships. An .rx module JavaScript imports is a
program's entry (JavaScript can import only an entry: a dependency is a
factory over the app, 09 MO-1b), so no option names it: the front end
compiles the program whole from it (CP-2), each module importing its
dependencies as ./x.rx?rx-dep, served from a program's build that contains
them; the build keys module(key, factory) by the factory, so a module two
programs share is one module. It returns the module's code for the
environment's generate, its map onto .rx, and on the entry the
stylesheet (CSS-4). Solid's plugin then takes it as it takes lowered .tsrx:
primitive names aside, its lazy, refresh and server-function passes (12
SF-7a, IDs hashed from the .rx path) and the stylesheet as a CSS module.
Every example app builds and runs in Chromium that way
(compiler/test/vite.mjs), each mounted with createApp from
@reactivescript/runtime, CP-4a's package (runtime/).
Ruled 2026-10-27 (Ryan, on the editor-support plan: spans replace lines).
An editor's features are TypeScript's, asked of the TSX projection (13
TY-1c), so every position has to cross between .rx and the projection.
- CP-8a (spans). The crate's AST records the byte range each node was
parsed from: every expression and statement (a kind plus its span), every
declaration (from its first keyword,
exportincluded), markup item and attribute (its name). A node the compiler makes has none (Span::NONE).ref/keeps lines: its AST is the grammar's specification, and the spans are where the crate's parser was, not a second grammar. - CP-8b (what a span promises). Inside the source and inside its
parent's span; a declaration's and a markup item's span starts on the
line the node records; a name's span is the name, a member's ends with
its property, an element's starts with its tag.
compiler/src/spans.rschecks this, andref/crate.tsruns it on every program. - CP-8c (the projection). The crate projects a checked program as
ref/types/project.tsdoes, text for text (ref/crate.ts --project), and records mappings: a piece of the projection that is a piece of the.rx, the same text on both sides — every name, member, literal, written type, tag and attribute the projection copies.rxc project app.rxprints the files, their mappings ([generated, source, length]) and the prelude;RX_CRATE=1 npm --prefix ref run typeschecks the scenarios on it. - CP-8d (diagnostics).
rxc check --jsongives each diagnostic and parse error a range, as an editor and an agent read it: 1-based lines, 0-based columns in UTF-16 code units (LSP's), and byte offsets. The checker is line-precise (CN-1a), so a diagnostic's range is its line's: from the first node that starts on it to the furthest any such node reaches on it (decision 9). - CP-8e (the editor). An editor's parse recovers: a declaration that
does not parse is a parse error on its line and is skipped to the next
line that starts at column 0, and a missing expression is an empty name,
so the rest of the module still checks and projects. On a module that
parses, the recovering parse gives the strict parse's tree and no errors
(
rxc conformchecks it on every program).rxc serveanswers a language server a line at a time: per file, with the editor's unsaved text as overlays, the projection with its mappings (exact text) and ranges (what was written for an expression or statement, for placing type errors) in UTF-16 offsets, and the file's parse errors, else its checker diagnostics (CP-8d).tooling/rx-language-serveris a Volar server over the same requests, andtooling/vscode-rxits VS Code client. The server runs the crate's WebAssembly build in process (decision 10).
- The helper API is CP-4b's (pinned 2026-10-26, taken by default —
veto here). Renamed from the reference's first names, so that a name says
which rule it lowers:
qsource→question,prevOf→pre,orInit→preOr,lv→firstPaint,cq→clientQuestion,cls→scopedClass,snap→snapshot,aw→awaited,raise→throw(07 decision 11),catchAcc→catchMemo,caughtRead→catchRead. The rest kept their names, most of them Solid's. compile_programis proposed upstream as the seam rather than vendoring Solid's back ends. If upstream declines, the fallback is a pinned fork exposing the same function — not a reimplementation.- The resolved model is recorded during the checker's walk (CP-6d, 2026-10-27, taken by default — veto here), not computed by a pass of its own over the AST. The checker already resolves every name and knows the scope it is in; a second pass would re-derive both, and could disagree.
- The crate matches
ref/beyond verdicts (CP-7, 2026-10-27, taken by default — veto here): CP-5 asks for equal verdicts; the crate also gives the same AST, diagnostic messages and signature text, so thatref/crate.tscan diff it byte for byte. The port keeps the reference's orders (insertion-ordered sets and maps) for that reason. - The crate's program carries the lowered module's layout, not
.rxpositions (CP-1c, 2026-10-27, taken by default — veto here). Solid's SSR props hoisting reads source order as meaning, so the program's spans must order declarations and elements as the module's text would;.rxpositions move to a map the crate keeps. The alternative is upstream: makedeclared_beforeindependent of spans (e.g. from statement order in the semantic model), which would let the program keep.rxspans. .rxpositions are lines (CP-1c, 2026-10-27). Superseded 2026-10-27 by CP-8 (Ryan: spans replace lines) for the AST and diagnostics. Source maps of the lowered module still map lines, below; they can now be made column-precise from the spans. A mapped position is the line of the declaration or markup it was lowered from, at that line's first non-blank column. The AST records lines (asref/'s does, for diagnostics), not columns; columns need a span on every expression in both parsers, which nothing in the spec asks for yet. A handler or action statement maps to its declaration's line..rxin Vite is Solid's plugin loading@reactivescript/compiler(CP-7, 2026-10-27, taken by default — veto here). The compiler ships on npm under thereactivescriptorg as@reactivescript/compiler, a Node-API build per platform (@reactivescript/compiler-<platform>), with its Vite front end at/vite; Solid's plugin resolves it from the project, so an app adds the package and nothing to its config. No WebAssembly fallback yet (StackBlitz and other unlisted platforms):@solidjs/compiler'swasm32-wasip1-threadsbuild shows how.- The projection maps only text it copies (CP-8c, 2026-10-27, taken
by default — veto here). A mapping joins the same text on both sides,
so a position inside it maps exactly and
ref/crate.ts --projectchecks every one. What the projection writes of its own (__.set(…),__.await_(…), a rewritten==) maps nowhere; an editor asking there gets the nearest mapped piece, as Volar does for Vue's and Svelte's. - A diagnostic's range is its line's (CP-8d, 2026-10-27, taken by default — veto here). The checker reports lines (CN-1a); narrowing each rule to the node it is about is per-rule work in both checkers, left for when an editor shows where it falls short.
- The language server runs the crate as WebAssembly (CP-8e,
2026-10-27, taken by default — veto here). Volar makes a file's virtual
code synchronously, so the requests are synchronous calls into the
crate built for
wasm32-unknown-unknown(compiler/src/wasm.rs, a C ABI over linear memory with the host reading files), loaded once: one.vsixfor every platform, about a millisecond per analysis.$RXCnames a nativerxcto run asrxc serveper request instead; both give the same answers (the LSP tests run on each). The Node-API build of decision 7 would serve the same requests, but one binary per platform; this build answers onlyserve.rs's requests, so it is not decision 7's missing WebAssembly fallback for compiling.