diff --git a/tsx-frontend/.gitignore b/tsx-frontend/.gitignore
new file mode 100644
index 0000000..5499bd9
--- /dev/null
+++ b/tsx-frontend/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.out/
+.canary/
diff --git a/tsx-frontend/README.md b/tsx-frontend/README.md
new file mode 100644
index 0000000..a9f9bd4
--- /dev/null
+++ b/tsx-frontend/README.md
@@ -0,0 +1,127 @@
+# reactivescript-tsx: moving-foundation TSX frontend for ReactiveScript
+
+Current direction: **Solid parity, subset first**. Run `npm run verify:subset` for the
+counter and component-composition checkpoint. Its scope is in [docs/SUBSET.md](docs/SUBSET.md).
+A passing subset is not full-port readiness; `npm run verify:pinned` remains the full gate.
+
+Experimental Oxc TSX frontend that lowers authored Solid-style TSX onto the pinned ReactiveScript `Program` and checker.
+Moved out of the original worktree (`/Users/devagr/reactivescript`, `proto/sound-tsx-frontend` @ f3c6771) into its own
+package so that upstream can move underneath it. Not a parity claim, not a shared IR, not a Rust frontend.
+Accepted/refused forms and semantic limits: [tsx/README.md](tsx/README.md), [tsx/MODULES.md](tsx/MODULES.md),
+[tsx/PARITY.md](tsx/PARITY.md). Measured results: [docs/RESULTS.md](docs/RESULTS.md).
+
+## In this repository (demo branch, not for merge)
+
+This copy lives at `tsx-frontend/` on branch `tsx-frontend-demo-main`, based on the clean pin 8518fa1e: nothing outside
+`tsx-frontend/` differs from the pin (`tsx/boundary.test.ts` checks that the pin is an ancestor of HEAD and that no
+file outside this directory differs from it). The sections below describe the original out-of-tree workspace. In-tree:
+
+- `#rx/*` maps to `reactivescript-ref/*`, a `file:../ref` dependency. Node does not accept package `imports` targets
+ outside the package, so `../ref/*` cannot be the target itself; the dependency resolves to this repository's `ref/`.
+- Solid comes from `file:../../solid-next/...`, the same checkout `ref/package.json` links.
+- `scripts/check-types.ts` treats `../ref/` diagnostics as upstream's; `baselines/upstream-tsc.txt` carries the same
+ 12 diagnostics with that path prefix.
+- Patch 0001 is not applied here. `patches/0001-server-question-keys.{md,patch}` stay as documentation of a proposed
+ core fix and its reproduction; the legacy prototype patches are not included.
+- `scripts/verify-pinned.sh`, `scripts/canary.sh` and `scripts/apply-patches.sh` need the out-of-tree workspace
+ (an `upstream` symlink to a patched worktree and a patch queue) and do not run in-tree.
+- `docs/RESULTS.md` was measured out-of-tree on pin + 0001. In-tree, only `npm run verify:subset` is claimed.
+
+### Running it
+
+```sh
+sh ref/scripts/solid-next.sh # repository root: clone and build Solid at ref/SOLID_NEXT into ../solid-next (pnpm, Rust)
+(cd ref && npm install) # upstream's own dependencies, used through #rx/*
+cd tsx-frontend
+npm install
+npm run verify:subset # typecheck, boundary, paired counter, composition, tracker demo, stale write-back: 25 tests
+```
+
+## Layout (workspace `/Users/devagr/rx-tsx-refresh`)
+
+| Path | What |
+| --- | --- |
+| `reactivescript/` | upstream checkout at the pinned commit, unmodified (branch `rx-pin`, push disabled) |
+| `reactivescript-patched/` | detached worktree of it at the same commit plus `frontend/patches/` (built by `scripts/apply-patches.sh`); `frontend/upstream` points here |
+| `solid-next/` | Solid at upstream's `ref/SOLID_NEXT`, built by upstream's `ref/scripts/solid-next.sh` (Rust 1.97.1) |
+| `frontend/` | this package (own git repo, own `package-lock.json`); `upstream -> ../reactivescript-patched` symlink |
+| `archive/` | snapshot of the original worktree: tracked patch, untracked tar, checksums, HEAD/index/status |
+| `scratch/original-suite-run/` | the original worktree re-materialized from `archive/` and run on the old Solid (coverage baseline) |
+| `logs/`, `tools/` | run logs; one-off review tools (baseline classifier, coverage diff) |
+
+Inside `frontend/`: `tsx/` lowering, loader, build, native primitives and all suites; `tsx/rx/` the adapter (the only
+code that imports upstream, via the package `imports` map `#rx/* -> ./upstream/ref/*`); `scripts/` workflow;
+`baselines/` reviewed expectations; `patches/` upstream patch queue (empty) plus legacy patches as evidence;
+`upstream-repros/` handwritten RX reproductions; `docs/`.
+
+## Pins (support only these; no version detection)
+
+`pins.json`: reactivescript `8518fa1ee28b135cabc8c6c151e3e7d9ef589070`, Solid `5e68925cc2fd216a9fb06345019e66d5da5c523c`
+(= upstream ref/SOLID_NEXT), Node 24.18.0, pnpm 11.20.0, Rust 1.97.1, oxc-parser 0.155.0, TypeScript 6.0.3,
+Playwright 1.59.1 (Chromium). `boundary.test.ts` fails if the checkouts drift from the pins.
+
+## Adapter boundary (`tsx/rx/`)
+
+- `ast.ts`: AST types plus constructors for the volatile forms only: `entryView`/`selectEntry` (MO-1c default
+ component entry), `serverDecl` (single SF-1 server form). Ordinary expressions are built inline by `lower.ts`.
+- `compiler.ts`: parser, checker, module loader, signatures, emitter, view lowering.
+- `facts.ts`: canonical semantic facts from the checker's resolved model (ids/lines canonicalized; read modes,
+ ordering, writes, calls, regions preserved) for paired TSX/RX comparison.
+- `infer.ts`: upstream AC-1b/AC-1c inference, used for the inferred-action decision below.
+- `runtime.ts`, `failure.ts`, `string-renderer.ts`: interpreter, compiled runtimes, failure/wire helpers
+ (browser-safe split) and runtime file paths: unavoidable private upstream imports.
+
+## Decisions forced by the new upstream
+
+- **Entry:** `export default function App()` lowers to `component App` with `isDefault` and the view ` ` (MO-1c).
+ `buildTsx(..., { mount })` / `--mount` selects an exported component as that same entry. Other default exports are refused.
+- **Server functions:** `serverFunction`/`serverAction` both lower to the single `server` declaration; the authored
+ loader/action intent stays frontend bookkeeping and upstream infers io (refusing io in reactive scope).
+- **Inferred actions:** upstream infers a plain arrow that writes and awaits as an action and holds writes made
+ before an `await`, which Solid applies immediately. Until [issue #20](https://github.com/ryansolid/reactivescript/issues/20) is decided, the TSX frontend
+ refuses every ordinary async callback at its `async` node, not only one that writes before an `await`, and points
+ to an explicit `action(function* () { ... yield request(); ... })`. That opts into held action semantics; it is not
+ a semantics-preserving rewrite. Synchronous refresh/affects callbacks are left to the checker and the differential
+ suites.
+- **Structured `untrack` captures:** `untrack(() => store.path)` returning an object/array lowers to an RX snapshot
+ (`peek`), which copies the value; Solid returns the live proxy. Accepted only when nothing can update the captured
+ object in place (not a derived store, no write inside the captured path); otherwise refused at the `untrack`.
+- **Imports:** the primitives module is now `reactivescript-tsx` (was `reactivescript-ref/tsx`, which named upstream's
+ package); same exports, renamed because the package is no longer part of upstream's `ref/`.
+
+## Commands (run in `frontend/`)
+
+| Command | What it runs |
+| --- | --- |
+| `npm run test:contracts` | fast: boundary/pin/representation contracts, paired counter, contracts suite |
+| `npm run check` | strict tsc of this package; upstream's own strict errors compared with `baselines/upstream-tsc.txt` |
+| `npm test` / `npm run test:browser` | whole ported suite / Chromium hydration suite |
+| `sh scripts/verify-pinned.sh [--rust]` | **READINESS** (default): exit 0 only if pins match, the upstream tree is its commit plus exactly the patch queue, types are clean, every suite and browser test passes, the whole coverage manifest ran, and the upstream runners ran (`--rust`: plus crate, `--lower`, `--project`) and match their pinned summaries. Every step's exit code is recorded and judged; the verdict is `VERDICT: READY` or `VERDICT: NOT READY` |
+| `sh scripts/verify-pinned.sh --compare` | **COMPARISON ONLY**: exit 0 when the run reproduces `baselines/observed-failures.json` (an unapproved observation of current failures and coverage gaps); still prints `VERDICT: NOT READY`. Integrity problems fail it too |
+| `sh scripts/canary.sh REV --dry-run` | cheap and labelled as such: fetch REV, report SOLID_NEXT and contract-surface/export changes; **runs no build and no tests** |
+| `sh scripts/canary.sh REV [--rust] [--compare]` | full rehearsal in a fresh temp dir: clone REV, build its SOLID_NEXT, apply the patch queue (reported separately if it no longer applies), `npm ci`, export this package's committed HEAD, run verify-pinned (readiness, or comparison with `--compare`) in canary mode |
+
+`baselines/coverage-manifest.json` lists every test that must run (the original suite's 513 identities with explicit
+renames, the tests added since, and the browser suite). `baselines/observed-failures.json` is an observation, not an
+approval; it is written only by hand (`tools/classify-baseline.cjs` in the workspace) and is ignored by readiness.
+`tsx/verifier.test.ts` proves that a known failure, a failing upstream runner, missing or truncated results and dropped
+coverage cannot yield READY.
+
+## Promotion procedure (moving the pin)
+
+1. `sh scripts/canary.sh --dry-run` to see SOLID_NEXT and contract-surface changes.
+2. `sh scripts/canary.sh --rust` to run everything in isolation and read `REPORT.md`.
+3. Fix adapter breakage in `tsx/rx/` (and lowering only where semantics changed). Re-run the canary until every
+ new failure is either fixed or understood.
+4. For every remaining failure decide: fix, explicit authored refusal with native evidence, or an upstream issue
+ with a handwritten RX repro (and, if narrow, a queued patch). Record decisions in docs/; re-check the patch queue's
+ removal conditions.
+5. Only then update `pins.json`, check out the new commits in `reactivescript/` and `solid-next/` (rebuild Solid with
+ upstream's script), rebuild `reactivescript-patched/` with `scripts/apply-patches.sh`, run `verify-pinned.sh --rust`,
+ refresh the observation by hand if needed, and commit pins, observation and docs together. A promotion is READY only
+ when readiness mode passes; until then the readiness verdict stays NOT READY.
+
+## Upstream patch policy
+
+See [patches/README.md](patches/README.md) and [docs/PATCHES.md](docs/PATCHES.md). The queue is empty at this pin.
+Upstream defects found are in [docs/UPSTREAM-ISSUES.md](docs/UPSTREAM-ISSUES.md) (not filed; filing needs approval).
diff --git a/tsx-frontend/baselines/coverage-manifest.json b/tsx-frontend/baselines/coverage-manifest.json
new file mode 100644
index 0000000..9339f11
--- /dev/null
+++ b/tsx-frontend/baselines/coverage-manifest.json
@@ -0,0 +1,595 @@
+{
+ "note": "Coverage manifest: every listed test must run. Built from the original suite (513 tests) with renames, plus tests added in tsx/boundary.test.ts, tsx/paired.test.ts, tsx/verifier.test.ts and the browser suite.",
+ "renames": {
+ "tsx/lower.test.ts :: later-declared children receive live direct and memo props and forward callbacks upward": "tsx/composition.test.ts :: later-declared children receive live direct and memo props and forward callbacks upward",
+ "tsx/lower.test.ts :: callback props can attach directly to onClick or forward through a zero-argument arrow": "tsx/composition.test.ts :: callback props can attach directly to onClick or forward through a zero-argument arrow",
+ "tsx/lower.test.ts :: rejects default export at an authored location": "tsx/lower.test.ts :: rejects non-component default export at an authored location",
+ "tsx/lower.test.ts :: async block handlers retain sequential writes, direct awaits, and returned values without actions": "tsx/lower.test.ts :: ordinary async block handlers are refused at the async arrow with or without pre-await writes; explicit generator actions lower (issue #20)",
+ "tsx/lower.test.ts :: async implementations are allowed for contextual void callback props": "tsx/lower.test.ts :: async implementations of contextual void callback props are refused at the async arrow (issue #20)",
+ "tsx/lower.test.ts :: async handlers refuse await positions requiring expression normalization": "tsx/lower.test.ts :: async handlers are refused before any await-position or body rule, for every body shape (issue #20)",
+ "tsx/boundary.test.ts :: inferred-action contract: upstream AC-1b/AC-1c facts the frontend refuses on": "tsx/boundary.test.ts :: inferred-action contract: upstream AC-1b/AC-1c facts; the frontend refuses that callback as ordinary async (issue #20)",
+ "tsx/browser.ts :: tsx async-handler streams HTML, adopts the same DOM on hydration, then updates on clicks": "tsx/browser.ts :: tsx async-handler is refused at its authored async callback (issue #20)",
+ "tsx/server-identities.test.ts :: server factory namespaces leave shared raw JS API flight keys unchanged": "tsx/server-identities.test.ts :: server fns with the same name in distinct modules keep distinct question identities"
+ },
+ "added": [
+ "tsx/boundary.test.ts",
+ "tsx/demo.test.ts",
+ "tsx/stale-write.test.ts",
+ "tsx/paired.test.ts",
+ "tsx/verifier.test.ts"
+ ],
+ "tests": [
+ "tsx/actions.test.ts :: native Solid generator actions hold status writes, roll back optimistic failure, and commit success",
+ "tsx/actions.test.ts :: lowered TSX generator actions hold status writes, roll back optimistic failure, and commit success",
+ "tsx/actions.test.ts :: handled generator action failures pass the core checker",
+ "tsx/actions.test.ts :: unhandled generator action failures require core UNCAUGHT_ACTION_FAILURE",
+ "tsx/async-bridge.test.ts :: async equations, JS contracts, failures and probes match native Solid",
+ "tsx/async-bridge.test.ts :: async equations, JS contracts, failures and probes match native Solid with Loading on",
+ "tsx/async-control.test.ts :: S-solid-41 async deferred questions block initially, then land independently with live pending probes",
+ "tsx/async-control.test.ts :: S-solid-26 quiet refresh stays quiet; affects holds an early reask landing until the gate commits",
+ "tsx/async-handlers.test.ts :: native Solid",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX",
+ "tsx/async-handlers.test.ts :: compiled authored TSX",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX",
+ "tsx/async-handlers.test.ts :: non-held async callbacks expose fresh and cached before-await frames and return values",
+ "tsx/async-handlers.test.ts :: native Solid #1",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #1",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #1",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #1",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #1",
+ "tsx/async-handlers.test.ts :: fresh and cached callback rejections reject their promises after finally without throwing from land",
+ "tsx/async-handlers.test.ts :: native Solid #2",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #2",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #2",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #2",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #2",
+ "tsx/async-handlers.test.ts :: async callback return survives an awaited finalizer",
+ "tsx/async-handlers.test.ts :: native Solid #3",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #3",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #3",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #3",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #3",
+ "tsx/async-handlers.test.ts :: async callback catch recovery survives an awaited finalizer",
+ "tsx/async-handlers.test.ts :: native Solid #4",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #4",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #4",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #4",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #4",
+ "tsx/async-handlers.test.ts :: plain-value awaits suspend and an awaited finalizer can override the return",
+ "tsx/async-handlers.test.ts :: native Solid #5",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #5",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #5",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #5",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #5",
+ "tsx/async-handlers.test.ts :: lexically async callbacks return promises even when returning or throwing before the await",
+ "tsx/async-handlers.test.ts :: native Solid #6",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #6",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #6",
+ "tsx/async-handlers.test.ts :: handwritten RX callbacks assimilate native promise arguments and returned promises",
+ "tsx/async-handlers.test.ts :: native Solid #7",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #7",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #7",
+ "tsx/async-handlers.test.ts :: awaits in nested RX closures do not turn their outer callback async",
+ "tsx/async-handlers.test.ts :: native Solid #8",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #8",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #8",
+ "tsx/async-handlers.test.ts :: an RX callback launching an inline held action stays synchronous",
+ "tsx/async-handlers.test.ts :: the direct scenario handler API retains synchronous frame arrays across flights",
+ "tsx/async-handlers.test.ts :: native Solid #9",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #9",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #9",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #6",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #6",
+ "tsx/async-handlers.test.ts :: synchronous callback pre-writes join an inline held action through success",
+ "tsx/async-handlers.test.ts :: native Solid #10",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #10",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #10",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #7",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #7",
+ "tsx/async-handlers.test.ts :: synchronous callback pre-writes join an inline held action through failure",
+ "tsx/async-handlers.test.ts :: native Solid #11",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #11",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #11",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #8",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #8",
+ "tsx/async-handlers.test.ts :: synchronous callback pre-writes join an inline held action through success with an optimistic overlay",
+ "tsx/async-handlers.test.ts :: native Solid #12",
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #12",
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #12",
+ "tsx/async-handlers.test.ts :: compiled authored TSX #9",
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #9",
+ "tsx/async-handlers.test.ts :: synchronous callback pre-writes join an inline held action through failure with an optimistic overlay",
+ "tsx/async-stream.test.ts :: native Solid",
+ "tsx/async-stream.test.ts :: emitted RX",
+ "tsx/async-stream.test.ts :: async memo loading values match native Solid",
+ "tsx/async-stream.test.ts :: native Solid #1",
+ "tsx/async-stream.test.ts :: emitted RX #1",
+ "tsx/async-stream.test.ts :: direct async iterable memo generations match native Solid",
+ "tsx/build.test.ts :: build uses Solid's universal backend after checking the shared AST",
+ "tsx/build.test.ts :: build uses Solid's dom backend after checking the shared AST",
+ "tsx/build.test.ts :: build uses Solid's ssr backend after checking the shared AST",
+ "tsx/build.test.ts :: exports do not mount without an explicit selection",
+ "tsx/build.test.ts :: an unknown component selection is refused",
+ "tsx/build.test.ts :: a missing entry cannot produce an empty emitted build",
+ "tsx/build.test.ts :: shared-checker errors stop emission",
+ "tsx/build.test.ts :: CLI checks a TSX entry and writes the selected target's module factories",
+ "tsx/build.test.ts :: CLI rejects unknown options instead of silently changing the build",
+ "tsx/callbacks.test.ts :: native Solid",
+ "tsx/callbacks.test.ts :: compiled TSX",
+ "tsx/callbacks.test.ts :: interpreter",
+ "tsx/callbacks.test.ts :: inline typed onInput receives the actual event target value",
+ "tsx/callbacks.test.ts :: native Solid #1",
+ "tsx/callbacks.test.ts :: compiled TSX #1",
+ "tsx/callbacks.test.ts :: interpreter #1",
+ "tsx/callbacks.test.ts :: parameterized component callbacks forward numbers through a child and relay",
+ "tsx/callbacks.test.ts :: native Solid #2",
+ "tsx/callbacks.test.ts :: compiled TSX #2",
+ "tsx/callbacks.test.ts :: interpreter #2",
+ "tsx/callbacks.test.ts :: forwarded event callback props preserve the payload across child and parent",
+ "tsx/callbacks.test.ts :: native Solid #3",
+ "tsx/callbacks.test.ts :: compiled TSX #3",
+ "tsx/callbacks.test.ts :: interpreter #3",
+ "tsx/callbacks.test.ts :: ordinary callback try catch finally handles success, failure, and early return",
+ "tsx/callbacks.test.ts :: native Solid #4",
+ "tsx/callbacks.test.ts :: compiled TSX #4",
+ "tsx/callbacks.test.ts :: interpreter #4",
+ "tsx/callbacks.test.ts :: a child catches its parent callback failure and still runs finally",
+ "tsx/callbacks.test.ts :: native Solid #5",
+ "tsx/callbacks.test.ts :: compiled TSX #5",
+ "tsx/callbacks.test.ts :: interpreter #5",
+ "tsx/callbacks.test.ts :: callback rethrows reach the event caller with their payload after both finalizers",
+ "tsx/callbacks.test.ts :: native Solid #6",
+ "tsx/callbacks.test.ts :: compiled TSX #6",
+ "tsx/callbacks.test.ts :: interpreter #6",
+ "tsx/callbacks.test.ts :: nested finalizers preserve completions unless their own return or throw overrides them",
+ "tsx/contracts.test.ts :: typed failure identities cross TSX exports, RX imports, and TSX boundary aliases",
+ "tsx/contracts.test.ts :: a typed TSX boundary does not discharge a different imported error class",
+ "tsx/contracts.test.ts :: a caller boundary cannot discharge failures read later by a child action",
+ "tsx/contracts.test.ts :: content-model signatures cross both module frontends before emission",
+ "tsx/contracts.test.ts :: source-context aliases retain their writable provision contract across modules",
+ "tsx/contracts.test.ts :: required source contexts are checked where the imported component is rendered",
+ "tsx/contracts.test.ts :: a source-context provider cannot combine a reader with a different source's writer",
+ "tsx/contracts.test.ts :: TSX server loaders retain side contracts and imported aliases in the shared compiler",
+ "tsx/contracts.test.ts :: an IO declaration does not become pure inside a TSX server loader",
+ "tsx/contracts.test.ts :: server generators cannot capture component state",
+ "tsx/derived-stores.test.ts :: native Solid",
+ "tsx/derived-stores.test.ts :: native Solid explicit null seed",
+ "tsx/derived-stores.test.ts :: compiled handwritten RX",
+ "tsx/derived-stores.test.ts :: interpreted handwritten RX",
+ "tsx/derived-stores.test.ts :: compiled authored TSX",
+ "tsx/derived-stores.test.ts :: interpreted authored TSX",
+ "tsx/derived-stores.test.ts :: S-solid-37 seedless derived stores suspend initially, re-ask by id and refresh quietly",
+ "tsx/derived-stores.test.ts :: native Solid #1",
+ "tsx/derived-stores.test.ts :: compiled handwritten RX #1",
+ "tsx/derived-stores.test.ts :: interpreted handwritten RX #1",
+ "tsx/derived-stores.test.ts :: compiled authored TSX #1",
+ "tsx/derived-stores.test.ts :: interpreted authored TSX #1",
+ "tsx/derived-stores.test.ts :: S-solid-37 held path writes pend only their key and latest sees the uncommitted path",
+ "tsx/derived-stores.test.ts :: native Solid #2",
+ "tsx/derived-stores.test.ts :: compiled handwritten RX #2",
+ "tsx/derived-stores.test.ts :: interpreted handwritten RX #2",
+ "tsx/derived-stores.test.ts :: compiled authored TSX #2",
+ "tsx/derived-stores.test.ts :: interpreted authored TSX #2",
+ "tsx/derived-stores.test.ts :: sync derived stores retain manual paths until an input changes, with stable root and captured nested aliases",
+ "tsx/derived-stores.test.ts :: native Solid #3",
+ "tsx/derived-stores.test.ts :: compiled handwritten RX #3",
+ "tsx/derived-stores.test.ts :: interpreted handwritten RX #3",
+ "tsx/derived-stores.test.ts :: compiled authored TSX #3",
+ "tsx/derived-stores.test.ts :: interpreted authored TSX #3",
+ "tsx/derived-stores.test.ts :: read-only derived stores keep a live root alias without path writers or pending probes",
+ "tsx/derived-stores.test.ts :: derived store result annotations do not invent purity for an imported loader",
+ "tsx/effect-semantics.test.ts :: plain Solid effects rerun for equal computes while RX effects keep EF-2 filtering",
+ "tsx/effect-semantics.test.ts :: RX effects over a memo equality cut match Solid's effect",
+ "tsx/effect-semantics.test.ts :: RX effect disposal follows Solid's reverse ownership order across sibling and nested effects",
+ "tsx/effect-semantics.test.ts :: a compute previous parameter uses a named packet equation, not the previous apply result",
+ "tsx/effect-semantics.test.ts :: RX effects retain cleanup through pending and suppress equal recovery, as Solid does",
+ "tsx/effect-semantics.test.ts :: RX effects apply changed async answers once after pending, as Solid does",
+ "tsx/effect-semantics.test.ts :: RX effects apply the first async answer after mounting pending, as Solid does",
+ "tsx/effect-semantics.test.ts :: ordinary RX effects still deliver compute errors as answers",
+ "tsx/effect-semantics.test.ts :: matching boundary payloads update without remounting fallback snapshots and state",
+ "tsx/effect-semantics.test.ts :: nested filtered boundary payloads update without remounting fallback snapshots and state",
+ "tsx/failure-identities.test.ts :: RX imported component failures and aliased boundaries share declaration identity",
+ "tsx/failure-identities.test.ts :: RX expression catches resolve aliases from a value signature",
+ "tsx/failure-identities.test.ts :: RX component prop and children catch summaries retain class identity",
+ "tsx/failure-identities.test.ts :: same-named classes in separate RX modules do not discharge each other",
+ "tsx/failure-identities.test.ts :: nested shadowed RX error declarations have distinct identities",
+ "tsx/failure-identities.test.ts :: same class imported under two aliases matches typed action catches and rethrows",
+ "tsx/failure-identities.test.ts :: RX rethrows retain every identity from a union catch",
+ "tsx/failure-identities.test.ts :: JS fails clauses and exported fn rejection rows resolve forward RX aliases",
+ "tsx/failure-identities.test.ts :: server function and action signatures print parameters and readable rejection identities",
+ "tsx/failure-identities.test.ts :: declared JS errors match aliases and declaration-local rejects through RX signatures",
+ "tsx/failure-identities.test.ts :: unknown JS promise defaults remain Error and declared io calls stay io",
+ "tsx/failure-identities.test.ts :: Panic boundaries do not discharge declared failure identities",
+ "tsx/failure-identities.test.ts :: written fails annotations and context contracts resolve imported class identities",
+ "tsx/features.test.ts :: native and emitted async handlers expose pre-await writes and return their Promise result",
+ "tsx/features.test.ts :: structured optimistic roots roll back across suspended actions without store conversion",
+ "tsx/features.test.ts :: authored RX effects use tuple samples and run returned cleanup before replacement",
+ "tsx/features.test.ts :: authored RX effects filter equal computed inputs without changing Solid effects",
+ "tsx/features.test.ts :: RX effects retain tuple samples and default scheduling in the shared AST",
+ "tsx/features.test.ts :: non-self previous preserves lazy fallback tracking and distinguishes null from undefined",
+ "tsx/features.test.ts :: same-setup previous accessors can be passed into later-mounted owners",
+ "tsx/features.test.ts :: array map preserves result shapes and caller tracking in memos and root updaters",
+ "tsx/features.test.ts :: map refuses unsupported callback arities and projection-root snapshots",
+ "tsx/features.test.ts :: typed store root updaters compose array spreads and tracked predicates",
+ "tsx/features.test.ts :: module sources and memos share component setup lowering",
+ "tsx/features.test.ts :: typed generator action arguments are passed through forwarding handlers",
+ "tsx/features.test.ts :: deferred scalar computations use the shared deferred equation",
+ "tsx/features.test.ts :: typed children slots forward live markup without stringification",
+ "tsx/features.test.ts :: module accessor-returning ref helpers instantiate once",
+ "tsx/features.test.ts :: local accessor-returning ref helpers instantiate once",
+ "tsx/features.test.ts :: keyed projections preserve row snapshots through reorder and replacement",
+ "tsx/features.test.ts :: exported module signals expose reader and setter aliases through shared modules",
+ "tsx/features.test.ts :: source context children read and update the original source",
+ "tsx/features.test.ts :: structured source contexts read value rows and write the original store paths",
+ "tsx/features.test.ts :: read-only accessor contexts preserve live payloads and default accessors",
+ "tsx/features.test.ts :: default-less contexts require a provider and preserve primitive snapshots",
+ "tsx/features.test.ts :: built-in Error filters catch declared failures in expressions",
+ "tsx/features.test.ts :: built-in Error filters catch declared failures at boundaries",
+ "tsx/features.test.ts :: imported failure aliases cannot use reserved Error or Panic identities",
+ "tsx/features.test.ts :: array predicate builtin references reject preceding and later lexical collisions",
+ "tsx/features.test.ts :: authored effect-sample names are reserved in apply scopes and cleanup closures",
+ "tsx/features.test.ts :: failure filters distinguish the global Error from shadowed or reserved names",
+ "tsx/features.test.ts :: parameterized callback contracts reject mismatched arguments and handlers",
+ "tsx/features.test.ts :: optimistic roots refuse projection proxies and mixed replacement element types",
+ "tsx/features.test.ts :: server cache invalidation refuses ambiguous Solid and ordinary memo getters",
+ "tsx/features.test.ts :: source context forwarding refuses an unestablished writable capability",
+ "tsx/features.test.ts :: untyped imperative catch fields do not assume thrown JS values are Errors",
+ "tsx/features.test.ts :: selective imperative catches preserve payloads, unmatched failures, and finalizers",
+ "tsx/features.test.ts :: selective imperative union guards leave primitive throws unmatched",
+ "tsx/features.test.ts :: raw Error guards are refused rather than widened to RX catch-all",
+ "tsx/features.test.ts :: declared failures preserve fields and filtered catch expressions",
+ "tsx/features.test.ts :: failure boundaries update filtered error accessors",
+ "tsx/features.test.ts :: failure boundaries update filtered error accessors and recover",
+ "tsx/features.test.ts :: JS call result annotations do not replace declaration purity contracts",
+ "tsx/features.test.ts :: bare JS imports remain usable as imperative calls without inventing result shapes",
+ "tsx/features.test.ts :: draft reads that require staged sequential values are explicitly rejected",
+ "tsx/features.test.ts :: heterogeneous structured arrays cannot silently borrow the first item shape",
+ "tsx/features.test.ts :: typed memo previous values use pre and recompute in the caller's reactive instant",
+ "tsx/features.test.ts :: derived scalar signals retain manual overrides until their input changes",
+ "tsx/features.test.ts :: store root aliases remain live while captured nested objects survive path replacement",
+ "tsx/features.test.ts :: projection aliases and nested captures preserve native replacement identity",
+ "tsx/features.test.ts :: structured projection-root snapshots require live alias support",
+ "tsx/features.test.ts :: generator action handlers attached directly",
+ "tsx/features.test.ts :: generator action handlers through a forwarding arrow",
+ "tsx/features.test.ts :: actions reject async arrows, delegated yields and untyped JS results",
+ "tsx/features.test.ts :: interpreter optimistic action truth commit",
+ "tsx/features.test.ts :: interpreter optimistic action rollback",
+ "tsx/features.test.ts :: primitive context providers snapshot once in owned regions and do not leak to siblings",
+ "tsx/features.test.ts :: primitive context providers capture the new mount value after Show remounts",
+ "tsx/features.test.ts :: context forms outside the tested primitive ownership subset fail explicitly",
+ "tsx/features.test.ts :: non-keyed Show callback receives a live accessor, not a snapshotted value",
+ "tsx/features.test.ts :: fire-and-forget memo refresh",
+ "tsx/features.test.ts :: fire-and-forget memo refresh with ambient affects",
+ "tsx/features.test.ts :: synchronous projection drafts start from an eager seed and retain their previous draft",
+ "tsx/features.test.ts :: eager module/component snapshots, pure typed helpers, strings, booleans, null, and untrack",
+ "tsx/features.test.ts :: Solid 2 store drafts, path writes, Show and default value-item For",
+ "tsx/features.test.ts :: split compute/apply createRxEffect lowers to RX's one effect form",
+ "tsx/features.test.ts :: plain Solid createEffect is refused instead of being given RX EF-2 semantics",
+ "tsx/features.test.ts :: structured signal identity is not silently changed to store semantics",
+ "tsx/features.test.ts :: Show preserves snapshots for truthy changes and resets them after hiding",
+ "tsx/features.test.ts :: default For passes values and preserves row snapshots when identities reorder",
+ "tsx/features.test.ts :: JSX entities decode strictly without decoding JS string expressions",
+ "tsx/features.test.ts :: native JSX text normalization: legacy entities",
+ "tsx/features.test.ts :: native JSX text normalization: CRLF tabs and blank lines",
+ "tsx/features.test.ts :: native JSX text normalization: bare carriage returns",
+ "tsx/features.test.ts :: native JSX text normalization: Unicode line separators",
+ "tsx/features.test.ts :: native JSX text normalization: Unicode whitespace",
+ "tsx/features.test.ts :: native JSX text normalization: empty continuation lines",
+ "tsx/features.test.ts :: native JSX text normalization: BOM is not Rust whitespace",
+ "tsx/keyed-async.test.ts :: S-store-3 structured keyed replacements preserve rows, quiet reorders, and dispose removed effects",
+ "tsx/keyed-async.test.ts :: async structured keyed replacements retain matching rows and suppress obsolete answers",
+ "tsx/keyed-async.test.ts :: async keyed row retention is isolated per projection with overlapping keys",
+ "tsx/keyed-async.test.ts :: DV-8 baseline: a projection-only reader retains rows and swallows later pending and failure",
+ "tsx/lower.test.ts :: counter: existing AST, checker, compiler and native Solid agree through four clicks",
+ "tsx/composition.test.ts :: later-declared children receive live direct and memo props and forward callbacks upward",
+ "tsx/composition.test.ts :: callback props can attach directly to onClick or forward through a zero-argument arrow",
+ "tsx/demo.test.ts :: demo tracker: TSX and handwritten RX have the same canonical checker facts",
+ "tsx/demo.test.ts :: demo tracker: snapshotting the filter in the handwritten RX is a fact difference, not normalized away",
+ "tsx/demo.test.ts :: demo tracker frames: native Solid",
+ "tsx/demo.test.ts :: demo tracker frames: compiled TSX",
+ "tsx/demo.test.ts :: demo tracker frames: compiled handwritten RX",
+ "tsx/demo.test.ts :: demo tracker frames: interpreted TSX",
+ "tsx/demo.test.ts :: demo tracker frames: interpreted handwritten RX",
+ "tsx/stale-write.test.ts :: stale write-back: native Solid runs the buggy TSX and loses the like made while the save was in flight",
+ "tsx/stale-write.test.ts :: stale write-back: the frontend refuses the authored TSX with upstream's STALE_WRITE_BACK, as the checker refuses the RX",
+ "tsx/stale-write.test.ts :: stale write-back: the functional setter is accepted and has the same facts as the fixed RX",
+ "tsx/stale-write.test.ts :: stale write-back: the fixed version keeps the concurrent like on native, compiled and interpreted backends",
+ "tsx/lower.test.ts :: declaration and JSX lines remain authored, and non-exported components stay non-exported",
+ "tsx/lower.test.ts :: import aliases, chained memos, signed initialization, nested text, and block handler",
+ "tsx/lower.test.ts :: numeric JSX conversion preserves Infinity, NaN, negative Infinity, and signed zero",
+ "tsx/lower.test.ts :: typed function-form stores retain structured question-source semantics",
+ "tsx/lower.test.ts :: only function-form stores retain native standing-question invalidation",
+ "tsx/lower.test.ts :: createPrevious lowers non-self history with a lazy matching fallback",
+ "tsx/lower.test.ts :: createPrevious cannot construct inherited history in another owned setup",
+ "tsx/lower.test.ts :: createPrevious refuses imported getters without companion memo ownership",
+ "tsx/lower.test.ts :: ordinary async block handlers are refused at the async arrow with or without pre-await writes; explicit generator actions lower (issue #20)",
+ "tsx/lower.test.ts :: async implementations of contextual void callback props are refused at the async arrow (issue #20)",
+ "tsx/lower.test.ts :: async handlers are refused before any await-position or body rule, for every body shape (issue #20)",
+ "tsx/lower.test.ts :: rejects destructured props parameter at an authored location",
+ "tsx/lower.test.ts :: rejects named props type at an authored location",
+ "tsx/lower.test.ts :: rejects optional prop contract at an authored location",
+ "tsx/lower.test.ts :: rejects readonly prop contract at an authored location",
+ "tsx/lower.test.ts :: rejects optional callback parameter contract at an authored location",
+ "tsx/lower.test.ts :: rejects callback return contract at an authored location",
+ "tsx/lower.test.ts :: rejects callback outside handler naming at an authored location",
+ "tsx/lower.test.ts :: rejects numeric handler-named prop at an authored location",
+ "tsx/lower.test.ts :: rejects reserved children prop at an authored location",
+ "tsx/lower.test.ts :: rejects reserved ref prop at an authored location",
+ "tsx/lower.test.ts :: rejects reserved prototype prop at an authored location",
+ "tsx/lower.test.ts :: rejects duplicate prop contract at an authored location",
+ "tsx/lower.test.ts :: rejects unknown component prop at an authored location",
+ "tsx/lower.test.ts :: rejects missing component prop at an authored location",
+ "tsx/lower.test.ts :: rejects accessor passed as numeric prop at an authored location",
+ "tsx/lower.test.ts :: rejects callback passed as numeric prop at an authored location",
+ "tsx/lower.test.ts :: rejects numeric prop called as accessor at an authored location",
+ "tsx/lower.test.ts :: rejects unknown prop read at an authored location",
+ "tsx/lower.test.ts :: rejects computed prop read at an authored location",
+ "tsx/lower.test.ts :: rejects callback used as numeric value at an authored location",
+ "tsx/lower.test.ts :: rejects callback invoked in memo at an authored location",
+ "tsx/lower.test.ts :: rejects numeric prop invoked as callback at an authored location",
+ "tsx/lower.test.ts :: rejects callback called with arguments at an authored location",
+ "tsx/lower.test.ts :: rejects assignment to numeric props at an authored location",
+ "tsx/lower.test.ts :: rejects setter passed as callback at an authored location",
+ "tsx/lower.test.ts :: rejects eager callback invocation at an authored location",
+ "tsx/lower.test.ts :: rejects local prop destructuring at an authored location",
+ "tsx/lower.test.ts :: rejects component spread at an authored location",
+ "tsx/lower.test.ts :: rejects duplicate component attribute at an authored location",
+ "tsx/lower.test.ts :: rejects component children at an authored location",
+ "tsx/lower.test.ts :: rejects unknown forwarded callback at an authored location",
+ "tsx/lower.test.ts :: rejects props import shadowing at an authored location",
+ "tsx/lower.test.ts :: rejects duplicate component binding at an authored location",
+ "tsx/lower.test.ts :: rejects direct recursive composition at an authored location",
+ "tsx/lower.test.ts :: rejects indirect recursive composition at an authored location",
+ "tsx/lower.test.ts :: rejects sibling local binding leakage at an authored location",
+ "tsx/lower.test.ts :: rejects accessor value is not a read at an authored location",
+ "tsx/lower.test.ts :: rejects memo cannot use accessor value at an authored location",
+ "tsx/lower.test.ts :: rejects unknown accessor at an authored location",
+ "tsx/lower.test.ts :: rejects accessor arguments at an authored location",
+ "tsx/lower.test.ts :: rejects async component at an authored location",
+ "tsx/lower.test.ts :: rejects async memo at an authored location",
+ "tsx/lower.test.ts :: rejects async expression handler at an authored location",
+ "tsx/lower.test.ts :: rejects signal options at an authored location",
+ "tsx/lower.test.ts :: rejects memo options at an authored location",
+ "tsx/lower.test.ts :: rejects external reactive primitive without a supported value contract at an authored location",
+ "tsx/lower.test.ts :: rejects extra import at an authored location",
+ "tsx/lower.test.ts :: rejects namespace import at an authored location",
+ "tsx/lower.test.ts :: rejects type import at an authored location",
+ "tsx/lower.test.ts :: rejects arbitrary call at an authored location",
+ "tsx/lower.test.ts :: rejects untyped handler parameter at an authored location",
+ "tsx/lower.test.ts :: rejects untyped previous-value memo parameter at an authored location",
+ "tsx/lower.test.ts :: rejects untyped props at an authored location",
+ "tsx/lower.test.ts :: rejects local import shadow at an authored location",
+ "tsx/lower.test.ts :: rejects mutable binding at an authored location",
+ "tsx/lower.test.ts :: rejects generic call at an authored location",
+ "tsx/lower.test.ts :: rejects overflowing numeric literal at an authored location",
+ "tsx/lower.test.ts :: rejects typed binding at an authored location",
+ "tsx/lower.test.ts :: rejects non-component default export at an authored location",
+ "tsx/lower.test.ts :: rejects object JSX at an authored location",
+ "tsx/lower.test.ts :: rejects unknown component JSX at an authored location",
+ "tsx/lower.test.ts :: rejects spread attribute at an authored location",
+ "tsx/lower.test.ts :: parser failures have authored locations, including UTF-16 offsets after non-ASCII text",
+ "tsx/modules.test.ts :: tsx imports tsx: aliases, live props and callbacks cross module factories",
+ "tsx/modules.test.ts :: tsx imports rx: aliases, live props and callbacks cross module factories",
+ "tsx/modules.test.ts :: rx imports tsx: aliases, live props and callbacks cross module factories",
+ "tsx/modules.test.ts :: rx imports rx: aliases, live props and callbacks cross module factories",
+ "tsx/modules.test.ts :: a diamond reads and lowers each module once",
+ "tsx/modules.test.ts :: missing entry is a loader diagnostic, not an empty successful build",
+ "tsx/modules.test.ts :: missing imports retain the importing filename and line",
+ "tsx/modules.test.ts :: mixed-language cycles are rejected before lowering incomplete imports",
+ "tsx/modules.test.ts :: RX declaration files retain JS purity contracts in a mixed graph",
+ "tsx/modules.test.ts :: module paths normalize without guessing extensions",
+ "tsx/modules.test.ts :: a shared missing dependency cannot accidentally lower a second incomplete importer",
+ "tsx/modules.test.ts :: unknown RX imports are still checked against exported signatures",
+ "tsx/modules.test.ts :: importing a source does not permit writing through the module boundary",
+ "tsx/modules.test.ts :: JS declaration contracts do not silently make a dirty TSX import pure",
+ "tsx/modules.test.ts :: an RX component's failure contract remains checked at a TSX application",
+ "tsx/modules.test.ts :: tsx context identity survives aliases in a tsx reader",
+ "tsx/modules.test.ts :: tsx context identity survives aliases in a rx reader",
+ "tsx/modules.test.ts :: rx context identity survives aliases in a tsx reader",
+ "tsx/modules.test.ts :: rx context identity survives aliases in a rx reader",
+ "tsx/modules.test.ts :: tsx source-context writes cross aliases in a tsx reader",
+ "tsx/modules.test.ts :: tsx source-context writes cross aliases in a rx reader",
+ "tsx/modules.test.ts :: rx source-context writes cross aliases in a tsx reader",
+ "tsx/modules.test.ts :: rx source-context writes cross aliases in a rx reader",
+ "tsx/owned-helpers.test.ts :: accessor-returning helpers keep ref arguments live and snapshot value arguments and locals",
+ "tsx/owned-helpers.test.ts :: accessor-returning helpers retain owned signal and effect state across ref updates",
+ "tsx/owned-helpers.test.ts :: Solid Element children render caller-owned live nodes instead of stringifying them",
+ "tsx/owned-lifetimes.test.ts :: functional writes discard transient predicates but retain stored closure provenance",
+ "tsx/owned-lifetimes.test.ts :: native: S-region-7 structured context registration and cleanup",
+ "tsx/owned-lifetimes.test.ts :: compiled/interpreter: S-region-7 structured context registration and cleanup",
+ "tsx/owned-lifetimes.test.ts :: native: Show helper retains live refs, disposes effects, and captures a fresh remount seed",
+ "tsx/owned-lifetimes.test.ts :: compiled/interpreter: Show helper retains live refs, disposes effects, and captures a fresh remount seed",
+ "tsx/owned-lifetimes.test.ts :: native: keyed row helpers survive reorder, dispose on removal, and remount after readdition",
+ "tsx/owned-lifetimes.test.ts :: compiled/interpreter: keyed row helpers survive reorder, dispose on removal, and remount after readdition",
+ "tsx/previous.test.ts :: native S-core-1 previous values survive equal writes, batching, and hiding the current reader",
+ "tsx/previous.test.ts :: a same-setup companion retains history before the previous accessor is first displayed",
+ "tsx/previous.test.ts :: a companion created later cannot reconstruct an inherited target's earlier history",
+ "tsx/previous.test.ts :: same-owner Show demand preserves warm history and records the existing cold-target baseline divergence",
+ "tsx/previous.test.ts :: previous follows original memo equality cuts rather than every upstream write or accessor read",
+ "tsx/previous.test.ts :: chained previous accessors equality-cut repeated intermediate answers",
+ "tsx/previous.test.ts :: seeded previous falls back on undefined history values, but never on null",
+ "tsx/previous.test.ts :: source previous coalesces multiple writes in one handler",
+ "tsx/previous.test.ts :: same-setup history can be consumed later, but inherited targets fail closed at setup",
+ "tsx/previous.test.ts :: unseeded history is undefined and the public overloads preserve accessor result types",
+ "tsx/previous.test.ts :: fallbacks are lazy, caller-tracked, and used whenever the previous value is undefined",
+ "tsx/previous.test.ts :: null, false, zero and empty-string previous values do not trigger the fallback",
+ "tsx/previous.test.ts :: failed input attempts are skipped, and successful equal-value recovery advances the companion",
+ "tsx/previous.test.ts :: authored and raw RX previous recover from failed current inputs with native packet history",
+ "tsx/previous.test.ts :: initial pending bypasses the fallback and starts history only after the first answer",
+ "tsx/previous.test.ts :: pending recomputes skip history until their answer settles",
+ "tsx/previous.test.ts :: previous companions dispose with their target owner and start fresh on remount",
+ "tsx/previous.test.ts :: native S-store-2 projection uses cross-node previous id while untouched paths stay quiet",
+ "tsx/primitives.test.ts :: defineError and raiseFailure use shared failure identity, fields and class matching",
+ "tsx/primitives.test.ts :: catchFailure handles matching synchronous failures without catching fallback failures",
+ "tsx/primitives.test.ts :: catchFailure maps controlled promise rejection and retains unmatched rejection identity",
+ "tsx/primitives.test.ts :: catchFailure maps a controlled stream failure after preserving its earlier answers",
+ "tsx/primitives.test.ts :: catchFailure forwards stream cancellation to the source",
+ "tsx/primitives.test.ts :: global Error is catchFailure's top class for throws, rejections and stream failures",
+ "tsx/primitives.test.ts :: a declared error named Error stays filtered rather than becoming the top class",
+ "tsx/primitives.test.ts :: global Error boundaries catch thrown and rejected values while Loading still owns pending",
+ "tsx/primitives.test.ts :: catchFailure never turns native pending into a failure, including promises and streams",
+ "tsx/primitives.test.ts :: FailureBoundary gives the fallback a real native error accessor and reset",
+ "tsx/primitives.test.ts :: FailureBoundary rethrows unmatched failures to the enclosing native Errored",
+ "tsx/primitives.test.ts :: matching failure payloads stay live without remounting fallback snapshots or local state",
+ "tsx/primitives.test.ts :: filtered nested failures rethrow every new payload and react to a changed matching class",
+ "tsx/primitives.test.ts :: native Loading still owns pending through catchFailure and FailureBoundary",
+ "tsx/primitives.test.ts :: source contexts share canonical native Signal tuples with lexical provider shadowing",
+ "tsx/primitives.test.ts :: structured source contexts retain canonical store pairs and lexical provider identity",
+ "tsx/primitives.test.ts :: source pair types preserve scalar signals and reject object signals as store providers",
+ "tsx/primitives.test.ts :: source context inference keeps natural scalar and array destructuring fully typed",
+ "tsx/primitives.test.ts :: source contexts fail explicitly without a provider or owner",
+ "tsx/primitives.test.ts :: serverFunction copies arguments and results across its real in-process wire",
+ "tsx/primitives.test.ts :: serverAction copies arguments and results across its real in-process wire",
+ "tsx/primitives.test.ts :: server generator drivers resume promises and throw rejections back into the server body",
+ "tsx/primitives.test.ts :: server failures preserve declared classes and sanitize undeclared throws and rejections",
+ "tsx/primitives.test.ts :: server wire rejects non-data arguments before execution and non-data results after execution",
+ "tsx/primitives.test.ts :: server drivers detach the native caller owner and dependencies while the callsite remains reactive",
+ "tsx/primitives.test.ts :: serverFunction deduplicates equal arguments across readers and reuses earlier flights",
+ "tsx/primitives.test.ts :: new loader factories in independent native owners do not share a cache",
+ "tsx/primitives.test.ts :: result serialization failures are sanitized but wire value diagnostics remain explicit",
+ "tsx/primitives.test.ts :: server memo refresh reasks only its reader, records cache hits, and retains other argument entries",
+ "tsx/primitives.test.ts :: server refresh invalidation stays within its factory and leaves unrelated memos alone",
+ "tsx/primitives.test.ts :: native Solid refresh and ordinary memos do not implicitly invalidate loader caches",
+ "tsx/primitives.test.ts :: server memo refresh evicts cached rejections without rerunning other failed readers",
+ "tsx/primitives.test.ts :: server memo refresh supersedes pending answers without discarding older argument cache entries",
+ "tsx/primitives.test.ts :: server memo forwards previous values and native memo options without creating another reactive layer",
+ "tsx/primitives.test.ts :: server memo request collection stops when an async compute returns its promise",
+ "tsx/primitives.test.ts :: wire argument cache keys distinguish types, delimiters, special numbers and data structures",
+ "tsx/primitives.test.ts :: wire cache keys canonicalize record order recursively while retaining array order",
+ "tsx/primitives.test.ts :: typed loader cache keys isolate cached rejections and targeted refresh",
+ "tsx/rx-effects.test.ts :: RX effects equality-filter direct inputs without disposing their cleanup",
+ "tsx/rx-effects.test.ts :: RX effects isolate synchronous and upstream errors per input and recover",
+ "tsx/rx-effects.test.ts :: consecutive RX error answers compare equal and retain cleanup",
+ "tsx/rx-effects.test.ts :: RX effects preserve cleanup through pending then false",
+ "tsx/rx-effects.test.ts :: RX effects preserve cleanup through pending then true",
+ "tsx/rx-effects.test.ts :: RX effects preserve cleanup through pending then reject",
+ "tsx/rx-effects.test.ts :: RX effects skip a pending mount, including an earlier errored input",
+ "tsx/rx-effects.test.ts :: RX effects do not flatten array/function answers and compare inputs with Object.is",
+ "tsx/rx-effects.test.ts :: RX input equations are cached independently, including function and array allocations",
+ "tsx/rx-effects.test.ts :: RX effects retain cleanup when pending is disposed and never apply a late landing",
+ "tsx/rx-effects.test.ts :: a pending RX tuple compares every input with its last received tuple (0)",
+ "tsx/rx-effects.test.ts :: a pending RX tuple compares every input with its last received tuple (1)",
+ "tsx/rx-effects.test.ts :: native RX effects compute but never apply or allocate client boundaries during SSR",
+ "tsx/rx-effects.test.ts :: RX apply reads are untracked, zero-input effects mount once, and native effects stay native",
+ "tsx/rx-effects.test.ts :: RX effect cleanup follows reverse sibling and nested ownership, even after equal inputs",
+ "tsx/rx-effects.test.ts :: authored RX and native RX effects retain cleanup on equal direct computes",
+ "tsx/rx-effects.test.ts :: authored RX effects deliver per-input error answers and filter consecutive errors",
+ "tsx/rx-effects.test.ts :: RX effect errors are opaque answers for string coercion and equality",
+ "tsx/rx-effects.test.ts :: authored RX pending then false preserves the native RX lifecycle",
+ "tsx/rx-effects.test.ts :: authored RX pending then true preserves the native RX lifecycle",
+ "tsx/rx-effects.test.ts :: authored RX pending then reject preserves the native RX lifecycle",
+ "tsx/semantics.test.ts :: scalar snapshots stay fixed while string/boolean derivations remain live",
+ "tsx/semantics.test.ts :: ordinary helpers receive current values without turning helper locals into equations",
+ "tsx/semantics.test.ts :: nested store draft writes preserve native primitive display and live memo reads",
+ "tsx/semantics.test.ts :: handler writes preserve native Solid committed-read ordering",
+ "tsx/semantics.test.ts :: fragments, ordinary attributes, decoded entities and multiline whitespace match native Solid",
+ "tsx/semantics.test.ts :: structured projections update their draft from reactive inputs",
+ "tsx/semantics.test.ts :: derived sources accept manual writes and rederive when their inputs change",
+ "tsx/semantics.test.ts :: a memo's previous-value parameter follows the node's latest change",
+ "tsx/semantics.test.ts :: explicit table sections retain live component cells across updates",
+ "tsx/semantics.test.ts :: browser-repaired content models fail at their authored component application",
+ "tsx/server-identities.test.ts :: same-named server factories in distinct components do not share loader answers",
+ "tsx/server-identities.test.ts :: instances of one component keep server arguments and loader caches separate",
+ "tsx/server-identities.test.ts :: refresh uses the actual getter after argument changes and cached argument reuse",
+ "tsx/server-identities.test.ts :: same-named server actions in different instances remain fresh requests",
+ "tsx/server-identities.test.ts :: serverFunction detaches initial, rejection and continuation steps from the client owner",
+ "tsx/server-identities.test.ts :: serverAction detaches initial, rejection and continuation steps from the client owner",
+ "tsx/server-identities.test.ts :: server loader keys preserve wire argument types, delimiters and numeric edge cases",
+ "tsx/server-identities.test.ts :: server fns with the same name in distinct modules keep distinct question identities",
+ "tsx/server-identities.test.ts :: server setup is detached too; loaders reuse and actions never reuse requests",
+ "tsx/setup-values.test.ts :: explicit untrack and earlier snapshots lower to source := peek; literals need no peek",
+ "tsx/setup-values.test.ts :: bare tracked setup reads are reported at the authored line, never peeked",
+ "tsx/setup-values.test.ts :: a createSignal seed does not authorize the bare setup read it snapshots",
+ "tsx/setup-values.test.ts :: setup consts are immutable to authored TSX, in their module and through imports",
+ "tsx/setup-values.test.ts :: an RX importer reads exported TSX consts but cannot write them (MO-2)",
+ "tsx/setup-values.test.ts :: an RX source imported into TSX setup is a tracked read, and its untracked snapshot is not certified total",
+ "tsx/setup-values.test.ts :: inert pure-helper locals are substituted into a plain fn tail, including under a branch",
+ "tsx/setup-values.test.ts :: pure-helper locals that could fail, suspend, allocate or read state are refused, not substituted",
+ "tsx/setup-values.test.ts :: a same-named const in a sibling component does not snapshot a signal read",
+ "tsx/setup-values.test.ts :: nested owners with a same-named const do not authorize a signal read",
+ "tsx/setup-values.test.ts :: an unrelated inert const of the same name does not make a helper's reactive local substitutable",
+ "tsx/setup-values.test.ts :: a createSignal seed peeks only its own owner's snapshots",
+ "tsx/setup-values.test.ts :: legitimate earlier and imported snapshots are still peeked by binding",
+ "tsx/setup-values.test.ts :: failing setup snapshots are refused even when read inside an inner boundary, a branch, or through helpers and memos",
+ "tsx/setup-values.test.ts :: props snapshots are accepted only when every application site in this module passes a total value",
+ "tsx/setup-values.test.ts :: locally caught, signal and literal snapshots remain accepted",
+ "tsx/setup-values.test.ts :: recursive helpers and failing error messages are not certified total in eager initializers",
+ "tsx/setup-values.test.ts :: props facts follow the component declaration, even for siblings on one line",
+ "tsx/snapshot-core.test.ts :: const is eager even when its first read happens after source writes",
+ "tsx/snapshot-core.test.ts :: component const captures mount state once, including pure calls, records and arrays",
+ "tsx/snapshot-core.test.ts :: new branch and component snapshots see the mounting pass and resnapshot only on reentry",
+ "tsx/snapshot-core.test.ts :: bound function initialization supports local const snapshots of ref parameters",
+ "tsx/snapshot-core.test.ts :: pure helper locals are substituted into a plain RX fn that reevaluates for every caller value",
+ "tsx/snapshot-core.test.ts :: a pure helper read is tracked in an equation and fixed in a snapshot source",
+ "tsx/snapshot-core.test.ts :: imported pure helpers remain ordinary functions under an alias",
+ "tsx/snapshot-core.test.ts :: own-pre memo accumulation advances once per reactive instant",
+ "tsx/snapshot-core.test.ts :: own-pre draft seeds retain the previous instant rather than a settle-pass result",
+ "tsx/snapshot-core.test.ts :: aliased imported instantiating helpers keep their local compiled name",
+ "tsx/snapshot-core.test.ts :: eager snapshots do not turn effect reads into dependencies on their original sources",
+ "tsx/snapshot-core.test.ts :: eager TSX initializers refuse bindings initialized later, directly and through pure helpers",
+ "tsx/snapshot-core.test.ts :: equation dependencies do not reorder eager snapshot and source initialization",
+ "tsx/snapshot-core.test.ts :: exported snapshot sources are fixed values, shared by import aliases and read-only to importers",
+ "tsx/snapshot-core.test.ts :: snapshot sources keep source-line diagnostics",
+ "tsx/snapshot-core.test.ts :: snapshot initializers do not make unknown imports pure",
+ "tsx/snapshot-core.test.ts :: snapshot failures surface at readers, and TSX refuses unused eager consts whose initializer could throw",
+ "tsx/snapshot-core.test.ts :: TSX setup consts refuse untracked async reads instead of snapshotting a pending value",
+ "tsx/snapshot-core.test.ts :: const function aliases do not conceal client-state captures from server bodies",
+ "tsx/snapshot-core.test.ts :: ordinary missing-row access panics while optional member and index access return undefined",
+ "tsx/snapshot-core.test.ts :: optional access boxes primitives and skips computed keys, arguments and chain continuations",
+ "tsx/snapshot-core.test.ts :: member and index access propagate pending and error values instead of treating them as nullish",
+ "tsx/store-inference.test.ts :: typed read-only questions stay structured across shadowed component writes",
+ "tsx/store-inference.test.ts :: typed read-only questions stay structured across shadowed branch writes",
+ "tsx/store-inference.test.ts :: root-written questions retain the supported atomic lowering",
+ "tsx/store-inference.test.ts :: a shadowed context root write does not reach another provision",
+ "tsx/store-inference.test.ts :: root writes through an imported context alias reach the provided question",
+ "tsx/boundary.test.ts :: only tsx/rx/ imports the upstream checkout",
+ "tsx/boundary.test.ts :: pinned stack: reactivescript, its ref/SOLID_NEXT and the Solid checkout agree with pins.json",
+ "tsx/boundary.test.ts :: entry representation: the adapter's default-component entry is what upstream's parser produces (MO-1c)",
+ "tsx/boundary.test.ts :: server representation: one server declaration kind, io inferred by the checker (SF-1)",
+ "tsx/boundary.test.ts :: model contract: canonical facts keep read modes, writes and region nesting",
+ "tsx/boundary.test.ts :: inferred-action contract: upstream AC-1b/AC-1c facts; the frontend refuses that callback as ordinary async (issue #20)",
+ "tsx/boundary.test.ts :: ordinary async callbacks are refused at their authored async node on every resolved callback route (issue #20)",
+ "tsx/boundary.test.ts :: actions refuse async functions and async generators with synchronous-generator guidance (issue #20)",
+ "tsx/boundary.test.ts :: explicit generator actions and async memo/server computations stay accepted beside the refusal (issue #20)",
+ "tsx/paired.test.ts :: paired counter: TSX and RX have the same canonical semantic facts",
+ "tsx/paired.test.ts :: paired counter: a different read mode is a fact difference, not normalized away",
+ "tsx/paired.test.ts :: paired counter: interpreter, compiled and native frames agree",
+ "tsx/verifier.test.ts :: verifier: a complete passing run is READY",
+ "tsx/verifier.test.ts :: verifier: a known (observed) frontend failure is never READY; comparison can only say it reproduces",
+ "tsx/verifier.test.ts :: verifier: a failing upstream runner or type check fails both modes, whatever its log says",
+ "tsx/verifier.test.ts :: verifier: missing or truncated results fail both modes",
+ "tsx/verifier.test.ts :: verifier: dropped coverage is never READY and must be an observed gap to compare equal",
+ "tsx/browser.ts :: rx async-handler streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx async-handler is refused at its authored async callback (issue #20)",
+ "tsx/browser.ts :: native async-handler streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx input-events streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx input-events streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx branch-remount streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx branch-remount streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx keyed-rows streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx keyed-rows streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx counter streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx counter streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx async streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx async streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx two-input-effect streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx two-input-effect streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx rx-effect streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx rx-effect streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: native rx-effect streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx stream streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx stream streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx context streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx context streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx source-context streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx source-context streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx typed-failure streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx typed-failure streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx server-loader streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx server-loader streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: rx server-factories streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: tsx server-factories streams HTML, adopts the same DOM on hydration, then updates on clicks",
+ "tsx/browser.ts :: native server-factories streams HTML, adopts the same DOM on hydration, then updates on clicks"
+ ]
+}
diff --git a/tsx-frontend/baselines/observed-failures.json b/tsx-frontend/baselines/observed-failures.json
new file mode 100644
index 0000000..1015275
--- /dev/null
+++ b/tsx-frontend/baselines/observed-failures.json
@@ -0,0 +1,415 @@
+{
+ "status": "OBSERVATION ONLY, NOT APPROVED. The migration's failures and coverage gaps as last measured; used by verify-pinned.sh --compare to detect change. It does not make any of them acceptable, and readiness ignores it.",
+ "observed": "2026-10-29 at reactivescript aa85cbde + patches/0001 / solid ac28157",
+ "counts": {
+ "upstream interpreter divergence": 17,
+ "upstream behavior (handwritten RX reproduces)": 20,
+ "upstream defect": 17,
+ "legacy core regression (patch not carried)": 23,
+ "upstream semantic change (AC-1b/AC-1c)": 2,
+ "upstream interpreter divergence (single test)": 11,
+ "upstream rule change": 4,
+ "native behavior change": 2,
+ "paired behavior mismatch": 2,
+ "upstream behavior difference": 1
+ },
+ "failures": {
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #5": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #5": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: lexically async callbacks return promises even when returning or throwing before the await": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #7": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled)"
+ },
+ "tsx/async-handlers.test.ts :: awaits in nested RX closures do not turn their outer callback async": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled)"
+ },
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #8": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled)"
+ },
+ "tsx/async-handlers.test.ts :: an RX callback launching an inline held action stays synchronous": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled)"
+ },
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #9": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #9": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #6": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: synchronous callback pre-writes join an inline held action through success": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #10": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #10": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #7": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: synchronous callback pre-writes join an inline held action through failure": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #11": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #11": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #8": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: synchronous callback pre-writes join an inline held action through success with an optimistic overlay": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: compiled handwritten RX #12": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted handwritten RX #12": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: interpreted authored TSX #9": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/async-handlers.test.ts :: synchronous callback pre-writes join an inline held action through failure with an optimistic overlay": {
+ "category": "upstream behavior (handwritten RX reproduces)",
+ "note": "handwritten RX legs fail too (rx-compiled, rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/callbacks.test.ts :: interpreter #3": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (tsx-interpreted)"
+ },
+ "tsx/callbacks.test.ts :: ordinary callback try catch finally handles success, failure, and early return": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (tsx-interpreted)"
+ },
+ "tsx/callbacks.test.ts :: interpreter #4": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (tsx-interpreted)"
+ },
+ "tsx/callbacks.test.ts :: a child catches its parent callback failure and still runs finally": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (tsx-interpreted)"
+ },
+ "tsx/callbacks.test.ts :: interpreter #5": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (tsx-interpreted)"
+ },
+ "tsx/callbacks.test.ts :: callback rethrows reach the event caller with their payload after both finalizers": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (tsx-interpreted)"
+ },
+ "tsx/callbacks.test.ts :: interpreter #6": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (tsx-interpreted)"
+ },
+ "tsx/callbacks.test.ts :: nested finalizers preserve completions unless their own return or throw overrides them": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (tsx-interpreted)"
+ },
+ "tsx/contracts.test.ts :: typed failure identities cross TSX exports, RX imports, and TSX boundary aliases": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/derived-stores.test.ts :: interpreted handwritten RX": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/derived-stores.test.ts :: interpreted authored TSX": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/derived-stores.test.ts :: S-solid-37 seedless derived stores suspend initially, re-ask by id and refresh quietly": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/derived-stores.test.ts :: interpreted handwritten RX #1": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/derived-stores.test.ts :: interpreted authored TSX #1": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/derived-stores.test.ts :: S-solid-37 held path writes pend only their key and latest sees the uncommitted path": {
+ "category": "upstream interpreter divergence",
+ "note": "only interpreted legs fail; the compiled legs of the same program match native (rx-interpreted, tsx-interpreted)"
+ },
+ "tsx/effect-semantics.test.ts :: RX effect disposal follows Solid's reverse ownership order across sibling and nested effects": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/effect-semantics.test.ts :: a compute previous parameter uses a named packet equation, not the previous apply result": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/effect-semantics.test.ts :: matching boundary payloads update without remounting fallback snapshots and state": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/effect-semantics.test.ts :: nested filtered boundary payloads update without remounting fallback snapshots and state": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/failure-identities.test.ts :: RX imported component failures and aliased boundaries share declaration identity": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: RX expression catches resolve aliases from a value signature": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: RX component prop and children catch summaries retain class identity": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: same-named classes in separate RX modules do not discharge each other": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: nested shadowed RX error declarations have distinct identities": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: same class imported under two aliases matches typed action catches and rethrows": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: RX rethrows retain every identity from a union catch": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: JS fails clauses and exported fn rejection rows resolve forward RX aliases": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: server function and action signatures print parameters and readable rejection identities": {
+ "category": "upstream defect",
+ "note": "signatures print the importer's alias (Lost) instead of the declared class (Missing): the aliased identity defect (docs/UPSTREAM-ISSUES.md #2)"
+ },
+ "tsx/failure-identities.test.ts :: declared JS errors match aliases and declaration-local rejects through RX signatures": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: unknown JS promise defaults remain Error and declared io calls stay io": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/failure-identities.test.ts :: written fails annotations and context contracts resolve imported class identities": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/features.test.ts :: native and emitted async handlers expose pre-await writes and return their Promise result": {
+ "category": "upstream semantic change (AC-1b/AC-1c)",
+ "note": "a plain callback that writes and awaits is an inferred action upstream"
+ },
+ "tsx/features.test.ts :: same-setup previous accessors can be passed into later-mounted owners": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/features.test.ts :: failure boundaries update filtered error accessors": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/features.test.ts :: typed memo previous values use pre and recompute in the caller's reactive instant": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/features.test.ts :: derived scalar signals retain manual overrides until their input changes": {
+ "category": "upstream rule change",
+ "note": "AC-1d STALE_OWN_READ refuses a read after the same change's write (Solid lets the stale read through)"
+ },
+ "tsx/features.test.ts :: synchronous projection drafts start from an eager seed and retain their previous draft": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/keyed-async.test.ts :: async structured keyed replacements retain matching rows and suppress obsolete answers": {
+ "category": "native behavior change",
+ "note": "native Solid leg differs at Solid ac28157"
+ },
+ "tsx/keyed-async.test.ts :: async keyed row retention is isolated per projection with overlapping keys": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/keyed-async.test.ts :: DV-8 baseline: a projection-only reader retains rows and swallows later pending and failure": {
+ "category": "native behavior change",
+ "note": "native Solid leg differs at Solid ac28157"
+ },
+ "tsx/lower.test.ts :: typed function-form stores retain structured question-source semantics": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "emitter-output regression for the old emit.ts patch that inferred structured question sources from a written type"
+ },
+ "tsx/lower.test.ts :: async block handlers retain direct awaits, later writes and returned values; pre-await writes are refused (AC-1b)": {
+ "category": "upstream defect",
+ "note": "aliased error-class imports lose declaration identity in both the reference and Rust checkers (docs/UPSTREAM-ISSUES.md #1)"
+ },
+ "tsx/owned-lifetimes.test.ts :: compiled/interpreter: keyed row helpers survive reorder, dispose on removal, and remount after readdition": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/previous.test.ts :: seeded previous falls back on undefined history values, but never on null": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/previous.test.ts :: source previous coalesces multiple writes in one handler": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/previous.test.ts :: authored and raw RX previous recover from failed current inputs with native packet history": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/rx-effects.test.ts :: RX effects isolate synchronous and upstream errors per input and recover": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/rx-effects.test.ts :: RX input equations are cached independently, including function and array allocations": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/rx-effects.test.ts :: RX effect cleanup follows reverse sibling and nested ownership, even after equal inputs": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/rx-effects.test.ts :: authored RX and native RX effects retain cleanup on equal direct computes": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/rx-effects.test.ts :: authored RX effects deliver per-input error answers and filter consecutive errors": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/rx-effects.test.ts :: RX effect errors are opaque answers for string coercion and equality": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/rx-effects.test.ts :: authored RX pending then reject preserves the native RX lifecycle": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/semantics.test.ts :: handler writes preserve native Solid committed-read ordering": {
+ "category": "upstream rule change",
+ "note": "AC-1d STALE_OWN_READ refuses a read after the same change's write (Solid lets the stale read through)"
+ },
+ "tsx/semantics.test.ts :: derived sources accept manual writes and rederive when their inputs change": {
+ "category": "upstream rule change",
+ "note": "AC-1d STALE_OWN_READ refuses a read after the same change's write (Solid lets the stale read through)"
+ },
+ "tsx/semantics.test.ts :: a memo's previous-value parameter follows the node's latest change": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/server-identities.test.ts :: same-named server factories in distinct components do not share loader answers": {
+ "category": "upstream interpreter divergence (single test)",
+ "note": "the test's interpreter assertion fails; its compiled/native assertions come later in the same test"
+ },
+ "tsx/server-identities.test.ts :: refresh uses the actual getter after argument changes and cached argument reuse": {
+ "category": "paired behavior mismatch",
+ "note": "compiled frames differ from the recorded expectation"
+ },
+ "tsx/server-identities.test.ts :: serverFunction detaches initial, rejection and continuation steps from the client owner": {
+ "category": "paired behavior mismatch",
+ "note": "compiled frames differ from the recorded expectation"
+ },
+ "tsx/server-identities.test.ts :: server fns with the same name in distinct modules keep distinct question identities": {
+ "category": "upstream defect",
+ "note": "same-named server fns in different components/modules share one question key (bare declaration name); fix needs emitter identity + Rust emit.rs (docs/UPSTREAM-ISSUES.md)"
+ },
+ "tsx/server-identities.test.ts :: server setup is detached too; loaders reuse and actions never reuse requests": {
+ "category": "upstream behavior difference",
+ "note": "an in-place server body runs its generator setup inside the calling client owner"
+ },
+ "tsx/snapshot-core.test.ts :: own-pre memo accumulation advances once per reactive instant": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/snapshot-core.test.ts :: own-pre draft seeds retain the previous instant rather than a settle-pass result": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/snapshot-core.test.ts :: equation dependencies do not reorder eager snapshot and source initialization": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/snapshot-core.test.ts :: snapshot failures surface at readers, and TSX refuses unused eager consts whose initializer could throw": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/snapshot-core.test.ts :: ordinary missing-row access panics while optional member and index access return undefined": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/snapshot-core.test.ts :: optional access boxes primitives and skips computed keys, arguments and chain continuations": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "raw-AST/RX core regression for the old runtime.ts/rt.ts fixes; unmodified upstream differs from the recorded Solid-aligned expectation"
+ },
+ "tsx/store-inference.test.ts :: typed read-only questions stay structured across shadowed component writes": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "emitter-output regression for the old emit.ts patch that inferred structured question sources from a written type"
+ },
+ "tsx/store-inference.test.ts :: typed read-only questions stay structured across shadowed branch writes": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "emitter-output regression for the old emit.ts patch that inferred structured question sources from a written type"
+ },
+ "tsx/store-inference.test.ts :: root-written questions retain the supported atomic lowering": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "emitter-output regression for the old emit.ts patch that inferred structured question sources from a written type"
+ },
+ "tsx/store-inference.test.ts :: a shadowed context root write does not reach another provision": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "emitter-output regression for the old emit.ts patch that inferred structured question sources from a written type"
+ },
+ "tsx/store-inference.test.ts :: root writes through an imported context alias reach the provided question": {
+ "category": "legacy core regression (patch not carried)",
+ "note": "emitter-output regression for the old emit.ts patch that inferred structured question sources from a written type"
+ },
+ "tsx/browser.ts :: rx async-handler streams HTML, adopts the same DOM on hydration, then updates on clicks": {
+ "category": "upstream rule change",
+ "note": "AC-1d STALE_OWN_READ refuses a read after the same change's write (Solid lets the stale read through)"
+ },
+ "tsx/browser.ts :: tsx async-handler streams HTML, adopts the same DOM on hydration, then updates on clicks": {
+ "category": "upstream semantic change (AC-1b/AC-1c)",
+ "note": "a plain callback that writes and awaits is an inferred action upstream"
+ },
+ "tsx/browser.ts :: rx server-factories streams HTML, adopts the same DOM on hydration, then updates on clicks": {
+ "category": "upstream defect",
+ "note": "same-named server fns in different components/modules share one question key (bare declaration name); fix needs emitter identity + Rust emit.rs (docs/UPSTREAM-ISSUES.md)"
+ },
+ "tsx/browser.ts :: tsx server-factories streams HTML, adopts the same DOM on hydration, then updates on clicks": {
+ "category": "upstream defect",
+ "note": "same-named server fns in different components/modules share one question key (bare declaration name); fix needs emitter identity + Rust emit.rs (docs/UPSTREAM-ISSUES.md)"
+ }
+ },
+ "missing": []
+}
diff --git a/tsx-frontend/baselines/upstream-tsc.txt b/tsx-frontend/baselines/upstream-tsc.txt
new file mode 100644
index 0000000..b6d39c4
--- /dev/null
+++ b/tsx-frontend/baselines/upstream-tsc.txt
@@ -0,0 +1,12 @@
+../ref/compile/emit.ts: error TS2339: Property 'e' does not exist on type '{ k: "import"; names: { name: string; alias: string; }[]; from: string; pure: boolean; side?: "server" | "client" | undefined; line: number; fails?: string[] | undefined; resolved?: string | undefined; declared?: string | undefined; declaredErrors?: string[] | undefined; declaredPure?: string[] | undefined; declared...'.
+../ref/compile/emit.ts: error TS2339: Property 'name' does not exist on type '{ k: "let"; name: string; e: Expr; type?: string | undefined; } | { k: "expr"; e: Expr; }'.
+../ref/compile/emit.ts: error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
+../ref/src/runtime.ts: error TS2339: Property 'first' does not exist on type 'never'.
+../ref/src/runtime.ts: error TS2339: Property 'state' does not exist on type 'never'.
+../ref/src/runtime.ts: error TS2339: Property 'state' does not exist on type 'never'.
+../ref/src/runtime.ts: error TS2339: Property 'value' does not exist on type 'never'.
+../ref/src/runtime.ts: error TS2345: Argument of type 'Expr | undefined' is not assignable to parameter of type 'Expr'.
+../ref/src/runtime.ts: error TS2345: Argument of type 'Node | CtxBinding' is not assignable to parameter of type 'Node'.
+../ref/src/runtime.ts: error TS2352: Conversion of type 'null | undefined' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
+../ref/src/runtime.ts: error TS2352: Conversion of type 'null | undefined' to type 'unknown[]' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
+../ref/src/runtime.ts: error TS2358: The left-hand side of an 'instanceof' expression must be of type 'any', an object type or a type parameter.
diff --git a/tsx-frontend/docs/PATCHES.md b/tsx-frontend/docs/PATCHES.md
new file mode 100644
index 0000000..9d73f57
--- /dev/null
+++ b/tsx-frontend/docs/PATCHES.md
@@ -0,0 +1,22 @@
+# Evaluation of the old prototype's core patches on the new pin
+
+The original worktree (`/Users/devagr/reactivescript`, branch `proto/sound-tsx-frontend` at f3c6771) carried 12
+modified upstream files. They are kept verbatim, per file, in `patches/legacy-f3c6771/` as evidence only (a scratch
+three-way rehearsal produced 11 conflicting files / 44 regions on aa85cbde; they are not applied). Each was evaluated by
+its regression tests, which are kept in this package even where the patch is no longer needed. "Status" is measured on
+the unmodified pinned upstream (reactivescript aa85cbde, Solid ac28157); see RESULTS.md for the exact run.
+
+| Legacy patch | What it did | Evaluation on aa85cbde | Carried? |
+| --- | --- | --- | --- |
+| `ref/package.json` | made `ref/` host the TSX package (exports, scripts, oxc/playwright/entities deps) | obsolete: this package has its own package.json/lockfile; upstream ref is untouched | no |
+| `ref/scripts/solid-next.sh` | also ran `pnpm run types` | upstreamed (`pnpm run --if-present types`) | no |
+| `ref/src/ast.ts` | added `hasAwait` helper | unused by the migrated lowering; AST stays unextended | no |
+| `ref/src/failure.ts` | added `wireCacheKey` (order-independent argument key) | needed only by native `serverFunction` caching, so moved into this package (`tsx/wire-key.ts`); primitives.test.ts keeps its tests | no (moved) |
+| `ref/src/check.ts` + `ref/src/signature.ts` | `errorKey`: failure identity by declaration rather than alias spelling; key-aware signature printing; server signature rows | **defect still present**: reproduced with handwritten RX (`upstream-repros/alias-failure-identity`, UPSTREAM-ISSUES.md #1). The fix is a cross-cutting checker refactor with an unexamined Rust counterpart, not a narrow patch, so it is not carried; affected tests stay failing in the baseline | no |
+| `ref/src/runtime.ts` (interpreter, ~313 lines) | own/non-self `pre` history (selfPre/prePacket), keyed row retention, EF-2 pending→equal, optimistic guess proposals, ownership disposal order, optional chaining, server-fn factory cache | mixed: suites exercising these now partly pass (upstream rewrote parts of the runtime) and partly diverge only in the interpreter while compiled/native agree (category "reference interpreter divergence" in the baseline). Not carried: the patch is broad, interpreter-only and would make the reference runtime disagree with the Rust crate's model checks | no |
+| `ref/compile/emit.ts`, `rt.ts`, `rt-web.ts`, `rt-server.ts` | compiled server-fn factory (`app.serverFn`) with lossless cache keys; related emitter changes | upstream replaced the factory with `serverRef`/`serverCall` (SF-2/SF-6); the three tests are ported to that bridge. The lossy-key defect remains at the pin (interpreter and all runtimes) and is fixed narrowly by **patches/0001-server-question-keys** (5 files, +53/-5). Same-name namespace collisions need emitter + Rust changes and are not patched | 0001 (keys only) |
+
+Net result: the temporary upstream patch queue holds **one** patch, 0001 (server question keys), which changes only JS
+runtimes and the reference interpreter; crate parity was re-run on the patched tree. No reference-only fix is claimed as
+production (Rust) compiler coverage. The aliased error identity defect (in both the reference and Rust checkers) is not
+patched; see UPSTREAM-ISSUES.md #2.
diff --git a/tsx-frontend/docs/RESULTS.md b/tsx-frontend/docs/RESULTS.md
new file mode 100644
index 0000000..7ebce1c
--- /dev/null
+++ b/tsx-frontend/docs/RESULTS.md
@@ -0,0 +1,113 @@
+# Measured results
+
+## Current state (2026-10-31, pin 8518fa1e)
+
+Stack: reactivescript 8518fa1e + patches/0001-server-question-keys (rebased; worktree `reactivescript-patched`),
+Solid 5e68925c (= ref/SOLID_NEXT, solid-js 2.0.0-rc.20), Node 24.18.0, Rust 1.97.1. Run: `frontend/.out/repin-8518`
+(`OUT=.out/repin-8518 sh scripts/verify-pinned.sh --rust`, READINESS mode).
+
+**Verdict: NOT READY (3 blockers: suite exit 1, browser exit 1, 96 failing tests).** Integrity problems: 0.
+
+Upstream at the pin (unmodified runners on pin + 0001, the new `pins.json` baseline): interpreter 109/0, oracle
+109/0/0, compiled 109/0/0, types 149/0, SSR 102/0 with 7 skips; crate parity 317/317, `crate --lower` 181/181,
+`crate --project` 182/182; `cargo test --release` in `compiler/` 6 passed, 0 failed. The frontend numbers below are
+unchanged from aa85cbde (demo-1, same failing set): moving the pin added no failure and fixed none. Patch 0001 still
+applies (rebased, same changes); the defect is not fixed upstream at 8518fa1e. Upstream's new AC-1e finding
+(`STALE_WRITE_BACK`) needed no adapter change.
+
+Previous state (2026-10-29, aa85cbde, `frontend/.out/demo-1`, `--skip-upstream`): the same frontend counts, with
+the upstream runners skipped.
+
+| | Now |
+| --- | --- |
+| All tests | 568 tests, 472 pass, 96 fail |
+| Ported suite | 537 tests, 444 pass, 93 fail |
+| Browser (Chromium) | 31 tests, 28 pass, 3 fail |
+| Coverage manifest (568) | 568 ran, 0 missing, 0 unlisted |
+| Skip / todo | 0 |
+| Strict types | 0 package diagnostics; upstream's 12 strict errors baselined |
+| `npm run verify:subset` | 21/21 (boundary 9, paired counter 3, composition 2, tracker demo 7) |
+
+Comparison with `baselines/observed-failures.json` (an unapproved observation, unchanged): 0 new failures, 0 new
+coverage gaps. Three observed entries no longer fail under their recorded identity, all because of the async-callback
+policy below, not because a backend was fixed: `async-handlers :: interpreted authored TSX #5` now passes as a
+refusal, and the `lower.test.ts` async block handler test and the browser `tsx async-handler` test were renamed to
+refusal tests. Every other failure is still classified as in the history below; none is approved.
+
+Changes since the 2026-10-28 measurement:
+
+- **Async-callback policy (temporary, [issue #20](https://github.com/ryansolid/reactivescript/issues/20)).** Every
+ authored ordinary async callback (event handlers, contextual callback props, forwarded and stored callbacks,
+ `createRxEffect` apply callbacks and cleanups) is refused at its `async` node with guidance to write
+ `action(function* () { ... yield request(); ... })`, which opts into held action semantics and is not a
+ semantics-preserving rewrite. `action(async ...)` is refused too. Async memo/store/server computations and
+ explicit generator actions are unaffected. Native and handwritten RX legs of the async-handler suites still run.
+- **Subset checkpoint.** `npm run verify:subset` (typecheck, boundary, paired counter, composition, demo); see
+ `docs/SUBSET.md`.
+- **Tracker demo.** `tsx/demo.test.ts` with `tsx/fixtures/demo/tracker.{tsx,rx}`: store list with a draft path write,
+ filter signal and memos, `For`/`Show` with fallback, a later-declared child with a callback prop, and an explicit
+ generator action with an optimistic claim, success commit and failure rollback. Equal checker facts and identical
+ frames across native Solid, both compiled programs and both interpreted programs.
+
+## History: measurement of 2026-10-28
+
+Stack: reactivescript aa85cbde + patches/0001-server-question-keys (worktree `reactivescript-patched`), Solid
+ac28157 (= ref/SOLID_NEXT, Rust 1.97.1), Node 24.18.0, this package at commit 31ca133. Final run:
+`frontend/.out/verify-8` (`sh scripts/verify-pinned.sh --rust`, READINESS mode).
+
+**Verdict: NOT READY.** The port is not complete; the failures below are classified, not accepted.
+
+### Upstream at the pins (unmodified runners, on pin + patch 0001; identical to the clean pin)
+
+interpreter 104 PASS / 0 FAIL; oracle 104/0 (0 divergences); compiled 104/0 (0 divergences); types 144/0; SSR 97/0
+with upstream's 7 skips; crate parity 305/305; `crate --lower` 176/176; `crate --project` 176/176. The same numbers
+were measured on the clean pin before the patch (verify-pinned-3) and in the full canary of `main` (= pin).
+
+### This package
+
+| | Original worktree (old pin, reproduced from archive) | Now (pin + 0001) |
+| --- | --- | --- |
+| Ported suite | 513 tests, 510 pass, 3 fail | 527 tests, 432 pass, 95 fail |
+| Browser (Chromium) | 31/31 | 31 tests, 27 pass, 4 fail |
+| Coverage manifest (558) | n/a | 558 ran, 0 missing, 0 unlisted |
+| Skip / todo | 0 | 0 |
+| Strict types | clean | 0 package diagnostics; upstream's 12 strict errors baselined |
+| Contracts | n/a | boundary 6/6, verifier 5/5, paired counter 3/3 |
+
+Original known failures: structured optimistic rollback now **passes**; same-setup previous accessor still fails
+(interpreter leg); async structured keyed replacement still fails (native leg).
+
+### Coverage reconciliation
+
+The manifest is the original 513 identities (file + name + occurrence) with 3 recorded renames (two lower.test.ts
+decisions, the ported server namespace test), plus 14 added tests and the 31 browser tests. All 558 ran. The 22
+async-handlers subtests that had stopped registering now always register; where upstream refuses the form, each leg
+asserts that refusal.
+
+### The 99 failures (baselines/observed-failures.json; unapproved observation)
+
+Triage is leg-based where tests have backend legs (every leg runs the same lowered program).
+
+| Category | Count | Evidence |
+| --- | --- | --- |
+| legacy core regression (patch not carried) | 23 | raw-AST/RX core suites (effect-semantics, rx-effects, snapshot-core) and store-inference emitter output written for the old runtime/emit patches |
+| upstream behavior (handwritten RX reproduces) | 20 | the handwritten RX legs fail the same way (async-handlers, derived-stores interpreter frames, …) |
+| upstream interpreter divergence | 17 | only interpreted legs fail; compiled legs of the same program match native (callbacks try/finally, previous, keyed-async, owned-lifetimes, …) |
+| upstream defect | 17 | aliased error identity (reference + Rust checkers), same-named server fn keys (UPSTREAM-ISSUES #2, #3) |
+| upstream interpreter divergence (single test) | 11 | a test's interpreter assertion fails before its compiled/native ones |
+| upstream rule change | 4 | AC-1d STALE_OWN_READ (browser async-handler RX, features) |
+| upstream semantic change (AC-1b/AC-1c) | 2 | inferred-action refusal outside the leg-based suites (features, browser TSX async-handler) |
+| native behavior change | 2 | keyed-async native legs differ at Solid ac28157 |
+| paired behavior mismatch (suspected upstream, unconfirmed) | 2 | server-identities refresh across two instances (compiled refresh is keyed by equation name, `lastKey.get(name)`) and serverFunction owner detachment (compiled shows `failed`); TSX-only tests, no handwritten RX repro yet |
+| upstream behavior difference | 1 | in-place server body runs in the client owner (UPSTREAM-ISSUES #4) |
+
+No failure is classified as an adapter (TSX-only) failure. Adapter defects found and fixed during triage: missing
+`ErrVal`/`PENDING` adapter exports (snapshot-core failed to load), a fragment wrongly migrated by the entry tool
+(features), `untrack(() => store.path)` silently changing meaning when the captured object is updated in place (now
+refused at the authored span; accepted where nothing can update it in place).
+
+### Not run / not covered
+
+- No Rust route for TSX lowering (the crate consumes RX source; the frontend produces `Program` objects).
+- The alias-identity and same-name server key defects need fixes in both the reference and Rust implementations; not
+ patched. Interpreter divergences are reported, not patched.
diff --git a/tsx-frontend/docs/SUBSET.md b/tsx-frontend/docs/SUBSET.md
new file mode 100644
index 0000000..8a61fac
--- /dev/null
+++ b/tsx-frontend/docs/SUBSET.md
@@ -0,0 +1,131 @@
+# Solid-Parity Subset
+
+The contract is native Solid behavior, not RX behavior under TSX syntax. Refuse
+forms that cannot be represented faithfully instead of silently changing meaning.
+
+## Current Checkpoint
+
+Run `npm run verify:subset`. It runs the package typecheck, all boundary contracts,
+paired-counter tests, component-composition tests, and the issue-tracker demo
+without test-name filtering. Any failed command fails the checkpoint. The
+behavioral scope is deliberately narrow: the authored counter, composition and
+tracker fixtures and their handwritten RX counterparts.
+
+```tsx
+import { createSignal, createMemo } from "solid-js";
+
+export default function Counter() {
+ const [count, setCount] = createSignal(0);
+ const doubled = createMemo(() => count() * 2);
+ return setCount(count() + 1)}>{doubled()} ;
+}
+```
+
+Tests require equal canonical checker facts and rendered frames `0`, `2`, `4`
+across the RX interpreter, RX compiler, and native Solid universal renderer.
+Changed tracked/snapshot reads must remain detectable. This is not a browser DOM
+or hydration claim, or proof for every combination of these primitives.
+
+Boundary tests check pins and adapter API contracts, and the temporary async
+callback boundary ([issue #20](https://github.com/ryansolid/reactivescript/issues/20)). Every authored ordinary async callback
+must be refused at its `async` node: event handlers and contextual callback props
+with any body, forwarded and stored callbacks, and `createRxEffect` apply callbacks
+and cleanups. The message must point to `action(function* () { ... yield request(); ... })`
+and say this opts into held action semantics rather than preserving meaning.
+`action(async ...)` and async generator actions are refused too. Explicit generator
+actions, `createMemo(async ...)` and `createServerMemo` must still lower. Before
+this boundary only a write before an `await` was refused (Solid applies it
+immediately; RX infers an action that holds it). This is a refusal contract, not
+async parity.
+
+## Component Props And Callbacks
+
+`tsx/composition.test.ts` uses `fixtures/composition.tsx` and `composition.rx`.
+The fixture passes a direct signal read, a memo value, and a synchronous callback
+through two later-declared child components:
+
+```tsx
+// Parent Counter
+return setCount(count() + 1)} />;
+
+// CounterButton: its own count starts at 10 and must remain independent.
+const [count, setCount] = createSignal(10);
+const doubled = createMemo(() => incoming.value * 2);
+return ;
+
+// Button
+return props.onIncrement()}>
+ {props.direct}/{props.value}/{props.local}
+ ;
+```
+
+Across four clicks the exact frames must be:
+
+```text
+0/0/10 -> 1/4/10 -> 2/8/10 -> 3/12/10 -> 4/16/10
+```
+
+Variants attach `onClick={props.onIncrement}` directly or forward through
+`onIncrement={() => incoming.onIncrement()}`. Each variant compares canonical
+TSX/RX facts, checks both programs, and asserts expected frames in the TSX-derived
+interpreter, both compiled RX programs, and native Solid's universal renderer.
+No callback return-value, argument-forwarding, async, or DOM-event behavior is
+claimed by these examples.
+
+These are the two existing composition tests moved unchanged from
+`lower.test.ts`, not replacement assertions. The full suite still discovers them,
+and the coverage manifest records their new paths.
+
+## Demo: Issue Tracker
+
+`tsx/demo.test.ts` uses `fixtures/demo/tracker.tsx` and its handwritten RX
+counterpart `tracker.rx`: a demo for review, not a broader parity claim. It
+combines a `createStore` issue list with a draft path write, a filter signal with
+`createMemo` views (visible rows, open count), `` rows inside a ``
+with an empty-state fallback, a later-declared `IssueRow` child with props and an
+`onAssign` callback, and an explicit generator action whose `createOptimistic`
+claim shows at once, commits the yielded `assignIssue(id)` answer on success, and
+rolls back with a status message on failure. The tests require equal canonical
+checker facts (and that snapshotting the filter in the RX is detected), and the
+same eight exact frames from native Solid's universal renderer, the RX compiler for
+both programs, and the RX interpreter for both programs, across a scripted session:
+initial render, a failed assignment with rollback, a filter change, two successful
+assignments, and the empty-state fallback. Requests are controllable flights; a
+repeated request for the same issue would reuse its cached answer on every backend,
+so the script never retries one issue. Async list loading, async memos and server
+functions are deliberately left out.
+
+## Stale Write-Back (AC-1e)
+
+`tsx/stale-write.test.ts` shows upstream's `STALE_WRITE_BACK` through authored TSX. An explicit generator action reads
+`likes()`, yields `save()`, then writes `setLikes(before + 1)`; a like made while the save was in flight is lost.
+Native Solid runs that TSX and shows the lost update (frames end at 1, not 2). The frontend refuses it through its
+normal checker route (`buildTsx` throws `CheckError`: `AC-1e: STALE_WRITE_BACK (AC-1e) likes.tsx:6: …`), as the
+checker refuses the handwritten RX. The functional setter `setLikes((n) => n + 1)` is accepted, has the same
+canonical facts as its RX, and keeps the concurrent like on native Solid, both compiled programs and both
+interpreted programs.
+
+`npm run verify:subset` passes 25 tests with no skips or TODOs; the package
+typecheck is clean. Upstream's 12
+strict-mode diagnostics remain the separately recorded baseline.
+
+## Exclusions
+
+Async behavior beyond the demo's scripted action flights, server functions, cross-module error identity, previous values,
+stores, owner lifetimes, SSR, and hydration are not certified by this command.
+Their existing tests and native assertions remain in the full suites. The full
+migration remains NOT READY while those suites fail.
+
+`npm run verify:pinned` remains the full readiness gate. Comparison-only results
+do not approve failures. No tests are removed from the full suite or coverage
+manifest to make the subset pass.
+
+## Expansion Rule
+
+Require authored TSX, handwritten RX, and native Solid evidence for each addition,
+including relevant failure and cleanup ordering. Fix adapter errors first. Backend
+defects need minimal reproductions; temporary patches need removal conditions.
+If native behavior is unrepresentable, test a specific diagnostic and retain the
+native counterexample. Do not weaken assertions, sort effect logs, or add skips.
diff --git a/tsx-frontend/docs/UPSTREAM-ISSUES.md b/tsx-frontend/docs/UPSTREAM-ISSUES.md
new file mode 100644
index 0000000..ec63fa3
--- /dev/null
+++ b/tsx-frontend/docs/UPSTREAM-ISSUES.md
@@ -0,0 +1,58 @@
+# Upstream issues found while migrating (found at reactivescript aa85cbde, Solid ac28157)
+
+The frontend's failing set is unchanged at the current pin (reactivescript 8518fa1e, Solid 5e68925c).
+
+Every item is reproduced on the pinned upstream without this package's lowering: by handwritten RX (repro directory or
+the handwritten-RX leg of a differential test), or by two upstream backends disagreeing on the same program. Nothing
+here has been filed upstream (filing needs the user's approval). "Decision needed" marks items that are not adapter
+work: they need an upstream fix (in both the reference and Rust implementations where the crate mirrors it) or a user
+decision about the supported boundary.
+
+## Patched (patches/0001-server-question-keys)
+
+1. **Server questions keyed lossily.** Reference interpreter and all three compiled runtimes keyed a server-fn
+ question as `name:` + `args.map(fmt).join(",")`. Repro: `upstream-repros/server-question-keys`; pin: interpreter
+ and compiled `number/number/a,b/a,b`; expected and patched: `number/string/a,b/a`. Rust: none needed (runtime/
+ interpreter only; crate parity re-run on the patched tree: 305/305, 176/176, 176/176).
+
+## Not patched (decision needed)
+
+2. **Aliased error-class imports lose declaration identity** (reference checker AND Rust checker). Repro:
+ `upstream-repros/alias-failure-identity`. `scripts/run-rx-repro.ts` and `rxc conform` (the crate binary) both report
+ `UNHANDLED_FAILURE main.rx:4 … can fail with Absent and no catch or errored boundary above it discharges Absent`
+ although `Absent` and the boundary's `Lost` alias one `Missing`. Signatures also print the importer's alias
+ (`rejects(Error | Lost)`). The fix is declaration-keyed failure identity in check.ts, signature.ts and the crate's
+ checker together; the old prototype's reference-only refactor is not narrow and would break ref/Rust parity, so it
+ is not carried. Affects failure-identities, contracts typed-failure tests.
+3. **Same-named server fns in different components/modules share a question.** The key's name is the bare
+ declaration name (interpreter `d.name`; emitter `serverRef(J(d.name), …)`). Handwritten RX leg reproduces
+ (browser server-factories `4/4` vs `4/5`; server-identities "same-named server factories…"). Needs per-declaration
+ identity from the emitter, i.e. emit.ts and the crate's emit.rs (crate --lower parity), so not a runtime patch.
+4. **In-place server bodies run inside the client owner.** `serverCall` drives the body under `untrack` but not
+ detached from the calling owner (server-identities owner tests). Only observable when bodies run in place (tests,
+ local mode); needs an upstream decision whether in-place execution must mimic the server's ownerless context.
+5. **Interpreter try/catch/finally approximation.** Callback finalizers: native and compiled TSX agree, the reference
+ interpreter differs on the same program (callbacks.test.ts four cases). Upstream's own comment in runtime.ts calls
+ its finally handling an approximation.
+6. **Interpreter handler returns.** A suspended interpreted handler returns its run's value, never a Promise; compiled
+ and native return Promises (async-handlers "lexically async callbacks…", RX interpreted leg; its authored TSX legs
+ are refused for now, issue #20).
+7. **Compiled callbacks become async when an await is only nested.** A callback whose only await sits inside a nested
+ closure or an inline `action { }` compiles to an async function; native returns synchronously (async-handlers,
+ handwritten RX compiled legs).
+8. **Interpreter shows a pre-action question write early.** In `n := 1; draft := 1; action { … }`, the interpreter
+ commits `draft` before the frame native and compiled show (`0/1/…` vs `0/0/…`; RX and TSX interpreted legs).
+9. **Reference interpreter vs compiled differences from the old prototype's runtime patches** (own/non-self `pre`
+ history, keyed row retention, effect ordering, optional chaining, derived-store pending probes): the
+ "legacy core regression" and "upstream interpreter divergence" categories in baselines/observed-failures.json. In
+ each, the compiled legs (or the handwritten RX legs) show the same behavior with no TSX involved.
+10. **Parser does not terminate** on some non-program inputs, e.g. `parseProgram("x
}")` (killed after 10 s).
+
+## Semantic boundaries decided in this package (not defects)
+
+- AC-1b/AC-1c: a plain Solid callback that writes before an await has no RX spelling (upstream holds the write in an
+ inferred action and refuses it as HELD_WRITE); the TSX is refused at the callback. Tests prove the refusal on every
+ leg and keep the native evidence.
+- AC-1d STALE_OWN_READ and IO-2 IO_IN_REACTIVE refuse forms Solid accepts; the refusals are the pinned semantics.
+- `untrack(() => storePath)` returning a structured object: RX has only a value snapshot. Accepted only when nothing
+ can update the captured object in place (no derived store, no write inside the captured path); otherwise refused.
diff --git a/tsx-frontend/package-lock.json b/tsx-frontend/package-lock.json
new file mode 100644
index 0000000..dfa530f
--- /dev/null
+++ b/tsx-frontend/package-lock.json
@@ -0,0 +1,1103 @@
+{
+ "name": "reactivescript-tsx",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "reactivescript-tsx",
+ "dependencies": {
+ "@solidjs/compiler": "file:../../solid-next/packages/compiler",
+ "@solidjs/signals": "file:../../solid-next/packages/signals",
+ "@solidjs/universal": "file:../../solid-next/packages/universal",
+ "@solidjs/web": "file:../../solid-next/packages/web",
+ "entities": "8.1.0",
+ "oxc-parser": "0.155.0",
+ "reactivescript-ref": "file:../ref",
+ "solid-js": "file:../../solid-next/packages/solid"
+ },
+ "devDependencies": {
+ "@types/node": "24.19.2",
+ "esbuild": "0.28.2",
+ "playwright": "1.59.1",
+ "typescript": "6.0.3"
+ }
+ },
+ "../../solid-next/packages/compiler": {
+ "name": "@solidjs/compiler",
+ "version": "2.0.0-rc.20",
+ "license": "MIT",
+ "devDependencies": {
+ "@emnapi/core": "^1.11.3",
+ "@emnapi/runtime": "^1.11.3",
+ "@napi-rs/cli": "^3.10.6",
+ "@napi-rs/wasm-runtime": "^1.1.6",
+ "emnapi": "^1.11.3"
+ },
+ "optionalDependencies": {
+ "@solidjs/compiler-darwin-arm64": "2.0.0-rc.20",
+ "@solidjs/compiler-darwin-x64": "2.0.0-rc.20",
+ "@solidjs/compiler-linux-arm64-gnu": "2.0.0-rc.20",
+ "@solidjs/compiler-linux-x64-gnu": "2.0.0-rc.20",
+ "@solidjs/compiler-wasm32-wasi": "2.0.0-rc.20",
+ "@solidjs/compiler-win32-x64-msvc": "2.0.0-rc.20"
+ }
+ },
+ "../../solid-next/packages/signals": {
+ "name": "@solidjs/signals",
+ "version": "2.0.0-rc.20",
+ "license": "MIT",
+ "devDependencies": {
+ "@codspeed/vitest-plugin": "^5.4.0",
+ "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
+ "@rollup/plugin-replace": "^6.0.3",
+ "@rollup/plugin-typescript": "^12.3.0",
+ "@types/node": "^25.0.8",
+ "rimraf": "^5.0.1",
+ "rollup": "^4.53.4",
+ "rollup-plugin-prettier": "^4.1.2",
+ "terser": "^5.49.0",
+ "tslib": "^2.8.1",
+ "typescript": "^6.0.3",
+ "vite": "^7.0.0",
+ "vitest": "^4.1.6"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "../../solid-next/packages/solid": {
+ "name": "solid-js",
+ "version": "2.0.0-rc.20",
+ "license": "MIT",
+ "dependencies": {
+ "@solidjs/signals": "^2.0.0-rc.20",
+ "csstype": "^3.1.0",
+ "seroval": "1.7.0",
+ "seroval-plugins": "1.7.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "../../solid-next/packages/universal": {
+ "name": "@solidjs/universal",
+ "version": "2.0.0-rc.20",
+ "license": "MIT",
+ "devDependencies": {
+ "solid-js": "workspace:*"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "peerDependencies": {
+ "solid-js": "^2.0.0-rc.20"
+ }
+ },
+ "../../solid-next/packages/web": {
+ "name": "@solidjs/web",
+ "version": "2.0.0-rc.20",
+ "license": "MIT",
+ "dependencies": {
+ "seroval": "1.7.0",
+ "seroval-plugins": "1.7.0"
+ },
+ "devDependencies": {
+ "@codspeed/vitest-plugin": "^5.4.0",
+ "@solidjs/signals": "workspace:*",
+ "fast-check": "3.23.2",
+ "solid-js": "workspace:*"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "peerDependencies": {
+ "solid-js": "^2.0.0-rc.20"
+ }
+ },
+ "../ref": {
+ "name": "reactivescript-ref",
+ "dependencies": {
+ "@solidjs/compiler": "file:../../solid-next/packages/compiler",
+ "@solidjs/signals": "file:../../solid-next/packages/signals",
+ "@solidjs/universal": "file:../../solid-next/packages/universal",
+ "@solidjs/web": "file:../../solid-next/packages/web",
+ "solid-js": "file:../../solid-next/packages/solid"
+ },
+ "devDependencies": {
+ "esbuild": "^0.28.2",
+ "typescript": "^6.0.3"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@oxc-parser/binding-android-arm-eabi": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.155.0.tgz",
+ "integrity": "sha512-A6xv7hDtnTm+xlZ7utn6rRQpRMJHRhVr3gjRGqlx0RUX84mNk0pAsy6YN/wWhuOTkaZhRrrzaLxf0NfbH77j2w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-android-arm64": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.155.0.tgz",
+ "integrity": "sha512-T1wWwH9/JI5g7Z4kEQwDOwhToxdDhRyOTklH+uuNpJ16m3MZFmGl+l2rKc0NHQdbVRG2LTOBsNjpW3h1BWytPA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-darwin-arm64": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.155.0.tgz",
+ "integrity": "sha512-ZoRB3qJxJ8DWA41jHja+YeB0lLfjBy4T+Kr15vnN1k65X3zbJt3rPZftEwN4Ju4tmA4K/T9dWWD7Z8ewQLnrVA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-darwin-x64": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.155.0.tgz",
+ "integrity": "sha512-vxtL86BtM+IgrpejA86nbTk5j0HKTIMeuaJ1fQciw6p9RfavPtIPjI7gN34owap0yG9txXBaAWpGdxokNwdqIw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-freebsd-x64": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.155.0.tgz",
+ "integrity": "sha512-tN6Ru5e0UVnWaIuiH8jEwnvxuxg7FvhWwSd3oe5oDhOD4ObsSr9/N6WfHLjilUWlPPj65ptwrSDH0Y7lzwTzmw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.155.0.tgz",
+ "integrity": "sha512-h4ozA5n6L6lwolsz9iwunEhKWnIVbmFRots5QxEix0H6nJ5ICpuDa7PuShuhfpIDFe3+Qvyscioyiv+9g+4Syg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.155.0.tgz",
+ "integrity": "sha512-058/+LardpfuUlsKIvBGk/CYoakEVcMgqYx8GgyUZeOxFWTojYLlCc1Gb60/y21v3esDhHidgiY/Zbq9LpBhaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm64-gnu": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.155.0.tgz",
+ "integrity": "sha512-nrOt2Y3xIAzu+lSPzH0864ieO+iCCXgoo9qFlGANE8Xh0cQ+RP8Wstfp+/wXN2YCyb98cAGzX0VruKYw7it/jw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm64-musl": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.155.0.tgz",
+ "integrity": "sha512-KYAayyN5hQbJPtGKtRxlyc8XYmyntOplyXSQzTDrmbz7l/GCrzQbSswvyJFBTH1LGtsZrbXOAfN52i/Kz6kLKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.155.0.tgz",
+ "integrity": "sha512-CSuSY5hfjtsBnHXydOj8jiNfS1qHbdPZuPz1YL1cSgK7VmNYP+QlZIbG0BBYYBgX/vYEgZwbG9mBdW4YairZ5w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.155.0.tgz",
+ "integrity": "sha512-7KCfOQm+Nz2N8trAXbQkal4TQ5756WVEmhm35y816YHvyFnEfVsICsiV5k0pEszNf/fRm4cm2i6i18SJ6/iCWg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-musl": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.155.0.tgz",
+ "integrity": "sha512-JSlhgoK+/g1TGJI5NuADx2+6syhxnQNLCWLXyO5QLFyetomcr/vIxSdiNlmyAv963YXUzCuwAwRG6npZ5xyJ1A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-s390x-gnu": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.155.0.tgz",
+ "integrity": "sha512-WFIX+dPokWp64mi434Md3U3lJaTwNxAphvGjpltoyQjGUsdXavOIRrnp9dOS34Hd75HOAwDxAI+YwWOwqmJbtg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-gnu": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.155.0.tgz",
+ "integrity": "sha512-g6xSDC/JpbnVOz5uafMGyK3txBVRFMQTx8opv/yHXGxqV3ZMVl3bf7W8XrSnF68dmy3gM4dgReNryuE53+WkeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-musl": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.155.0.tgz",
+ "integrity": "sha512-6/4xlxKlZJWrQ0uwDFPc0H9+xLu7068CKhX3vIDW3SbvP/lVgnVuT5v6dyugeWskWpuxqF/WJ4WKwhxjU3pG2w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-openharmony-arm64": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.155.0.tgz",
+ "integrity": "sha512-tltklL0J0XjDxIRTBXWOBhvc2mitcmES45pIxhwDWfxRyqz7Vkeu0NekTZEIxGE4f/lXgq3vZcjjffteQOVorg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-arm64-msvc": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.155.0.tgz",
+ "integrity": "sha512-alAb9TzjatUEsOeoncAziUnfdTDxfyswhalFZ9sC6plUgATIUx2NIZ0+bMn6aJ28AduS1OpG/OAisr/YISn4Ig==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-ia32-msvc": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.155.0.tgz",
+ "integrity": "sha512-yc5cbSfcGmRv/KpxDFuyk8adEBaWd/9HilWvBbQ4bFbus1V2PUh2mGB2Cfq9rqZTqLeiMT8x0skV+LKp9hOMgQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-x64-msvc": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.155.0.tgz",
+ "integrity": "sha512-8heq6jGq+rfpTDRDjoQiBHw0maQZf3IXkS99pdHQZPoTL5kyF5qyPS0RidUlSdnWDIqxXb5nux7GBD9cT+imUw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
+ "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@solidjs/compiler": {
+ "resolved": "../../solid-next/packages/compiler",
+ "link": true
+ },
+ "node_modules/@solidjs/signals": {
+ "resolved": "../../solid-next/packages/signals",
+ "link": true
+ },
+ "node_modules/@solidjs/universal": {
+ "resolved": "../../solid-next/packages/universal",
+ "link": true
+ },
+ "node_modules/@solidjs/web": {
+ "resolved": "../../solid-next/packages/web",
+ "link": true
+ },
+ "node_modules/@types/node": {
+ "version": "24.19.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
+ "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/oxc-parser": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.155.0.tgz",
+ "integrity": "sha512-C/ReSf1JUFukK/zvk6jhcNibSssvas5+ktIzK7fbKVd3CedWe59C3QwHEOrEZj/4Rt9+xg0Jeis/Jv1F/KM1mw==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "^0.155.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ },
+ "optionalDependencies": {
+ "@oxc-parser/binding-android-arm-eabi": "0.155.0",
+ "@oxc-parser/binding-android-arm64": "0.155.0",
+ "@oxc-parser/binding-darwin-arm64": "0.155.0",
+ "@oxc-parser/binding-darwin-x64": "0.155.0",
+ "@oxc-parser/binding-freebsd-x64": "0.155.0",
+ "@oxc-parser/binding-linux-arm-gnueabihf": "0.155.0",
+ "@oxc-parser/binding-linux-arm-musleabihf": "0.155.0",
+ "@oxc-parser/binding-linux-arm64-gnu": "0.155.0",
+ "@oxc-parser/binding-linux-arm64-musl": "0.155.0",
+ "@oxc-parser/binding-linux-ppc64-gnu": "0.155.0",
+ "@oxc-parser/binding-linux-riscv64-gnu": "0.155.0",
+ "@oxc-parser/binding-linux-riscv64-musl": "0.155.0",
+ "@oxc-parser/binding-linux-s390x-gnu": "0.155.0",
+ "@oxc-parser/binding-linux-x64-gnu": "0.155.0",
+ "@oxc-parser/binding-linux-x64-musl": "0.155.0",
+ "@oxc-parser/binding-openharmony-arm64": "0.155.0",
+ "@oxc-parser/binding-win32-arm64-msvc": "0.155.0",
+ "@oxc-parser/binding-win32-ia32-msvc": "0.155.0",
+ "@oxc-parser/binding-win32-x64-msvc": "0.155.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/reactivescript-ref": {
+ "resolved": "../ref",
+ "link": true
+ },
+ "node_modules/solid-js": {
+ "resolved": "../../solid-next/packages/solid",
+ "link": true
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/tsx-frontend/package.json b/tsx-frontend/package.json
new file mode 100644
index 0000000..2f5aa3f
--- /dev/null
+++ b/tsx-frontend/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "reactivescript-tsx",
+ "private": true,
+ "type": "module",
+ "description": "Experimental Oxc TSX frontend for ReactiveScript (demo, in-tree as tsx-frontend/). Lowers authored Solid-style TSX onto the pinned upstream Program/checker in ../ref. See README.md.",
+ "exports": {
+ ".": "./tsx/primitives.ts"
+ },
+ "imports": {
+ "#rx/*": "reactivescript-ref/*"
+ },
+ "scripts": {
+ "verify:subset": "npm run check && node --experimental-strip-types --conditions=browser --conditions=development --test tsx/boundary.test.ts tsx/paired.test.ts tsx/composition.test.ts tsx/demo.test.ts tsx/stale-write.test.ts",
+ "test:contracts": "node --experimental-strip-types --conditions=browser --conditions=development --test tsx/boundary.test.ts tsx/verifier.test.ts tsx/paired.test.ts tsx/contracts.test.ts",
+ "test": "node --experimental-strip-types --conditions=browser --conditions=development --test tsx/*.test.ts",
+ "test:browser": "node --experimental-strip-types --test tsx/browser.ts",
+ "check": "node --experimental-strip-types scripts/check-types.ts",
+ "build": "node --experimental-strip-types tsx/build.ts",
+ "verify:pinned": "sh scripts/verify-pinned.sh",
+ "compare:pinned": "sh scripts/verify-pinned.sh --compare",
+ "canary": "sh scripts/canary.sh"
+ },
+ "dependencies": {
+ "@solidjs/compiler": "file:../../solid-next/packages/compiler",
+ "@solidjs/signals": "file:../../solid-next/packages/signals",
+ "@solidjs/universal": "file:../../solid-next/packages/universal",
+ "@solidjs/web": "file:../../solid-next/packages/web",
+ "entities": "8.1.0",
+ "oxc-parser": "0.155.0",
+ "reactivescript-ref": "file:../ref",
+ "solid-js": "file:../../solid-next/packages/solid"
+ },
+ "devDependencies": {
+ "@types/node": "24.19.2",
+ "esbuild": "0.28.2",
+ "playwright": "1.59.1",
+ "typescript": "6.0.3"
+ }
+}
diff --git a/tsx-frontend/patches/0001-server-question-keys.md b/tsx-frontend/patches/0001-server-question-keys.md
new file mode 100644
index 0000000..ddb3a99
--- /dev/null
+++ b/tsx-frontend/patches/0001-server-question-keys.md
@@ -0,0 +1,29 @@
+# 0001 server-question-keys (temporary upstream patch)
+
+- **Applies to:** reactivescript 8518fa1ee28b135cabc8c6c151e3e7d9ef589070 (the pin; rebased from aa85cbde with the same
+ changes, only blob ids and hunk offsets differ). Files: ref/compile/rt.ts,
+ ref/compile/rt-web.ts, ref/compile/rt-server.ts, ref/src/failure.ts, ref/src/runtime.ts (5 files, +53/-5).
+- **Defect:** a server fn asked in reactive scope is a question with one flight per argument list (SF-2), but every
+ runtime keyed that flight as `name + ":" + args.map(fmt).join(",")`, where `fmt` leaves strings bare and
+ JSON-encodes the rest. Distinct wire arguments therefore shared one flight and one answer: `load(1)`/`load("1")`,
+ `first("a,b","c")`/`first("a","b,c")`, `undefined`/`"undefined"`, `NaN`/`null`/`Infinity`, `-0`/`0`,
+ `{a: undefined}`/`{}`, `[undefined]`/`Array(1)`; and `{a:1,b:2}`/`{b:2,a:1}` did NOT share one.
+- **Reproduction (handwritten RX, no TSX):** `upstream-repros/server-question-keys/main.rx`. At the pin:
+ interpreter `number/number/a,b/a,b`, compiled `number/number/a,b/a,b`; expected (and with this patch, both)
+ `number/string/a,b/a`. Run: `node --experimental-strip-types --conditions=browser --conditions=development
+ scripts/run-rx-repro.ts upstream-repros/server-question-keys`.
+- **Regression test:** `tsx/server-identities.test.ts` "server loader keys preserve wire argument types, delimiters
+ and numeric edge cases" (native, compiled questions via the emitter's serverRef/serverCall bridge, interpreter and
+ compiled handwritten RX).
+- **Change:** one key function `wireKey` (lossless for wire data, record keys sorted) used at the four key sites.
+ The runtimes are self-contained by design ("served alone"), so the helper is repeated in rt.ts/rt-web.ts/rt-server.ts
+ as upstream already does for wireCopy. Server and client compute the same key, so streamed answers still hydrate.
+- **Not fixed here:** same-named server fns in different components/modules still share a key (bare declaration name);
+ that needs per-declaration identity from the emitter (emit.ts) and its Rust counterpart (compiler/src emit.rs), so it
+ is reported, not patched (docs/UPSTREAM-ISSUES.md).
+- **Rust parity:** no Rust change is needed: the patch changes only the JS runtimes and the reference interpreter, not
+ the checker, model or emitter the crate mirrors; `npm run crate`, `crate --lower` and `crate --project` are
+ run on the patched tree (docs/RESULTS.md) to show the crate still agrees with ref/.
+- **Upstream issue:** none filed (needs approval).
+- **Remove when:** upstream keys server questions losslessly (the regression test passes on an unpatched candidate:
+ `sh scripts/canary.sh REV` reports the patch no longer applies or the test passing without it).
diff --git a/tsx-frontend/patches/0001-server-question-keys.patch b/tsx-frontend/patches/0001-server-question-keys.patch
new file mode 100644
index 0000000..7261744
--- /dev/null
+++ b/tsx-frontend/patches/0001-server-question-keys.patch
@@ -0,0 +1,141 @@
+diff --git a/ref/compile/rt-server.ts b/ref/compile/rt-server.ts
+index 107e064..7cebd14 100644
+--- a/ref/compile/rt-server.ts
++++ b/ref/compile/rt-server.ts
+@@ -53,6 +53,18 @@ function tryCatch(f: () => unknown, match: (err: unknown) => boolean, alt: (err:
+ }
+
+ // ---------------------------------------------------------------- the wire (12 SF-6; a copy of ref/src/failure.ts's)
++/** A server question's argument key (frontend patch 0001): lossless for wire data and independent of record key order,
++ * so argument lists that differ as data never share a flight. serverCall still validates the arguments (wireCopy). */
++function wireKey(args: readonly unknown[]): string {
++ const enc = (v: unknown): unknown =>
++ v === undefined ? ["u"] : v === null ? ["z"] : typeof v === "boolean" ? ["b", v] : typeof v === "string" ? ["s", v]
++ : typeof v === "number" ? ["n", Object.is(v, -0) ? "-0" : String(v)]
++ : Array.isArray(v) ? ["a", Array.from({ length: v.length }, (_, i) => Object.hasOwn(v, i) ? enc(v[i]) : ["h"])]
++ : typeof v === "object" && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
++ ? ["o", Object.keys(v as object).sort().map(k => [k, enc((v as Record)[k])])]
++ : ["x", typeof v];
++ return JSON.stringify(args.map(enc));
++}
+ function wireCopy(v: unknown, where: string, fail: (msg: string) => never): unknown {
+ if (v === null || v === undefined || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
+ if (Array.isArray(v)) return v.map((x, i) => wireCopy(x, `${where}[${i}]`, fail));
+@@ -166,7 +178,7 @@ export function createApp(opts: { api?: Record unknown>
+ * — a request to the server. */
+ serverRef(name: string, fn: (...a: unknown[]) => Promise) {
+ return (...args: unknown[]) => {
+- const key = `${name}:${args.map(fmt).join(",")}`;
++ const key = `${name}:${wireKey(args)}`;
+ const question = !!getObserver();
+ if (question && flights.has(key)) return flights.get(key)!;
+ const fl = question ? flight(key) : new Flight(`${key}#${serverSeq++}`);
+diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts
+index b4a4c61..ee94872 100644
+--- a/ref/compile/rt-web.ts
++++ b/ref/compile/rt-web.ts
+@@ -74,6 +74,18 @@ const NO_PROVISION: Entry = {};
+ class Forward { err: unknown; constructor(e: unknown) { this.err = e; } }
+
+ // ---------------------------------------------------------------- the wire (12 SF-6; a copy of ref/src/failure.ts's)
++/** A server question's argument key (frontend patch 0001): lossless for wire data and independent of record key order,
++ * so argument lists that differ as data never share a flight. serverCall still validates the arguments (wireCopy). */
++function wireKey(args: readonly unknown[]): string {
++ const enc = (v: unknown): unknown =>
++ v === undefined ? ["u"] : v === null ? ["z"] : typeof v === "boolean" ? ["b", v] : typeof v === "string" ? ["s", v]
++ : typeof v === "number" ? ["n", Object.is(v, -0) ? "-0" : String(v)]
++ : Array.isArray(v) ? ["a", Array.from({ length: v.length }, (_, i) => Object.hasOwn(v, i) ? enc(v[i]) : ["h"])]
++ : typeof v === "object" && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
++ ? ["o", Object.keys(v as object).sort().map(k => [k, enc((v as Record)[k])])]
++ : ["x", typeof v];
++ return JSON.stringify(args.map(enc));
++}
+ function wireCopy(v: unknown, where: string, fail: (msg: string) => never): unknown {
+ if (v === null || v === undefined || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
+ if (Array.isArray(v)) return v.map((x, i) => wireCopy(x, `${where}[${i}]`, fail));
+@@ -220,7 +232,7 @@ export function createApp(opts: { root: any; api?: Record Promise) {
+ return (...args: unknown[]) => {
+- const key = `${name}:${args.map(fmt).join(",")}`;
++ const key = `${name}:${wireKey(args)}`;
+ const question = !!getObserver();
+ if (question && flights.has(key)) return flights.get(key)!;
+ const fl = question ? flight(key) : new Flight(`${key}#${serverSeq++}`);
+diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts
+index 6cdd55c..662d418 100644
+--- a/ref/compile/rt.ts
++++ b/ref/compile/rt.ts
+@@ -65,6 +65,18 @@ const NO_PROVISION: Entry = {};
+ class Forward { err: unknown; constructor(e: unknown) { this.err = e; } }
+
+ // ---------------------------------------------------------------- the wire (12 SF-6; a copy of ref/src/failure.ts's)
++/** A server question's argument key (frontend patch 0001): lossless for wire data and independent of record key order,
++ * so argument lists that differ as data never share a flight. serverCall still validates the arguments (wireCopy). */
++function wireKey(args: readonly unknown[]): string {
++ const enc = (v: unknown): unknown =>
++ v === undefined ? ["u"] : v === null ? ["z"] : typeof v === "boolean" ? ["b", v] : typeof v === "string" ? ["s", v]
++ : typeof v === "number" ? ["n", Object.is(v, -0) ? "-0" : String(v)]
++ : Array.isArray(v) ? ["a", Array.from({ length: v.length }, (_, i) => Object.hasOwn(v, i) ? enc(v[i]) : ["h"])]
++ : typeof v === "object" && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
++ ? ["o", Object.keys(v as object).sort().map(k => [k, enc((v as Record)[k])])]
++ : ["x", typeof v];
++ return JSON.stringify(args.map(enc));
++}
+ function wireCopy(v: unknown, where: string, fail: (msg: string) => never): unknown {
+ if (v === null || v === undefined || typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
+ if (Array.isArray(v)) return v.map((x, i) => wireCopy(x, `${where}[${i}]`, fail));
+@@ -228,7 +240,7 @@ export function createApp(opts: { api?: Record unknown>
+ * — a request to the server. */
+ serverRef(name: string, fn: (...a: unknown[]) => Promise) {
+ return (...args: unknown[]) => {
+- const key = `${name}:${args.map(fmt).join(",")}`;
++ const key = `${name}:${wireKey(args)}`;
+ const question = !!getObserver();
+ if (question && flights.has(key)) return flights.get(key)!;
+ const fl = question ? flight(key) : new Flight(`${key}#${serverSeq++}`);
+diff --git a/ref/src/failure.ts b/ref/src/failure.ts
+index b00ff35..089c7f6 100644
+--- a/ref/src/failure.ts
++++ b/ref/src/failure.ts
+@@ -57,6 +57,18 @@ export function wireCopy(v: unknown, where: string, fail: (msg: string) => never
+ }
+ return fail(`${where} is ${typeof v === "function" ? "a function" : `an instance of ${(v as object).constructor?.name ?? "a class"}`}, which the wire cannot carry: send data`);
+ }
++/** A server question's argument key (frontend patch 0001): lossless for wire data and independent of record key order,
++ * so argument lists that differ as data never share a flight. serverCall still validates the arguments (wireCopy). */
++export function wireKey(args: readonly unknown[]): string {
++ const enc = (v: unknown): unknown =>
++ v === undefined ? ["u"] : v === null ? ["z"] : typeof v === "boolean" ? ["b", v] : typeof v === "string" ? ["s", v]
++ : typeof v === "number" ? ["n", Object.is(v, -0) ? "-0" : String(v)]
++ : Array.isArray(v) ? ["a", Array.from({ length: v.length }, (_, i) => Object.hasOwn(v, i) ? enc(v[i]) : ["h"])]
++ : typeof v === "object" && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
++ ? ["o", Object.keys(v as object).sort().map(k => [k, enc((v as Record)[k])])]
++ : ["x", typeof v];
++ return JSON.stringify(args.map(enc));
++}
+ /** SF-6b / SV-5: a failure crosses as it is when its class is declared; anything else as the generic error. */
+ export function wireFailure(err: unknown): unknown {
+ return err instanceof Error && (err.constructor as { declared?: boolean }).declared ? err : new Error("Internal Server Error");
+diff --git a/ref/src/runtime.ts b/ref/src/runtime.ts
+index cd3da47..f47a4ad 100644
+--- a/ref/src/runtime.ts
++++ b/ref/src/runtime.ts
+@@ -8,7 +8,7 @@ type FnDecl = Extract; type CompDecl = Extract = { GET: (f: unknown) => f, live: (f: unknown) => f, broadcast: (f: unknown) => f, withMeta: (f: unknown) => f };
+@@ -903,7 +903,7 @@ export class Runtime {
+ private serverFns = new WeakMap(); private serverNames = new WeakMap();
+ private serverFn(d: { name: string; params: string[]; body: Stmt[] }, home: Region) {
+ return (...args: unknown[]) => {
+- const key = `${d.name}:${args.map(fmt).join(",")}`;
++ const key = `${d.name}:${wireKey(args)}`;
+ const question = this.demandMode;
+ if (question && this.flights.has(key)) return this.flights.get(key)!;
+ const f = question ? this.flight(key) : new Flight(`${key}#${this.serverSeq++}`);
diff --git a/tsx-frontend/patches/README.md b/tsx-frontend/patches/README.md
new file mode 100644
index 0000000..7a2c22c
--- /dev/null
+++ b/tsx-frontend/patches/README.md
@@ -0,0 +1,20 @@
+# Upstream patch queue
+
+In-tree on the `tsx-frontend-demo-main` branch nothing here is applied: the branch is the clean pin plus `tsx-frontend/`.
+`0001-server-question-keys` is kept as documentation of a proposed core fix; the text below describes the
+out-of-tree workspace.
+
+Policy: the clean upstream checkout (`../reactivescript`) stays at the pinned commit, unmodified. A patch is carried
+only for a defect reproduced on the pin with handwritten RX (no TSX lowering), narrowly scoped, and not expressible as
+adapter work. Each `NNNN-name.patch` has a `NNNN-name.md` record: base SHA, defect, repro, regression test, Rust
+counterpart status (a reference-only fix never counts as production compiler coverage), upstream issue (none filed
+without approval) and removal condition.
+
+`sh scripts/apply-patches.sh ../reactivescript ../reactivescript-patched` rebuilds the patched tree (a detached
+worktree at the same commit plus the queue); `frontend/upstream` points at it. verify-pinned.sh fails unless the
+patched tree equals its commit plus exactly the queue, and canary.sh reports a queue that no longer applies as its own
+category. Patches are never committed to any upstream checkout and never applied to the original worktree.
+
+Queue at 8518fa1e: **0001-server-question-keys** (docs/UPSTREAM-ISSUES.md #1; rebased from aa85cbde, same changes).
+Legacy prototype patches in
+`legacy-f3c6771/` are evidence only (docs/PATCHES.md).
diff --git a/tsx-frontend/pins.json b/tsx-frontend/pins.json
new file mode 100644
index 0000000..6d31ff7
--- /dev/null
+++ b/tsx-frontend/pins.json
@@ -0,0 +1,26 @@
+{
+ "reactivescript": {
+ "repo": "https://github.com/ryansolid/reactivescript",
+ "commit": "8518fa1ee28b135cabc8c6c151e3e7d9ef589070"
+ },
+ "solid": {
+ "repo": "https://github.com/solidjs/solid",
+ "commit": "5e68925cc2fd216a9fb06345019e66d5da5c523c",
+ "source": "reactivescript ref/SOLID_NEXT at the pinned commit"
+ },
+ "toolchain": {
+ "node": "24.18.0",
+ "pnpm": "11.20.0",
+ "rust": "1.97.1",
+ "oxc-parser": "0.155.0",
+ "typescript": "6.0.3",
+ "playwright": "1.59.1"
+ },
+ "upstreamBaseline": {
+ "test": "109 pass, 0 fail",
+ "oracle": "109 pass, 0 fail, 0 skip",
+ "compiled": "109 pass, 0 fail, 0 skip",
+ "types": "149 pass, 0 fail",
+ "ssr": "102 pass, 0 fail, 7 skip"
+ }
+}
diff --git a/tsx-frontend/scripts/apply-patches.sh b/tsx-frontend/scripts/apply-patches.sh
new file mode 100644
index 0000000..5f0aa7a
--- /dev/null
+++ b/tsx-frontend/scripts/apply-patches.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+# Build the patched upstream tree the frontend runs on: a detached git worktree of a CLEAN reactivescript checkout at
+# its current HEAD, with patches/NNNN-*.patch applied in order. The clean checkout is never modified.
+# sh scripts/apply-patches.sh CLEAN_CHECKOUT TARGET_DIR
+# Exit 3 when a queued patch no longer applies (reported by the canary as patch-queue breakage).
+set -eu
+HERE=$(cd "$(dirname "$0")/.." && pwd)
+CLEAN=$(cd "$1" && pwd); TARGET=$2
+[ -z "$(git -C "$CLEAN" status --porcelain)" ] || { echo "clean checkout $CLEAN has local changes"; exit 2; }
+COMMIT=$(git -C "$CLEAN" rev-parse HEAD)
+[ -e "$TARGET" ] && { git -C "$CLEAN" worktree remove --force "$TARGET" 2>/dev/null || { echo "$TARGET exists and is not a worktree of $CLEAN"; exit 2; }; }
+git -C "$CLEAN" worktree add -q --detach "$TARGET" "$COMMIT"
+for p in "$HERE"/patches/[0-9][0-9][0-9][0-9]-*.patch; do
+ [ -e "$p" ] || continue
+ git -C "$TARGET" apply --check "$p" || { echo "PATCH QUEUE NO LONGER APPLIES: $(basename "$p") at $COMMIT"; exit 3; }
+ git -C "$TARGET" apply "$p"; echo "applied $(basename "$p")"
+done
+echo "patched tree $TARGET at $COMMIT"
diff --git a/tsx-frontend/scripts/canary.sh b/tsx-frontend/scripts/canary.sh
new file mode 100644
index 0000000..d456aac
--- /dev/null
+++ b/tsx-frontend/scripts/canary.sh
@@ -0,0 +1,64 @@
+#!/bin/sh
+# Rehearse another ReactiveScript revision without touching the pinned workspace. Everything happens in a fresh
+# temporary directory: a clone of reactivescript at REV, Solid at that revision's ref/SOLID_NEXT, and an export of this
+# package's committed HEAD with its own npm ci. Pins, baselines and working checkouts are never modified.
+# sh scripts/canary.sh REV --dry-run cheap: fetch, report pin/schema changes; NO build and NO tests run
+# sh scripts/canary.sh REV [--rust] full READINESS run: build Solid, install, run scripts/verify-pinned.sh --canary
+# sh scripts/canary.sh REV --compare full run, COMPARISON ONLY against the unapproved observation (never "ready")
+set -eu
+REV=${1:?usage: canary.sh REV [--dry-run] [--rust]}; shift
+DRY=0; EXTRA=""
+for a in "$@"; do case $a in --dry-run) DRY=1;; --rust) EXTRA="$EXTRA --rust";; --compare) EXTRA="$EXTRA --compare";; *) echo "unknown option $a"; exit 2;; esac; done
+HERE=$(cd "$(dirname "$0")/.." && pwd)
+WS=$(cd "$HERE/.." && pwd)
+pin() { node -e "process.stdout.write(require('$HERE/pins.json')$1)"; }
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/rx-canary.XXXXXX")
+echo "canary workspace: $TMP"
+
+git clone -q --no-checkout "$WS/reactivescript" "$TMP/reactivescript"
+git -C "$TMP/reactivescript" remote set-url origin "$(pin .reactivescript.repo)"
+git -C "$TMP/reactivescript" remote set-url --push origin NO_PUSH
+if git -C "$TMP/reactivescript" cat-file -e "$REV^{commit}" 2>/dev/null; then
+ COMMIT=$(git -C "$TMP/reactivescript" rev-parse "$REV^{commit}")
+else
+ git -C "$TMP/reactivescript" fetch -q origin "$REV" # a branch name or a sha from the upstream remote (read-only fetch)
+ COMMIT=$(git -C "$TMP/reactivescript" rev-parse "FETCH_HEAD^{commit}")
+fi
+PINNED=$(pin .reactivescript.commit)
+SOLID_PIN=$(git -C "$TMP/reactivescript" show "$COMMIT:ref/SOLID_NEXT" | tr -d '[:space:]')
+git -C "$TMP/reactivescript" fetch -q origin "$PINNED" 2>/dev/null || true
+{
+ echo "candidate reactivescript $COMMIT (pinned $PINNED)"
+ echo "candidate ref/SOLID_NEXT $SOLID_PIN (pinned solid $(pin .solid.commit))$([ "$SOLID_PIN" = "$(pin .solid.commit)" ] && echo ' unchanged' || echo ' CHANGED')"
+ echo "commits since pin: $(git -C "$TMP/reactivescript" rev-list --count "$PINNED..$COMMIT" 2>/dev/null || echo '?')"
+ echo "-- contract surface changes (pinned..candidate):"
+ git -C "$TMP/reactivescript" diff --stat "$PINNED" "$COMMIT" -- ref/src/ast.ts ref/src/model.ts ref/src/check.ts ref/src/infer.ts ref/src/parse.ts ref/src/modules.ts ref/src/failure.ts ref/compile/emit.ts ref/compile/jsx.ts ref/compile/rt.ts ref/compile/rt-web.ts ref/compile/rt-server.ts ref/compile/string-renderer.ts ref/package.json ref/SOLID_NEXT || true
+ echo "-- exported names added/removed in adapter-facing modules:"
+ for f in ref/src/ast.ts ref/src/model.ts ref/src/check.ts ref/src/infer.ts ref/src/parse.ts ref/compile/rt.ts; do
+ git -C "$TMP/reactivescript" diff -U0 "$PINNED" "$COMMIT" -- "$f" | grep -E '^[-+]export ' | cut -c1-160 | sed "s|^|$f: |" || true
+ done
+} | tee "$TMP/dry-run.txt"
+if [ $DRY = 1 ]; then
+ echo "DRY RUN ONLY: no Solid build, install, type check or test was executed. Run without --dry-run for the full canary."
+ exit 0
+fi
+
+git -C "$TMP/reactivescript" checkout -q "$COMMIT"
+if ! sh "$HERE/scripts/apply-patches.sh" "$TMP/reactivescript" "$TMP/reactivescript-patched" > "$TMP/patch-queue.log" 2>&1; then
+ cat "$TMP/patch-queue.log"; echo "PATCH QUEUE BREAKAGE: the temporary upstream patches no longer apply to $COMMIT (review each patch's removal condition)"; exit 1
+fi
+git clone -q --no-checkout "$WS/solid-next" "$TMP/solid-next"
+git -C "$TMP/solid-next" remote set-url origin "$(pin .solid.repo)"
+git -C "$TMP/solid-next" remote set-url --push origin NO_PUSH
+# upstream's own build script: checks out ref/SOLID_NEXT in $TMP/solid-next and builds it (Rust for the compiler)
+(cd "$TMP/reactivescript" && RUSTUP_TOOLCHAIN=$(pin .toolchain.rust) SOLID_NEXT="$TMP/solid-next" sh ref/scripts/solid-next.sh) > "$TMP/solid-build.log" 2>&1 \
+ || { echo "UPSTREAM BASELINE FAILURE: Solid build failed (see $TMP/solid-build.log)"; exit 1; }
+(cd "$TMP/reactivescript-patched/ref" && npm ci) > "$TMP/upstream-install.log" 2>&1 || { echo "UPSTREAM BASELINE FAILURE: npm ci in ref failed"; exit 1; }
+mkdir -p "$TMP/frontend"
+git -C "$HERE" archive HEAD | tar -x -C "$TMP/frontend"
+(cd "$TMP/frontend" && npm ci) > "$TMP/frontend-install.log" 2>&1 || { echo "ADAPTER/API BREAKAGE: npm ci of the frontend failed (see $TMP/frontend-install.log)"; exit 1; }
+set +e
+(cd "$TMP/frontend" && OUT="$TMP/report" sh scripts/verify-pinned.sh --canary $EXTRA)
+status=$?
+echo "canary $(grep -m1 '^VERDICT' "$TMP/report/REPORT.md" 2>/dev/null || echo 'VERDICT: NOT READY (no report)'); report $TMP/report/REPORT.md, exit $status. Pins, manifest and observations in $HERE were not modified."
+exit $status
diff --git a/tsx-frontend/scripts/check-types.ts b/tsx-frontend/scripts/check-types.ts
new file mode 100644
index 0000000..266a038
--- /dev/null
+++ b/tsx-frontend/scripts/check-types.ts
@@ -0,0 +1,27 @@
+// Strict type check of this package against the pinned upstream. Upstream's own .ts sources are type-checked too
+// (they are imported as source), and upstream does not build under these strict options; those diagnostics are
+// compared with baselines/upstream-tsc.txt and reported separately. Any diagnostic in this package fails the check.
+// Usage: node --experimental-strip-types scripts/check-types.ts [--out DIR]
+import { spawnSync } from "node:child_process";
+import { readFileSync, writeFileSync } from "node:fs";
+
+const root = new URL("..", import.meta.url).pathname;
+const tsc = spawnSync(process.execPath, [root + "node_modules/typescript/bin/tsc", "-p", root + "tsconfig.json"], { encoding: "utf8", cwd: root });
+const diags = tsc.stdout.split("\n").filter(l => /error TS\d+/.test(l));
+// In-tree, upstream's sources are the repository's ref/, reported relative to this package as ../ref/.
+const UPSTREAM = "../ref/";
+const ours = diags.filter(l => !l.startsWith(UPSTREAM));
+const normalize = (l: string) => l.replace(/\(\d+,\d+\)/, "").trim();
+const theirs = diags.filter(l => l.startsWith(UPSTREAM)).map(normalize).sort();
+const expected = readFileSync(root + "baselines/upstream-tsc.txt", "utf8").split("\n").filter(Boolean).sort();
+const added = theirs.filter(l => !expected.includes(l));
+const removed = expected.filter(l => !theirs.includes(l));
+const report = [
+ "package diagnostics: " + ours.length, ...ours,
+ "upstream strict diagnostics: " + theirs.length + " (baseline " + expected.length + ")",
+ ...added.map(l => " new upstream: " + l), ...removed.map(l => " gone upstream: " + l),
+].join("\n");
+const out = process.argv.includes("--out") ? process.argv[process.argv.indexOf("--out") + 1] : undefined;
+if (out) writeFileSync(out + "/types.txt", report + "\n");
+console.log(report);
+process.exitCode = ours.length || added.length || removed.length ? 1 : 0;
diff --git a/tsx-frontend/scripts/find-legacy-rx-views.ts b/tsx-frontend/scripts/find-legacy-rx-views.ts
new file mode 100644
index 0000000..fd08d0e
--- /dev/null
+++ b/tsx-frontend/scripts/find-legacy-rx-views.ts
@@ -0,0 +1,71 @@
+// One-off migration aid (upstream MO-1c): finds string/template literals in test files that are RX programs with
+// top-level markup and proposes the current spelling: the same declarations, with the view wrapped in
+// `export default component App() { … }`. Prints a JSON list of insertions; they are reviewed and applied by patch.
+// Candidates are literals in RX positions only: values of "*.rx" file keys, direct parseProgram(...) arguments, and
+// initializers of consts named rx/source/main. Each is parsed in a worker with a timeout, because upstream
+// parseProgram does not terminate on some inputs (docs/UPSTREAM-ISSUES.md); those are reported as skipped.
+// Usage: node --experimental-strip-types scripts/find-legacy-rx-views.ts tsx/a.test.ts ...
+import { readFileSync } from "node:fs";
+import { Worker, isMainThread, parentPort } from "node:worker_threads";
+import { parseSync } from "oxc-parser/src-js/index";
+import { parseProgram, viewStart } from "../tsx/rx/compiler.ts";
+
+if (!isMainThread) {
+ parentPort!.on("message", (text: string) => {
+ let legacy = false, at = -1;
+ try { legacy = !!parseProgram(text).legacyView; if (legacy) at = viewStart(text); } catch { legacy = false; }
+ parentPort!.postMessage({ legacy, at });
+ });
+} else {
+ type Piece = { start: number; raw: string; cooked: string };
+ type Candidate = { file: string; line: number; pieces: Piece[]; joined: string };
+ const PH = "__tsx_placeholder";
+ const positions = new WeakSet();
+ const mark = (v: unknown) => { if (v && typeof v === "object") positions.add(v); };
+ const candidates: Candidate[] = [];
+ for (const file of process.argv.slice(2)) {
+ const source = readFileSync(file, "utf8");
+ const ast = parseSync(file, source, { sourceType: "module", lang: "ts" }).program;
+ const visit = (node: unknown): void => {
+ if (Array.isArray(node)) return node.forEach(visit);
+ if (!node || typeof node !== "object") return;
+ const n = node as { type?: string; start: number; end: number; value?: unknown; quasis?: { start: number; end: number; value: { raw: string; cooked: string } }[] };
+ const any = node as Record;
+ if (any.type === "Property" && any.key?.type === "Literal" && typeof any.key.value === "string" && any.key.value.endsWith(".rx")) mark(any.value);
+ if (any.type === "CallExpression" && any.callee?.type === "Identifier" && any.callee.name === "parseProgram") mark(any.arguments?.[0]);
+ if (any.type === "VariableDeclarator" && any.id?.type === "Identifier" && ["rx", "main"].includes(any.id.name)) mark(any.init);
+ let pieces: Piece[] | undefined;
+ if (n.type === "Literal" && typeof n.value === "string") pieces = [{ start: n.start + 1, raw: source.slice(n.start + 1, n.end - 1), cooked: n.value }];
+ if (n.type === "TemplateLiteral" && n.quasis) pieces = n.quasis.map(q => ({ start: q.start, raw: q.value.raw, cooked: q.value.cooked }));
+ if (pieces) {
+ const joined = pieces.map(p => p.cooked).join(PH);
+ if (positions.has(node as object) && joined.includes("<")) candidates.push({ file, line: source.slice(0, n.start).split("\n").length, pieces, joined });
+ }
+ for (const value of Object.values(node)) if (value && typeof value === "object") visit(value);
+ };
+ visit(ast);
+ }
+ // RX-position literals are whole programs; run under an outer time guard (perl alarm) in case one still hangs.
+ const ask = (text: string): { legacy: boolean; at: number } | "timeout" => {
+ if (process.env.TRACE) process.stderr.write(JSON.stringify(text.slice(0, 100)) + "\n");
+ try { const p = parseProgram(text); return { legacy: !!p.legacyView, at: p.legacyView ? viewStart(text) : -1 }; } catch { return { legacy: false, at: -1 }; }
+ };
+ const inserts: { file: string; at: number; text: string }[] = [];
+ const skipped: { file: string; line: number; why: string }[] = [];
+ for (const c of candidates) {
+ const answer = ask(c.joined);
+ if (answer === "timeout") { skipped.push({ file: c.file, line: c.line, why: "upstream parseProgram did not terminate" }); continue; }
+ if (!answer.legacy) continue;
+ const locate = (offset: number): number | undefined => {
+ let base = 0;
+ for (const p of c.pieces) {
+ if (offset <= base + p.cooked.length) return p.raw === p.cooked ? p.start + offset - base : undefined;
+ base += p.cooked.length + PH.length;
+ }
+ };
+ const a = locate(answer.at), b = locate(c.joined.trimEnd().length);
+ if (a === undefined || b === undefined) skipped.push({ file: c.file, line: c.line, why: "escapes or expression at the view boundary" });
+ else inserts.push({ file: c.file, at: a, text: "export default component App() { " }, { file: c.file, at: b, text: " }" });
+ }
+ console.log(JSON.stringify({ candidates: candidates.length, inserts, skipped }));
+}
diff --git a/tsx-frontend/scripts/jsonl-reporter.mjs b/tsx-frontend/scripts/jsonl-reporter.mjs
new file mode 100644
index 0000000..cb289b0
--- /dev/null
+++ b/tsx-frontend/scripts/jsonl-reporter.mjs
@@ -0,0 +1,19 @@
+// node:test reporter: one JSON line per finished test (pass/fail), for scripts/report.ts. Suites are included as
+// ordinary entries; a failing file that never registers tests appears as one failing entry named after the file.
+export default async function* jsonlReporter(source) {
+ for await (const event of source) {
+ if (event.type !== "test:pass" && event.type !== "test:fail") continue;
+ const d = event.data;
+ const error = event.type === "test:fail" ? d.details?.error : undefined;
+ const message = error ? String(error.cause?.message ?? error.message ?? error).split("\n").slice(0, 6).join("\n") : undefined;
+ yield JSON.stringify({
+ status: event.type === "test:pass" ? (d.skip ? "skip" : d.todo ? "todo" : "pass") : "fail",
+ file: d.file ? d.file.split("/frontend/").at(-1) : undefined,
+ line: d.line,
+ column: d.column,
+ name: d.name,
+ nesting: d.nesting,
+ error: message,
+ }) + "\n";
+ }
+}
diff --git a/tsx-frontend/scripts/report.ts b/tsx-frontend/scripts/report.ts
new file mode 100644
index 0000000..09dd706
--- /dev/null
+++ b/tsx-frontend/scripts/report.ts
@@ -0,0 +1,146 @@
+// Verdict for a verification run directory written by scripts/verify-pinned.sh.
+//
+// Two modes, deliberately separate:
+// --mode ready (default) READINESS. Exit 0 only when every recorded step exited 0, both result sets are complete
+// (spec summary and JSONL agree), every test in the coverage manifest ran, nothing failed,
+// nothing was skipped/todo, and the upstream runners ran and matched their pinned summaries.
+// --mode compare COMPARISON ONLY. Says whether this run reproduces baselines/observed-failures.json
+// (same failures, same coverage gaps). It never reports readiness: the verdict line is
+// still NOT READY whenever anything fails. Integrity problems (missing or truncated results,
+// failed upstream/type steps) fail this mode too.
+// Nothing here writes the observation or manifest files; --proposal writes a separate file for human review.
+//
+// Usage: node --experimental-strip-types scripts/report.ts --out DIR [--mode ready|compare]
+// [--observed FILE] [--manifest FILE] [--pins FILE] [--proposal FILE]
+import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs";
+
+type Result = { status: string; file?: string; name: string; error?: string };
+const arg = (name: string) => { const i = process.argv.indexOf(name); return i < 0 ? undefined : process.argv[i + 1]; };
+const here = (p: string) => new URL("../" + p, import.meta.url).pathname;
+const out = arg("--out");
+if (!out) throw new Error("--out DIR is required");
+const mode = arg("--mode") ?? "ready";
+if (mode !== "ready" && mode !== "compare") throw new Error("--mode must be ready or compare");
+const readJson = (p: string) => JSON.parse(readFileSync(p, "utf8"));
+const pins = readJson(arg("--pins") ?? here("pins.json"));
+const manifestPath = arg("--manifest") ?? here("baselines/coverage-manifest.json");
+const observedPath = arg("--observed") ?? here("baselines/observed-failures.json");
+const manifest: { tests: string[] } = existsSync(manifestPath) ? readJson(manifestPath) : { tests: [] };
+const observed: { failures: Record; missing: string[] } =
+ existsSync(observedPath) ? readJson(observedPath) : { failures: {}, missing: [] };
+
+const problems: string[] = []; // integrity: fail in every mode
+const notReady: string[] = []; // readiness blockers
+
+// 1. Recorded process exits (verify-pinned.sh writes "name code" lines); never inferred from log text.
+const exits = new Map();
+if (existsSync(out + "/exits.txt"))
+ for (const line of readFileSync(out + "/exits.txt", "utf8").split("\n").filter(Boolean)) {
+ const [name, code] = line.trim().split(/\s+/);
+ exits.set(name, Number(code));
+ }
+else problems.push("exits.txt missing: process exit codes were not recorded");
+const REQUIRED = ["types", "suite", "browser"];
+const UPSTREAM = ["upstream-test", "upstream-oracle", "upstream-compiled", "upstream-types", "upstream-ssr"];
+for (const step of REQUIRED) if (!exits.has(step)) problems.push("step " + step + " did not run (no recorded exit)");
+for (const step of UPSTREAM) if (!exits.has(step)) notReady.push("upstream runner " + step + " did not run");
+for (const [step, code] of exits) {
+ if (code === 0) continue;
+ if (step === "suite" || step === "browser") notReady.push(step + " exited " + code);
+ else problems.push(step + " exited " + code);
+}
+
+// 2. Result integrity: the JSONL must be complete relative to the reporter's own summary.
+const results: Result[] = [];
+for (const set of ["suite", "browser"]) {
+ const jsonl = out + "/" + set + ".jsonl", spec = out + "/" + set + ".spec.txt";
+ if (!existsSync(jsonl) || !existsSync(spec)) { problems.push(set + ": result files missing"); continue; }
+ const rows: Result[] = [];
+ for (const line of readFileSync(jsonl, "utf8").split("\n").filter(Boolean)) {
+ try { rows.push(JSON.parse(line)); } catch { problems.push(set + ": unparsable result line (truncated?)"); }
+ }
+ const text = readFileSync(spec, "utf8");
+ const count = (label: string) => { const m = text.match(new RegExp("^ℹ " + label + " (\\d+)$", "m")); return m ? Number(m[1]) : undefined; };
+ const tests = count("tests"), fail = count("fail"), cancelled = count("cancelled"), skipped = count("skipped"), todo = count("todo");
+ if (tests === undefined || fail === undefined) problems.push(set + ": reporter summary missing (truncated run)");
+ else {
+ if (rows.length !== tests) problems.push(set + ": " + rows.length + " result lines but the reporter counted " + tests + " tests");
+ if (rows.filter(r => r.status === "fail").length !== fail) problems.push(set + ": failing result lines disagree with the reporter's fail count " + fail);
+ }
+ if (cancelled) problems.push(set + ": " + cancelled + " cancelled");
+ if (skipped || todo) notReady.push(set + ": " + (skipped ?? 0) + " skipped, " + (todo ?? 0) + " todo (policy: none)");
+ if (rows.length === 0) problems.push(set + ": no results");
+ results.push(...rows);
+}
+
+// 3. Identity (file :: name, with an occurrence index for repeated names) and coverage against the manifest.
+const seen = new Map();
+const keyed = results.map(r => {
+ const file = (r.file ?? "?").replace(/^.*\/tsx\//, "tsx/");
+ const base = file + " :: " + r.name;
+ const n = seen.get(base) ?? 0; seen.set(base, n + 1);
+ return { ...r, id: n ? base + " #" + n : base };
+});
+const ran = new Set(keyed.map(r => r.id));
+const missing = manifest.tests.filter(id => !ran.has(id));
+const unlisted = keyed.filter(r => !manifest.tests.includes(r.id)).map(r => r.id);
+if (!manifest.tests.length) problems.push("coverage manifest missing or empty");
+if (missing.length) notReady.push(missing.length + " manifest tests did not run (coverage gap)");
+
+// 4. Upstream summaries against the pins (only for runners that ran).
+const summaries = UPSTREAM.filter(s => existsSync(out + "/" + s + ".log")).map(s => {
+ const text = readFileSync(out + "/" + s + ".log", "utf8");
+ const tally = (w: string) => text.split("\n").filter(l => l.startsWith(w + " ")).length;
+ const line = text.split("\n").reverse().find(l => /\d+ pass, \d+ fail/.test(l));
+ const summary = line ? line.match(/\d+ pass, \d+ fail(, \d+ skip)?/)![0] : tally("PASS") + tally("FAIL") ? tally("PASS") + " pass, " + tally("FAIL") + " fail" : "(no summary)";
+ const expected = pins.upstreamBaseline?.[s.slice("upstream-".length)];
+ if (expected !== summary) problems.push(s + ": " + summary + " (pinned " + expected + ")");
+ return s + ": " + summary;
+});
+
+const failing = keyed.filter(r => r.status === "fail");
+if (failing.length) notReady.push(failing.length + " failing tests");
+const ready = !problems.length && !notReady.length;
+
+// Comparison against the unapproved observation (compare mode only).
+const freshFailures = failing.filter(r => !observed.failures[r.id]).map(r => r.id);
+const nowPassing = Object.keys(observed.failures).filter(id => ran.has(id) && !failing.some(r => r.id === id));
+const vanished = Object.keys(observed.failures).filter(id => !ran.has(id) && !observed.missing.includes(id));
+const newGaps = missing.filter(id => !observed.missing.includes(id));
+const closedGaps = observed.missing.filter(id => ran.has(id));
+const reproduces = !freshFailures.length && !nowPassing.length && !vanished.length && !newGaps.length && !closedGaps.length;
+
+const lines = [
+ "# Verification report (" + (mode === "ready" ? "READINESS" : "COMPARISON ONLY: not a readiness check") + ")",
+ "",
+ "VERDICT: " + (ready ? "READY" : "NOT READY") + (ready ? "" : " (" + [...problems, ...notReady].length + " blockers)"),
+ ...(mode === "compare" ? ["COMPARISON: " + (problems.length ? "INVALID (integrity problems below)" : reproduces ? "reproduces the observed (unapproved) failures and coverage gaps" : "DIFFERS from the observation")] : []),
+ "",
+ "Results: " + keyed.length + " tests, " + keyed.filter(r => r.status === "pass").length + " pass, " + failing.length + " fail; manifest " + manifest.tests.length + ", missing " + missing.length + ", unlisted " + unlisted.length,
+ "Recorded exits: " + [...exits].map(([k, v]) => k + "=" + v).join(" "),
+ "",
+ "## Integrity problems: " + problems.length, ...problems.map(p => "- " + p),
+ "## Readiness blockers: " + notReady.length, ...notReady.map(p => "- " + p),
+ "## Upstream runners", ...summaries.map(s => "- " + s),
+ "## Coverage gaps (manifest tests that did not run): " + missing.length, ...missing.map(id => "- " + id),
+ "## Tests not in the manifest: " + unlisted.length, ...unlisted.map(id => "- " + id),
+ "## Failing tests: " + failing.length,
+ ...failing.map(r => "- " + r.id + (observed.failures[r.id] ? " [observed: " + observed.failures[r.id].category + "]" : " [NEW]") + "\n " + (r.error ?? "").split("\n")[0].slice(0, 200)),
+ ...(mode === "compare" ? [
+ "## Comparison with " + observedPath,
+ "- new failures: " + freshFailures.length, ...freshFailures.map(id => " - " + id),
+ "- observed failures now passing: " + nowPassing.length, ...nowPassing.map(id => " - " + id),
+ "- observed failures that did not run: " + vanished.length, ...vanished.map(id => " - " + id),
+ "- new coverage gaps: " + newGaps.length, ...newGaps.map(id => " - " + id),
+ "- observed gaps now closed: " + closedGaps.length, ...closedGaps.map(id => " - " + id),
+ ] : []),
+];
+writeFileSync(out + "/REPORT.md", lines.join("\n") + "\n");
+console.log(lines.slice(0, 6).join("\n"));
+const proposal = arg("--proposal");
+if (proposal) {
+ writeFileSync(proposal, JSON.stringify({ status: "PROPOSAL ONLY: unapproved observation, not a readiness baseline",
+ failures: Object.fromEntries(failing.map(r => [r.id, observed.failures[r.id] ?? { category: "UNREVIEWED", note: (r.error ?? "").split("\n")[0].slice(0, 200) }])),
+ missing }, null, 2) + "\n");
+}
+process.exitCode = mode === "ready" ? (ready ? 0 : 1) : (!problems.length && reproduces ? 0 : 1);
diff --git a/tsx-frontend/scripts/run-rx-repro.ts b/tsx-frontend/scripts/run-rx-repro.ts
new file mode 100644
index 0000000..5087088
--- /dev/null
+++ b/tsx-frontend/scripts/run-rx-repro.ts
@@ -0,0 +1,31 @@
+// Runs a handwritten RX reproduction (a directory with main.rx and any modules it imports) on the pinned upstream:
+// prints the checker diagnostics, the reference interpreter's first settled frame and the compiled frame.
+// Usage: node --experimental-strip-types --conditions=browser --conditions=development scripts/run-rx-repro.ts DIR
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { checkModules, compileModules, loadModules, lowerViews, STRING_RENDERER } from "../tsx/rx/compiler.ts";
+import { createApp, flush, Runtime } from "../tsx/rx/runtime.ts";
+
+const dir = resolve(process.argv[2] ?? ".");
+const read = (p: string) => existsSync(dir + "/" + p) ? readFileSync(dir + "/" + p, "utf8") : undefined;
+const set = loadModules("main.rx", read);
+const diags = [...set.diags, ...checkModules(set).diags];
+console.log("diagnostics: " + (diags.length ? diags.map(d => d.code + " " + (d.file ?? "") + ":" + d.line + " " + d.msg.slice(0, 160)).join("\n ") : "none"));
+if (!diags.length) {
+ console.log("interpreter: " + new Runtime(set).step([]).at(-1)!.frame);
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const out = mkdtempSync(root + "repro-");
+ const renderer = fileURLToPath(new URL("../tsx/test-renderer.ts", import.meta.url));
+ try {
+ const built = compileModules(set);
+ for (const [name, code] of lowerViews(built.files, "universal")) writeFileSync(out + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(out + "/" + built.entry).href);
+ const app = createApp();
+ const dispose = app.mount(mod.default(app));
+ for (let i = 0; i < 6; i++) { await new Promise(r => setImmediate(r)); flush(); }
+ console.log("compiled: " + app.frame());
+ dispose();
+ } finally { rmSync(out, { recursive: true, force: true }); }
+}
diff --git a/tsx-frontend/scripts/verify-pinned.sh b/tsx-frontend/scripts/verify-pinned.sh
new file mode 100644
index 0000000..35df04a
--- /dev/null
+++ b/tsx-frontend/scripts/verify-pinned.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+# Verification of the pinned stack. Every step's process exit code is recorded in $OUT/exits.txt and judged by
+# scripts/report.ts; nothing is inferred from log tails.
+# sh scripts/verify-pinned.sh [--compare] [--skip-upstream] [--rust] [--canary]
+# default READINESS: exits 0 only if pins match, types are clean, every suite and browser test passes, the whole
+# coverage manifest ran, and the upstream runners ran and match their pinned summaries.
+# --compare COMPARISON ONLY: exits 0 when the run reproduces baselines/observed-failures.json (an unapproved
+# observation of the migration's current failures and coverage gaps). The report still says NOT READY.
+# --skip-upstream skip upstream runners (never READY then); --rust adds the crate parity runners;
+# --canary used by scripts/canary.sh: pins may differ from the checkout (reported, not enforced).
+set -u
+HERE=$(cd "$(dirname "$0")/.." && pwd)
+RX=$(cd "$HERE/upstream" && pwd -P)
+SOLID=$(cd "$RX/../solid-next" && pwd -P)
+OUT=${OUT:-$HERE/.out/verify-$(date +%Y%m%d-%H%M%S)}
+mkdir -p "$OUT"; : > "$OUT/exits.txt"
+MODE=ready; SKIP_UPSTREAM=0; RUST=0; CANARY=0
+for a in "$@"; do case $a in --compare) MODE=compare;; --skip-upstream) SKIP_UPSTREAM=1;; --rust) RUST=1;; --canary) CANARY=1;; *) echo "unknown option $a"; exit 2;; esac; done
+[ $CANARY = 1 ] && export RX_CANARY=1
+record() { echo "$1 $2" >> "$OUT/exits.txt"; echo "step $1 exit $2"; }
+
+pin() { node -e "process.stdout.write(require('$HERE/pins.json')$1)"; }
+RX_HEAD=$(git -C "$RX" rev-parse HEAD); SOLID_HEAD=$(git -C "$SOLID" rev-parse HEAD); SOLID_NEXT=$(cat "$RX/ref/SOLID_NEXT")
+{
+ echo "reactivescript $RX_HEAD (pin $(pin .reactivescript.commit))"
+ echo "ref/SOLID_NEXT $SOLID_NEXT; solid checkout $SOLID_HEAD (pin $(pin .solid.commit))"
+ echo "node $(node --version); rustc $(RUSTUP_TOOLCHAIN=$(pin .toolchain.rust) rustc --version 2>/dev/null)"
+} | tee "$OUT/pins.txt"
+pins=0
+# The upstream tree may differ from its commit only by exactly the declared patch queue (patches/README.md).
+QUEUE=$(cat "$HERE"/patches/[0-9][0-9][0-9][0-9]-*.patch 2>/dev/null || true)
+if [ "$(git -C "$RX" diff)" != "$QUEUE" ] || [ -n "$(git -C "$RX" status --porcelain --untracked-files=normal | grep -v '^ M ' || true)" ]; then
+ echo "MISMATCH: upstream tree differs from its commit by more or less than the patch queue"; pins=1
+else
+ echo "upstream tree = commit + patch queue ($(ls "$HERE"/patches/[0-9][0-9][0-9][0-9]-*.patch 2>/dev/null | wc -l | tr -d ' ') patches)"
+fi
+[ "$SOLID_HEAD" = "$SOLID_NEXT" ] || { echo "MISMATCH: solid checkout is not ref/SOLID_NEXT"; pins=1; }
+if [ $CANARY = 0 ]; then
+ [ "$RX_HEAD" = "$(pin .reactivescript.commit)" ] || { echo "MISMATCH: reactivescript is not the pinned commit"; pins=1; }
+ [ "$SOLID_HEAD" = "$(pin .solid.commit)" ] || { echo "MISMATCH: solid is not the pinned commit"; pins=1; }
+fi
+record pins $pins
+
+cd "$HERE"
+node --experimental-strip-types scripts/check-types.ts --out "$OUT"; record types $?
+node --experimental-strip-types --conditions=browser --conditions=development --test \
+ --test-reporter=spec --test-reporter-destination="$OUT/suite.spec.txt" \
+ --test-reporter=./scripts/jsonl-reporter.mjs --test-reporter-destination="$OUT/suite.jsonl" tsx/*.test.ts
+record suite $?
+node --experimental-strip-types --test \
+ --test-reporter=spec --test-reporter-destination="$OUT/browser.spec.txt" \
+ --test-reporter=./scripts/jsonl-reporter.mjs --test-reporter-destination="$OUT/browser.jsonl" tsx/browser.ts
+record browser $?
+
+if [ $SKIP_UPSTREAM = 0 ]; then
+ for s in test oracle compiled types ssr; do
+ (cd "$RX/ref" && NODE_PATH="$HERE/node_modules" npm run -s $s > "$OUT/upstream-$s.log" 2>&1); record upstream-$s $?
+ done
+ if [ $RUST = 1 ]; then
+ for m in "" --lower --project; do
+ (cd "$RX/ref" && RUSTUP_TOOLCHAIN=$(pin .toolchain.rust) npm run -s crate -- $m > "$OUT/crate$m.log" 2>&1); record crate$m $?
+ done
+ fi
+fi
+
+node --experimental-strip-types scripts/report.ts --out "$OUT" --mode $MODE --proposal "$OUT/proposed-observation.json"
+status=$?
+echo "report: $OUT/REPORT.md (mode $MODE, exit $status)"
+exit $status
diff --git a/tsx-frontend/tsconfig.json b/tsx-frontend/tsconfig.json
new file mode 100644
index 0000000..d0f6e95
--- /dev/null
+++ b/tsx-frontend/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "noEmit": true,
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "target": "esnext",
+ "allowImportingTsExtensions": true,
+ "skipLibCheck": true,
+ "types": ["node"]
+ },
+ "files": [
+ "tsx/lower.ts",
+ "tsx/modules.ts",
+ "tsx/build.ts",
+ "tsx/primitives.ts",
+ "tsx/previous.ts",
+ "tsx/effects.ts",
+ "tsx/rx/ast.ts",
+ "tsx/rx/compiler.ts",
+ "tsx/rx/runtime.ts",
+ "tsx/rx/infer.ts",
+ "tsx/rx/failure.ts",
+ "tsx/rx/facts.ts",
+ "tsx/rx/string-renderer.ts",
+ "tsx/wire-key.ts"
+ ]
+}
diff --git a/tsx-frontend/tsx/MODULES.md b/tsx-frontend/tsx/MODULES.md
new file mode 100644
index 0000000..719cd82
--- /dev/null
+++ b/tsx-frontend/tsx/MODULES.md
@@ -0,0 +1,65 @@
+# Mixed Modules
+
+The prototype loader accepts explicit .tsx and .rx imports in either direction.
+Both languages become one dependency-ordered ModuleSet, then use the existing
+checkModules, module signatures and compileModules. It does not introduce another
+checker or translate RX into TSX. Module cycles and missing files are diagnostics.
+
+TSX modules lower to ReactiveScript's existing AST only, so their exports are
+ordinary RX exports. An exported TSX `const` is an exported snapshot source
+(`export source x := peek(e)`, or an equation for a function value): RX importers
+see a read-only source (MO-2, writes are `WRITE_TO_NON_SOURCE`), and TSX importers
+read a plain value. Which exports are TSX consts is frontend bookkeeping kept in
+`tsx/lower.ts` (`immutableExports`), not an AST field or a signature kind; there
+is no `const` signature. A TSX importer cannot assign one, and an `.rx` importer's
+root or path write is `WRITE_TO_NON_SOURCE (MO-2)` (see `setup-values.test.ts`).
+
+From ref/:
+
+~~~sh
+npm run build -- tsx/fixtures/counter.tsx --mount Counter --target dom --out .out/tsx-counter
+~~~
+
+The command checks before emitting. It writes one ES module per input module and
+prints the emitted entry path. The outputs are the existing ReactiveScript
+per-application factories, not ordinary named Solid component exports. They still
+need the target's ReactiveScript runtime and bundling; this is not a standalone
+browser bundle or a production server-function transport.
+
+Choosing a mount is explicit. A TSX module that exports components does not run
+them merely because it was imported. An RX entry with its own view needs no
+mount option. API consumers can call buildTsx or loadTsxModules with an in-memory
+source reader, just like the RX loader.
+
+Relative JS declaration files beside mixed-language imports retain their .d.rx
+contracts. Arbitrary npm implementation bodies are not made pure by this loader,
+and the reference compiler's existing JS prelude restrictions still apply.
+Component signatures also retain HTML content models: a row exported by either
+frontend still requires an explicit table section at its application. Caller
+error boundaries do not discharge failures that a child action reads later.
+
+## Browser Verification
+
+The separate real-browser test renders RX and TSX through SSR, checks that
+hydration adopts their existing button nodes, and clicks them after takeover:
+
+~~~sh
+npx playwright install chromium
+npm run test:browser
+~~~
+
+The paired cases cover synchronous counters, typed input callbacks, branch
+remounts, keyed rows, async answers and streams, two-input `createRxEffect` effects, provider
+snapshots, writable source contexts, typed failures, and isolated in-process
+server-loader factories.
+The direct JS API and server-loader cases currently invoke the API once again
+during hydration in both syntaxes;
+the test records that shared limitation, rather than claiming request reuse.
+This tests full hydration, not resumability or server-only components. Fast
+module, checker, and compiler tests run with npm test without a browser.
+
+The setup script builds Solid's generated declaration files as well as its JS
+packages. These declarations are required by npm run check, which also checks
+the shared interpreter and emitter; ambient any-type stubs are not used to hide
+missing dependencies. The native Solid compiler at
+the current pin requires Rust 1.95 or later.
diff --git a/tsx-frontend/tsx/PARITY.md b/tsx-frontend/tsx/PARITY.md
new file mode 100644
index 0000000..70590e5
--- /dev/null
+++ b/tsx-frontend/tsx/PARITY.md
@@ -0,0 +1,92 @@
+# RX Parity Target
+
+Parity means that an executable RX feature has an authored TSX spelling that
+lowers to the same shared semantics and passes behavioral tests. Merely exposing
+an AST node or allowing a TSX module to import an RX implementation does not
+establish syntax parity.
+
+The baseline is the current reference interpreter and compiled scenario suite,
+not unimplemented production-compiler plans. The shared compiler still has its
+existing JS-prelude restrictions, six recorded Solid-oracle frame differences,
+and incomplete server-function transport. This frontend does not remove those
+limits. Nor does RX parity imply compatibility with arbitrary JavaScript.
+
+## Completion Checklist
+
+- Primitive and structured expressions, one-time snapshots and helper functions.
+- Atomic and structured sources, equations, previous values, drafts, deferred
+ equations, keyed reconciliation, and derived-until-written sources.
+- Components, reactive props, callback contracts, children, ownership and cleanup.
+- Conditional regions and loops, including stateful branch and row bodies.
+- Split effects, imperative locals, writes, branches, return, throw and try/catch,
+ including non-held async callbacks and their Promise completion semantics
+ (authored TSX async callbacks are refused for now: [issue #20](https://github.com/ryansolid/reactivescript/issues/20)).
+- Async equations, initial values, streams, loading/error boundaries and probes.
+- Actions, optimistic writes, refresh and affects, within RX's implemented limits.
+- Typed failures and error classes, retaining checking across module boundaries.
+- Context identities, defaults, provision scopes and required-context checking.
+- Mixed RX/TSX modules, exported signatures and JS declaration-file contracts.
+- Universal, DOM and SSR emission, plus actual browser hydration and interaction.
+- Server functions/actions with the same capture and wire checks as RX.
+
+This is a completion target, not a claim that every item is implemented. The
+README describes the accepted syntax; tests are the coverage evidence. Unsupported
+forms must continue to fail explicitly instead of being assigned guessed semantics.
+
+## Authored Evidence
+
+These suites start from authored TSX and exercise behavior, rather than counting
+shared AST constructors as frontend coverage. The raw-AST core regressions remain
+useful additional evidence, but are not substitutes for these tests.
+
+| Executable behavior | Authored spelling | Proof suites |
+| --- | --- | --- |
+| Expressions, eager snapshots (`source x := peek(e)`), pure (inert locals only) and owned helpers | Ordinary values, `const` with `untrack` for tracked reads, typed functions, accessor arguments | `semantics.test.ts`, `features.test.ts`, `owned-helpers.test.ts`, `setup-values.test.ts`, `snapshot-core.test.ts` |
+| Atomic/structured sources and previous answers | `createSignal`, `createStore`, memo previous-value parameter, `createProjection` | `semantics.test.ts`, `features.test.ts` |
+| Deferred answers, pending probes, independent landings | Async `createDeferred`, `isPending`, `latest` | `async-control.test.ts` |
+| Components, props, events, callback failures and finally | Typed props/handlers, forwarding, ordinary `try`/`catch`/`finally` | `lower.test.ts`, `callbacks.test.ts`, `contracts.test.ts` |
+| Non-held async callbacks and microtask ordering (native and handwritten RX legs) | None for now: authored `async` callbacks are refused at their `async` node ([issue #20](https://github.com/ryansolid/reactivescript/issues/20)); an explicit `action(function* ...)` opts into held semantics instead | `async-handlers.test.ts`, `boundary.test.ts`, `browser.ts` |
+| Branch and row ownership, removal and fresh remounts | `Show`, `For`, keyed projections, owned helper calls | `features.test.ts`, `owned-lifetimes.test.ts`, `browser.ts` |
+| RX effects (EF-2), error answers and cleanup; plain `createEffect` refused | Extension `createRxEffect` | `features.test.ts`, `effect-semantics.test.ts`, `rx-effects.test.ts`, `owned-lifetimes.test.ts`, `browser.ts` |
+| Async values, seeded answers, streams and boundaries | Async/iterable memos, `Loading`, `Errored`, probes | `async-bridge.test.ts`, `async-stream.test.ts`, `browser.ts` |
+| Held actions, optimistic values, refresh and affects | Named/inline generator `action`, `createOptimistic`, `refresh`, `affects` | `actions.test.ts`, `async-control.test.ts`, `features.test.ts` |
+| Declared/filtered failures and imperative catches | `defineError`, `raiseFailure`, `catchFailure`, `FailureBoundary`, guarded catches | `features.test.ts`, `contracts.test.ts`, `callbacks.test.ts` |
+| Context identity, defaults, required/live and writable contexts | Native contexts/accessors; canonical `createSourceContext` tuples | `features.test.ts`, `modules.test.ts`, `contracts.test.ts`, `owned-lifetimes.test.ts` |
+| Mixed modules, JS contracts, HTML content models | Explicit `.tsx`/`.rx` imports and `.d.rx` declarations | `modules.test.ts`, `contracts.test.ts`, `build.test.ts` |
+| Server capture, wire values, cache identity and refresh | `serverFunction`, `serverAction`, `createServerMemo`, extension `refresh` | `primitives.test.ts`, `server-identities.test.ts`, `contracts.test.ts` |
+| Universal, DOM, SSR and actual hydration | Checked build targets and explicit component mounting | `build.test.ts`, `browser.ts` |
+
+The baseline's HTTP transport, partial hydration/resumability, arbitrary npm
+execution, optimistic path writes, and recorded oracle/SSR divergences are not
+made complete by a syntax frontend. The README records the supported native
+subset and deliberate rejections separately from this RX feature target.
+
+## Architectural Constraints
+
+JavaScript snapshots must be eager values, not lazy untracked equations. An
+object-valued Solid signal must not silently become a fine-grained store. JSX
+booleans/null/undefined must be omitted rather than formatted as RX text.
+Ordinary function calls must retain value-versus-accessor parameter behavior.
+Providers must not leak to sibling owners. Import aliases must preserve binding
+and context identity. Async handlers must not silently become hold-safe actions; for now TSX refuses them outright.
+
+The frontend targets only ReactiveScript's existing AST; it adds no shared AST
+constructs, flags, or fields, and there is no shared IR. Where a Solid behavior
+has no faithful spelling in the existing nodes, the form is refused explicitly:
+
+- Setup snapshots are RX snapshot sources (`source x := peek(e)`, E-4/DV-6); a
+ bare tracked read in setup is reported as `SNAPSHOT_INITIALIZER` by the shared
+ checker rather than snapshotted. Snapshots whose eager timing RX would observe
+ differently (unused failing initializers, untracked async reads, reads before
+ initialization, server-body captures) are refused.
+- Plain Solid `createEffect` reruns for equal direct computes and skips compute
+ errors, unlike RX's EF-2 value filtering and E-12 error-as-answer contract. It
+ is refused; `createRxEffect` is the effect form, and it lowers to RX's one
+ `effect` form.
+- Structured versus atomic is the type (ST-1): store questions carry their value
+ contract as type text, and object-valued `createSignal` stays refused.
+- Eager initializers that could fail are refused unless caught by a catch-all in
+ the initializer: Solid's setup-time throw routing cannot be expressed by an RX
+ snapshot source. Props snapshots are accepted only for non-exported components
+ whose in-module call sites pass total values; selective catches, rejecting JS
+ calls and other modules' values are refused (README, "Setup Consts").
diff --git a/tsx-frontend/tsx/README.md b/tsx-frontend/tsx/README.md
new file mode 100644
index 0000000..ff0b3af
--- /dev/null
+++ b/tsx-frontend/tsx/README.md
@@ -0,0 +1,418 @@
+# Solid TSX Frontend Prototype
+
+Oxc parses authored Solid-style TSX and `lowerTsx` constructs the existing
+ReactiveScript `Program` from `ref/src/ast.ts`. This is a bounded executable
+frontend, not a second checker, runtime, or a claim of full Solid/RX parity.
+The shared checker, interpreter, and compiler remain authoritative.
+
+The frontend targets only ReactiveScript's existing AST: there is no shared IR
+and no node, flag, or field that exists for TSX. Every accepted form is spelled
+with what an RX program could say (equations, `source x := peek(e)` snapshots,
+`source x = e` questions, `fn`, one `effect` form, types as text). A Solid form
+with no faithful RX spelling is refused with a diagnostic instead of being
+lowered to nearby RX semantics. Frontend-only bookkeeping (which TSX exports are
+plain values) lives in `lower.ts` (`immutableExports`), never in the AST.
+
+See [MODULES.md](MODULES.md) for mixed RX/TSX module loading, builds, CLI usage,
+and browser targets. [PARITY.md](PARITY.md) tracks the wider executable target,
+remaining gaps, and verification boundaries. Exports never mount implicitly.
+
+The adapter uses pinned `oxc-parser` through its explicit Node entry so the
+browser condition does not select an unavailable WASM binding. JSX entities use
+`entities` and `decodeHTML`, including native semicolon-less legacy/numeric
+forms, not an incomplete hand-maintained entity table.
+No Sound implementation is imported or depended on.
+
+## API
+
+```ts
+import { lowerTsx } from "./lower.ts";
+import { check } from "./rx/compiler.ts";
+
+const program = lowerTsx(source, "Counter.tsx", {
+ modules: new Map(), // authored source -> { path, program }
+ declarations: new Map(), // authored JS source -> parsed JsDecl[]
+});
+const diagnostics = check(program);
+```
+
+For executable graphs, prefer the loader/build APIs documented in MODULES.md.
+They attach JS declaration contracts, check the graph, select the mount, and
+emit the chosen target. `lowerTsx` alone does not assert JS import purity.
+
+## Supported Forms
+
+| Authored form | Existing AST and scope |
+| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
+| Named synchronous uppercase component functions, optionally exported | `component`; same-file and resolved imported components |
+| Inline required primitive, record, array and handler prop contracts | `propsType`; live values and parameterized void `on[A-Z]` callbacks |
+| Module/component/branch/owned-helper setup `const` | Snapshot `source x := peek(e)` (E-4); function values are equations; see below |
+| Explicitly typed pure module/local helpers with inert `const` locals | Plain `fn` with `decls: []`; inert locals substituted into the tail; others refused |
+| Primitive `createSignal` and expression/single-return `createMemo` | `source` and `eq`; direct values and one-parameter signal updaters |
+| Typed primitive previous-value memo parameter | Self `pre`; initial previous value is undefined |
+| Solid 2 `createStore` with known record/array seed | Source plus draft callback path writes |
+| Synchronous `createProjection(draft => { ... }, seed)` | Seed snapshot source plus `eq` using `draft(pre projection -> seed)` |
+| Primitive literals, records/arrays, known members and numeric indices | Existing expression nodes; unsupported shape combinations fail closed |
+| Arithmetic, strict equality, relational/logical operators, conditionals, parentheses | Existing operators; JS `===`/`!==` spell RX strict `==`/`!=` |
+| `untrack`, `latest`, `isPending` callback probes | `peek`, `latest`, `pending` |
+| Fragments, comments, multiline/entity JSX text and quoted attributes | `el`, normalized/decoded `text`, ordinary attributes |
+| Primitive and nested primitive-array JSX children | Explicit JS string conversion; booleans/null/undefined omitted |
+| Conditional JSX, `&&`, non-keyed `Show` | `mif`; Show callback argument is an accessor, not a value |
+| Default `For`, one value-item callback and optional fallback | Identity-keyed `mfor`; no index accessor or custom key options |
+| Extension `createRxEffect([() => input, ...], (sample, ...) => { ... })` | RX's one `effect (inputs) { ... }` form (EF-2) with `$i` samples |
+| Named JS calls with result annotations and declaration contracts | Ordinary `import`/`call`; shared checker decides purity/failure legality |
+| `createMemo(async (): Promise => await call(...))` | `eq` with `await`; one returned await, explicit supported result shape |
+| `Loading` and `Errored` with fallback | Existing boundaries; explicit Loading `on` and one-error-accessor Errored callback |
+
+## Setup Consts, Effects, and Structure
+
+Setup `const` (module, component, branch, owned-helper setup) is evaluated once,
+eagerly, in Solid. RX's only eager form is a snapshot source:
+
+- `const x = untrack(() => e)` lowers to `source x := peek(e)`.
+- Any other initializer lowers to `source x := e` as written, except that reads
+ of earlier setup consts and `useContext` lookups (which Solid does not track)
+ are wrapped in `peek`: `const next = first + 1` is
+ `source next := peek(first) + 1`. This keeps Solid's evaluate-once timing.
+ Nothing else is peeked implicitly.
+ Whether a name is an earlier setup const is decided by the binding it resolves
+ to in the reading scope, never by spelling: a same-named const in a sibling
+ component, child component or owned helper authorizes nothing.
+- A function value (an accessor-typed `useContext` result, a closure, an
+ accessor-typed provider seed) lowers to an equation over the snapshot
+ (`read = peek(use Count)`): a source cannot hold a function as a value.
+- Naming a store or projection (`const alias = state`) reads nothing; the alias
+ is the store binding itself, as Solid keeps the same proxy.
+- A bare tracked read (`const initial = count() * 2`, `props.x`, `state.x`, a
+ source imported from an `.rx` module) is never silently snapshotted (Solid
+ warns `STRICT_READ_UNTRACKED`): it stays outside `peek` and the shared checker
+ reports `SNAPSHOT_INITIALIZER (E-4)` at the authored line. Write
+ `untrack(() => …)` for a mount snapshot or `createMemo` for a derived value.
+ Passing such a const to `createSignal` does not hide the error. (A
+ `createSignal(count())` seed itself is still peeked, as before this change.)
+- No setter is bound, so a const is never written; assignment is refused
+ (`Assignments are only supported on the draft parameter of createStore/createProjection`).
+ Exported consts are exported snapshot sources; TSX importers read them as plain
+ values, RX importers as read-only sources (MO-2).
+- Refused, because the RX snapshot would observe them differently from Solid:
+ an unused const whose initializer could throw (`Unused setup const x: …`), a
+ const reading an async value (`Setup const x reads an async value at mount; …
+ (PENDING_ASYNC_UNTRACKED_READ)`), an initializer reading a binding initialized
+ after it, directly or through a helper (`x reads y before its initialization
+ (JavaScript's temporal dead zone)`), and server bodies capturing a const
+ (`SERVER_CAPTURE (SF-3)`: pass it as an argument).
+
+Eager initializers that could fail are refused, whether or not they are read.
+This covers setup consts and signal, store, projection and provider seeds.
+Solid throws during setup, to the boundary above the owner, before any boundary
+inside it exists. An RX `source x := e` holds the failure and raises it at its
+readers, perhaps inside an inner boundary, an untaken branch or a closure, and
+the AST cannot carry Solid's routing. The diagnostic is `Setup const x can fail
+at mount (…): …`, or `The eager initializer of x (a signal, store, projection or
+provider seed) can fail at mount (…)`. The frontend accepts only a conservative
+total subset, resolved lexically in this module:
+- literals, operators, records/arrays, closures;
+- earlier snapshots and signal/store values;
+- calls of helpers whose tails are total, and array builtins whose callbacks
+ are total;
+- JS imports declared to reject nothing;
+- equations and questions whose expressions are total;
+- props fields of a non-exported component whose every application site in
+ this module passes a total value;
+- anything wrapped in a catch-all `catchFailure(…, [Error] or undefined, …)`.
+
+Refused as possibly failing:
+- explicit throws, including through helpers and memos;
+- selective catches, since full discharge is not proved without class
+ inference;
+- JS imports that can reject, server functions, actions and callback values;
+- reads of non-snapshot imports;
+- props of exported components, whose callers in other modules are unknown.
+
+An initializer with a bare reactive read is left to the shared checker's
+`SNAPSHOT_INITIALIZER`.
+
+Pure helpers are RX `fn`s with `decls: []`, whose body is one expression, so a
+`const` local can only be substituted at its uses. Purity alone does not make
+that sound: a call, member access, record/array literal or reactive read could
+be evaluated a different number of times, in a different order, with a
+different identity, or skipped in an untaken branch. Only inert locals are
+substituted: literals, parameters, earlier locals, setup consts with inert
+initializers, and operators, conditionals and templates over them. Any other
+local is refused with `Pure helper local x cannot be lowered: an RX fn body is
+one expression, …`. Unused locals are refused (`Unused pure helper local x: …`),
+as is a substitution that an inner binding would capture. Imperative handler
+bodies keep their `let` statements.
+
+Effects: only `createRxEffect` is lowered, to RX's one `effect` form (EF-2
+per-input value comparison). Plain Solid `createEffect` is refused with
+`createEffect is not lowered: ReactiveScript has one effect form with per-input
+value comparison (EF-2). Use createRxEffect from reactivescript-tsx`.
+
+Structure is the type (ST-1): function-form `createStore` writes its value
+contract as the source's `type` text, and the emitter lowers a question source
+whose type is an object or array type as a store (as it does for path-written
+questions). Object-valued `createSignal` stays refused, so the type rule never
+turns a Solid signal into a store.
+
+`Show` tests verify that truthy-to-truthy changes preserve local snapshots and
+false-to-true remounts reset them. `For` tests verify value-item arguments and
+row snapshot preservation through identity reordering. Projection tests verify
+previous-draft accumulation. These are tested subsets, not all ownership cases.
+
+Handlers support setter/callback/JS-call statements, initialized locals, bounded
+conditionals, return, throw and try/catch/finally using existing imperative nodes.
+
+Ordinary async callbacks are refused for now, pending
+[issue #20](https://github.com/ryansolid/reactivescript/issues/20). Every authored `async` callback is rejected at its `async`
+node, whatever its body does: event handlers and contextual callback props (with
+or without writes before the `await`, without an `await`, expression bodies,
+early return or throw), a child forwarding its callback prop through an async
+arrow, stored async arrows, nested `async function` declarations, async closures
+inside synchronous handlers, and `createRxEffect` apply callbacks and returned
+cleanups. Nothing is lowered or silently converted to an action. This is refused:
+
+```tsx
+ {
+ setN(1);
+ const result: number = await request();
+ setN(result);
+ }}
+>
+ Save
+
+```
+
+The diagnostic points to an explicit generator action, which is supported:
+
+```tsx
+const save = action(function* () {
+ try {
+ const result: number = yield request();
+ setN(result);
+ } catch (error) {
+ setN(-1);
+ }
+});
+// ...
+ save()}>Save
+```
+
+Writing it this way opts into action semantics: the action's writes are held until
+it completes. It is not a semantics-preserving rewrite of the async callback,
+whose writes before the `await` Solid applies immediately. `action(async function* ...)`,
+`action(async function ...)` and `action(async () => ...)` are refused too: actions
+take a synchronous generator and use `yield` instead of `await`. Async computations
+are not callbacks and keep their own rules (`createMemo(async ...)`, async
+function-form stores, `createServerMemo` and server functions).
+
+Effects and ordinary imperative returns accept synchronous zero-argument cleanup
+closures. Authored `$` bindings are reserved globally so that effect
+samples cannot collide with locals or cleanup captures.
+Effect cleanups retain their sampled values and run before replacement;
+the shared checker remains responsible for closure lifetime and escape checks.
+Component bodies remain declarations followed by one JSX return. Shadowing,
+recursive component graphs and unsupported statement shapes are rejected rather
+than resolved by spelling or guessed execution behavior.
+
+## Semantic Limits
+
+- JS declaration parsing currently discards return shapes. JS call results need
+ an explicit supported TS annotation, such as `async (): Promise => ...`,
+ a typed helper return, or a typed const. Unknown returns are never assumed numeric.
+- JS imports emit `pure: false`; the module loader attaches `.d.rx` contracts.
+ Bare imports default to IO, usable in handlers but rejected in reactive calls
+ by the shared checker. Result annotations cannot grant purity or suppress failures.
+- Structured `createSignal` values are refused: RX sources automatically become
+ stores and would silently change JS object identity semantics. Use `createStore`.
+- Store callbacks allow straight-line path assignments and expression-bodied,
+ shape-preserving root updaters. Explicit supported `createStore` type arguments
+ supply empty-array item contracts. Known-array spreads and scalar one-item
+ predicates in `.every`/`.filter` map to existing array/builtin nodes. One-item
+ pure `.map` callbacks return supported scalar, record or array values, preserve
+ caller tracking, and work inside root updaters. No index/array callback
+ parameters or general reduce methods are inferred. Each used
+ builtin name cannot also be an authored binding anywhere in the source,
+ including later declarations, helpers and imports. Registration predicates
+ may compare an item with a known ordinary store root; arbitrary
+ structured equality remains refused. Reads of a
+ field after a write to that field are refused because native draft reads see
+ staged mutations while ordinary RX handler reads see committed values.
+ Projection draft statements use actual RX draft semantics instead.
+- Function-form `createStore((): { a: number } => ({ a: input() }))` uses a
+ structured standing-question source with no invented seed. An async initializer
+ (a computation, not an event callback) requires a single returned await and an
+ explicit `Promise` record/array contract. Synchronous initializers require a
+ supported result annotation or
+ explicit store type argument. Existing path writes remain store writes;
+ `refresh(state)` and `affects(state)` target the original standing question.
+ Static object/array stores are not invalidation targets. Imported JS purity
+ remains enforced by the shared checker, not inferred from an annotation.
+- Projection seeds are snapshotted eagerly. Synchronous draft blocks and
+ `createProjection(() => rows, [], { key: "id" })` are supported. Keyed rows
+ require a known number/string key field; tests cover replacement, reordering
+ and row snapshot identity. Async projections, arbitrary root replacement,
+ other options and writable derived stores remain unsupported. RX draft
+ binding `d` is reserved.
+- `createDeferred(() => value)` maps to a deferred equation. A single returned
+ await with a supported result annotation also retains deferred scheduling;
+ tests cover true pending async replacement, not just synchronous arithmetic.
+ Previous-value parameters and deferred options are not covered.
+- Async/stream memos accept `{ loadingValue: literal }` for matching primitive
+ literals, optionally signed, through `await.init`. A callback annotated
+ `(): AsyncIterable => importedCall(...)` maps to `await.stream`; tests cover
+ repeated yields, supersession cancellation, stale completions and disposal.
+ Equality/deferred options, nonliteral loading values, async previous-value
+ memos, arbitrary async blocks and generator stream bodies remain unsupported.
+- Previous-value memos currently need a primitive parameter or return annotation.
+ Their first previous value is undefined; no initial value is invented.
+- `createPrevious(double, () => 0)` from `reactivescript-tsx` lowers to a
+ separate equation using non-self `pre double` with a matching fallback. The
+ synchronous zero-argument expression fallback stays lazy and reactive: it is
+ evaluated only when the previous value is undefined, never for null. Without
+ a fallback the accessor has shape `T | undefined`. Inputs must be original
+ known source/memo accessors declared earlier in the same owned setup. Existing
+ previous accessors can be read by later-mounted children, but constructing a
+ companion for an inherited target cannot reconstruct history and is refused.
+ Same-setup chaining is supported. Self-cycles, arbitrary callbacks, values,
+ helper/ref targets, store/projection roots and imported getters are refused.
+- Void `on[A-Z]` callback props accept explicitly typed primitive/inline object
+ parameters; forwarding and invocation preserve arguments and validate their
+ shape and count. Inline callback implementations may infer parameters from
+ that prop contract. Intrinsic event handlers accept one explicitly typed
+ parameter, for example `(e: { target: { value: string } }) => { ... }`.
+ Destructured/default/optional event parameters and arbitrary DOM event types
+ are not supported. Shared action failure contracts remain inferred.
+ A `children: Element` prop, using Solid
+ `import { type Element } from "solid-js"` (including an alias), is a raw markup
+ slot; `JSX.Element` is also recognized. Children can be forwarded without
+ stringification, but snapshots of slots are refused. Non-handler callback props,
+ arbitrary named/generic TS types, JSX spreads and namespaced components remain
+ unsupported.
+- Typed accessor-returning setup helpers accept original `() => T` ref arguments
+ and value arguments, own signals/memos/effects, and return an original accessor.
+ Value arguments and local consts snapshot once at instantiation; ref arguments
+ stay live. Pure value-returning helpers remain ordinary per-invocation functions
+ with caller tracking. General returned closures and higher-order functions are
+ not covered.
+- Primitive `createSignal(() => value)` derivations use question sources.
+ Manual writes remain visible until a dependency changes; function-valued
+ payloads are not silently treated as ordinary source values.
+- Module `createContext(literal)`, component `useContext(C)`, and callable
+ `` support primitive snapshots and read-only accessor payloads.
+ Primitive defaults and literal-bodied default accessors are supported; typed
+ default-less contexts require a provider, matching native Solid. Each provider
+ snapshots its payload in an owned component: a primitive stays fixed while a
+ captured accessor stays live. Aliases preserve context identity across modules.
+ Arbitrary structured payloads/default evaluation and pure-helper context reads
+ are not covered.
+- `createSourceContext()` and `useSourceContext(C)` from the real
+ `reactivescript-tsx` helper module use required source contexts. Provider
+ `value={[read, set]}` must name the same compatible authoritative source; mixed
+ tuples and missing providers are rejected. Native, compiled and interpreter
+ tests cover children updating the original source across module aliases.
+ Structured contracts use actual store/value pairs, not object signals: the
+ reader is a record/array value with draft-path and root-updater writes. The
+ record-array regression verifies child writes reach the original store.
+ Forwarding a consumed context tuple as another source provider, or invalidating
+ a context reader, is refused until writable capability targeting is established.
+- Fire-and-forget `refresh(target)` and one-target `affects(target)` statements
+ accept original accessor/store bindings. Awaited refresh results and
+ property-slot marks are not covered by this slice.
+ Server-factory cache refresh requires `createServerMemo` and `refresh` from
+ `reactivescript-tsx`. `createServerMemo` accepts the same bounded async/stream
+ callbacks and loading-value option as `createMemo`. Ordinary Solid refresh of
+ a known factory-backed memo is rejected: it does not evict native loader cache
+ entries. Extension refresh of an ordinary factory-backed memo or imported async
+ getter is also refused when getter-owned cache metadata is unavailable.
+ A directly returned server call, including `createServerMemo(() => load())`,
+ becomes an explicit AST await. Supported explicit `Promise` annotations can
+ describe raw JS promise results. Known local server result types are checked,
+ not replaced by the callback annotation; raw Promises cannot enter arithmetic.
+- `action(function* (typedArgs) { ... })` maps to `Decl.action`; parameterized
+ handlers require explicit arguments through a zero-argument forwarding arrow.
+ Non-delegating yields of imported JS/server calls suspend actions; consumed JS
+ results require an explicit TS local annotation. Tests cover held writes,
+ affects pending state, resolution and caught rejection.
+ Immediately invoked `action(function* () { ... })()` uses the existing inline
+ action statement, not a discarded generator or an ordinary async handler.
+ `createOptimistic` setters use optimistic writes separately from the authoritative
+ source writer.
+ Primitive, record and array optimistic roots use question sources, not automatic
+ stores. The array-clear/suspended-action regression proves held status writes,
+ rollback and unchanged captured-array identity in all three execution paths.
+ Optimistic path writes, `createOptimisticStore`, and roots containing projection
+ proxies remain unsupported; ordinary structured `createSignal` is still refused.
+ Async arrows/generators, delegated yields and consumed action return values
+ remain unsupported.
+- Real `reactivescript-tsx` helpers `defineError`, `raiseFailure`,
+ `catchFailure`, and `FailureBoundary` lower to error declarations, throw/catch
+ expressions and class-filtered boundaries. Fields/message parameters are
+ explicit, classes are declared constructor arrays, built-in `Error`, or undefined, and boundary
+ errors are real accessors. General error-class imports and reset callbacks
+ remain unsupported. Recovery regressions are tested, not assumed.
+ Failure constructor declarations and imported aliases named `Error` or `Panic`
+ are rejected because those are reserved RX failure identities.
+ In these extension helpers, `[Error]` intentionally means the RX top failure
+ class, including primitive throws, and lowers like `catch (err: Error)`.
+ Common `name`/`message` fields are available without assuming arbitrary thrown
+ JS values are Errors or that those properties must be strings.
+ An imperative catch containing only `if (err instanceof C.cls) { ... } else
+{ throw err }`, or a union of those exact guards, becomes a selective catch
+ with canonical classes and common payload fields. The else must rethrow the
+ same binder. Raw `instanceof Error` guards and general instanceof narrowing
+ are refused: native JS Error filtering is not RX catch-all semantics.
+ User-defined constructor bindings may not use reserved `Error`/`Panic` identities.
+- `serverFunction("name", function* (typedArgs): Generator
+{ ... })` and `serverAction` use shared server declarations, yield suspension,
+ capture/purity checks and the existing in-process wire runtime. Literal names
+ must match bindings. These helpers do not imply an HTTP transport or general
+ async-function translation.
+- Module reactive declarations and component setup use the same lowering path.
+ Exported signals expose reader and direct-value setter aliases; exported
+ memos/constants carry supported type contracts. Imported setter updater
+ functions and arbitrary action-result contracts are not covered.
+- Direct aliases of projection-root proxies are lexical aliases, not eager
+ object-value snapshots. Tests distinguish these live roots from captured
+ nested objects that survive replacement without following the new object.
+ Structured snapshots containing projection-root proxies, such as
+ `{ root: projected }`, are refused after a native/compiled/interpreter
+ differential reproduced stale interpreter values. Ordinary store aliases
+ and captured nested objects remain supported.
+- Loose JS equality, exponentiation and other operators without verified
+ cross-path semantics are rejected. Possibly absent member reads require
+ explicit optional access; this is not general JS null-dereference parity.
+- Numeric JSX uses `"" + value`, not RX JSON formatting, preserving NaN, infinities
+ and signed-zero display. Overflowed numeric literals remain rejected.
+- Only authored JSX text and quoted JSX attributes are entity-decoded. JS string
+ expressions such as `{'&'}` remain literal. Whitespace normalization runs
+ before decoding so numeric newline entities are preserved.
+
+Unsupported forms throw `TsxError` with authored filename, 1-based line/column,
+and UTF-16 start/end offsets. Parsing and lowering do not imply successful
+checking or full DOM/SSR/hydration parity for every accepted HTML content model.
+
+## Verification
+
+From the package root (`frontend/`; see ../README.md for the pinned and canary workflows):
+
+```sh
+npm run check
+npm test
+npm run test:browser
+```
+
+The paired counter, composition, values, store and effect fixtures compare the
+pinned checker's canonical semantic facts (tsx/rx/facts.ts) against handwritten
+RX, run the shared checker, drive actual interpreter button
+handlers, compile both ASTs, and compare multiple-click frames with native TSX
+compiled by the pinned Solid compiler. Eager nodes currently have no RX surface
+spelling, so snapshot fixtures replace designated parsed equations with explicit
+`Decl.const` nodes before comparison.
+
+Additional tests cover modules/builds, snapshots, helper recalculation, JSX text,
+async JS declarations and boundaries, and browser target integration. Native
+artifacts use `.mts` because Solid preserves inline TS types. Each test removes
+only its own unique `frontend/.out/*` artifacts. `test-renderer.ts` applies
+static element props through the existing renderer equally for all compared paths.
diff --git a/tsx-frontend/tsx/actions.test.ts b/tsx-frontend/tsx/actions.test.ts
new file mode 100644
index 0000000..ddcef28
--- /dev/null
+++ b/tsx-frontend/tsx/actions.test.ts
@@ -0,0 +1,114 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { loadTsxModules } from "./modules.ts";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+import { checkModules } from "./rx/compiler.ts";
+
+const source = `import { createSignal, createOptimistic, action } from "solid-js";
+import { request } from "./api";
+export function Counter() {
+ const [truth, setTruth] = createSignal(0);
+ const [count, setCount] = createOptimistic(() => truth());
+ const [status, setStatus] = createSignal("");
+ const [orders, setOrders] = createSignal(0);
+ const save = action(function*() {
+ setCount(1);
+ setStatus("placing");
+ try {
+ const receipt: number = yield request();
+ setTruth(receipt);
+ setStatus("ok");
+ } catch (e) {
+ setStatus("failed");
+ } finally {
+ setOrders(p => p + 1);
+ }
+ });
+ return save()}>{count()}/{status()}/{orders()} ;
+}`;
+const inputs: Record = {
+ "main.tsx": source,
+ "api.d.rx": "export fn request(): number",
+};
+const expected = ["0//0", "1//0", "0/failed/1", "1/failed/1", "42/ok/2"];
+
+for (const name of ["native.mts", "main.mjs"]) {
+ test((name === "native.mts" ? "native Solid" : "lowered TSX") +
+ " generator actions hold status writes, roll back optimistic failure, and commit success", async t => {
+ const code = name === "native.mts"
+ ? transform(source, { filename: "Counter.tsx", generate: "universal", moduleName: STRING_RENDERER }).code
+ .replaceAll('"./api"', '"./api.mjs"')
+ : buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" }).files.get(name)!;
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-actions-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ const flights: { resolve: (value: number) => void; reject: (error: Error) => void }[] = [];
+ const request = () => {
+ let resolve!: (value: number) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
+ flights.push({ resolve, reject });
+ return promise;
+ };
+ (globalThis as any).__tsxActionRequest = request;
+ try {
+ writeFileSync(dir + "/api.mjs", "export const request = () => globalThis.__tsxActionRequest();");
+ writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(dir + "/" + name).href);
+ const app = createApp({ api: { request } });
+ const dispose = app.mount(name === "native.mts" ? () => mod.Counter() : mod.default(app));
+ const settle = async () => {
+ for (let i = 0; i < 3; i++) {
+ await new Promise(done => setImmediate(done));
+ flush();
+ }
+ };
+ try {
+ await settle();
+ const frames = [app.frame()];
+ assert.equal(flights.length, 0, "request must only run when the action is invoked");
+ const button = (node: any): any => node.tag === "button" ? node : node.children?.map(button).find(Boolean);
+
+ const failed = button(app.root).props.onClick();
+ await settle(); frames.push(app.frame());
+ assert.equal(flights.length, 1);
+ flights[0].reject(new Error("offline"));
+ await failed; await settle(); frames.push(app.frame());
+
+ const saved = button(app.root).props.onClick();
+ await settle(); frames.push(app.frame());
+ assert.equal(flights.length, 2);
+ flights[1].resolve(42);
+ await saved; await settle(); frames.push(app.frame());
+
+ t.diagnostic(name + " frames: " + JSON.stringify(frames));
+ assert.deepEqual(frames, expected, name);
+ } finally { dispose(); }
+ } finally {
+ delete (globalThis as any).__tsxActionRequest;
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+}
+
+for (const handled of [true, false]) {
+ test(handled ? "handled generator action failures pass the core checker"
+ : "unhandled generator action failures require core UNCAUGHT_ACTION_FAILURE", () => {
+ const authored = handled ? source : source.replace(' catch (e) {\n setStatus("failed");\n }', "");
+ if (!handled) assert.notEqual(authored, source);
+ const files: Record = { ...inputs, "main.tsx": authored };
+ const set = loadTsxModules("main.tsx", path => files[path]);
+ assert.deepEqual(set.diags, [], "frontend must accept authored generator action syntax");
+ const diagnostics = checkModules(set).diags;
+ if (handled) assert.deepEqual(diagnostics, []);
+ else assert.deepEqual(diagnostics.map(({ code, rule, file }) => ({ code, rule, file })), [
+ { code: "UNCAUGHT_ACTION_FAILURE", rule: "R-f-6", file: "main.tsx" },
+ ]);
+ });
+}
diff --git a/tsx-frontend/tsx/async-bridge.test.ts b/tsx-frontend/tsx/async-bridge.test.ts
new file mode 100644
index 0000000..1747677
--- /dev/null
+++ b/tsx-frontend/tsx/async-bridge.test.ts
@@ -0,0 +1,57 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+
+const baseSource = 'import { createSignal, createMemo, Loading, Errored, isPending, latest } from "solid-js"; import { fetchValue } from "./api"; export function Counter() { const [n,setN] = createSignal(0); const result = createMemo(async (): Promise => await fetchValue(n())); return failed
}>waiting
}> setN(n()+1)}>{result()} {isPending(() => result()) ? "pending" : "idle"} {latest(() => n())}
; }';
+
+for (const explicitOn of [false, true]) test("async equations, JS contracts, failures and probes match native Solid" + (explicitOn ? " with Loading on" : ""), async () => {
+ const source = explicitOn ? baseSource.replace(" = { "main.tsx": source, "api.d.rx": "export pure fn fetchValue(n: number): number" };
+ const native = transform(source, { filename: "Counter.tsx", generate: "universal", moduleName: STRING_RENDERER }).code.replaceAll('"./api"', '"./api.mjs"');
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-async-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ try {
+ writeFileSync(dir + "/api.mjs", "export const fetchValue = value => globalThis.__tsxLoad(value);");
+ const traces: string[][] = [];
+ for (const name of ["native.mts", "main.mjs"]) {
+ const code = name === "native.mts" ? native
+ : buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" }).files.get(name)!;
+ const flights = new Map; resolve: (value: number) => void; reject: (error: Error) => void }>();
+ const load = (key: number) => {
+ let flight = flights.get(key);
+ if (!flight) {
+ let resolve!: (value: number) => void; let reject!: (error: Error) => void;
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
+ flight = { promise, resolve, reject }; flights.set(key, flight);
+ }
+ return flight.promise;
+ };
+ (globalThis as any).__tsxLoad = load;
+ writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const module = await import(pathToFileURL(dir + "/" + name).href);
+ const app = createApp({ api: { fetchValue: load } });
+ const dispose = app.mount(name === "native.mts" ? () => module.Counter() : module.default(app));
+ const settle = async () => { for (let i = 0; i < 3; i++) { await new Promise(resolve => setImmediate(resolve)); flush(); } };
+ try {
+ await settle(); const frames = [app.frame()];
+ assert.ok(flights.has(0), name + " must start the initial question");
+ flights.get(0)!.resolve(0); await settle(); frames.push(app.frame());
+ const button = (node: any): any => node.tag === "button" ? node : node.children?.map(button).find(Boolean);
+ button(app.root).props.onClick(); await settle(); frames.push(app.frame());
+ assert.ok(flights.has(1)); flights.get(1)!.resolve(10); await settle(); frames.push(app.frame());
+ button(app.root).props.onClick(); await settle(); frames.push(app.frame());
+ assert.ok(flights.has(2)); flights.get(2)!.reject(new Error("offline")); await settle(); frames.push(app.frame());
+ assert.deepEqual(frames, explicitOn ? ["waiting", "0idle0", "waiting", "10idle1", "waiting", "failed"] : ["waiting", "0idle0", "0pending1", "10idle1", "10pending2", "failed"], name);
+ traces.push(frames);
+ } finally { dispose(); delete (globalThis as any).__tsxLoad; }
+ }
+ assert.deepEqual(traces[0], traces[1]);
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+});
diff --git a/tsx-frontend/tsx/async-control.test.ts b/tsx-frontend/tsx/async-control.test.ts
new file mode 100644
index 0000000..a624fe1
--- /dev/null
+++ b/tsx-frontend/tsx/async-control.test.ts
@@ -0,0 +1,130 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+import { Runtime } from "./rx/runtime.ts";
+
+type Step = { click: number } | { land: string; value: unknown };
+const imports = 'import { createSignal, createMemo, createDeferred, isPending, Loading, Errored, action, refresh, affects } from "solid-js"; import { fetch } from "./api";';
+const settle = async () => { for (let i = 0; i < 4; i++) { await new Promise(resolve => setImmediate(resolve)); flush(); } };
+const buttons = (node: any): any[] => node.tag === "button" ? [node] : (node.children ?? []).flatMap(buttons);
+
+function interpretedButtons(items: any[]): any[] {
+ return items.flatMap(item => {
+ if (item.k === "el" && item.tag === "button") return [item];
+ const nested = item.children ?? item.inst?.items ?? item.committed?.inst?.items
+ ?? item.committedRows?.flatMap((row: any) => row.inst.items)
+ ?? ((item.k === "loading" || item.k === "errored") ? (item.committedFallback ? item.fallback : item.content)?.items : []);
+ return interpretedButtons(nested ?? []);
+ });
+}
+
+async function differential(
+ nativeSources: [string, string][], compiledSources: [string, string][], declarations: string, steps: Step[], expected: string[], calls: Record,
+) {
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "async-control-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ const traces: string[][] = [];
+ try {
+ // The direct native form establishes the contract before the alternate spelling or checker is consulted.
+ for (const { name, source, native } of [
+ ...nativeSources.map(([name, source]) => ({ name, source, native: true })),
+ ...compiledSources.map(([name, source]) => ({ name, source, native: false })),
+ ]) {
+ const output = dir + "/" + name; mkdirSync(output);
+ writeFileSync(output + "/api.mjs", "export const fetch = key => globalThis.__asyncControlFetch(key);");
+ const requests = new Map void; landed: boolean }[]>();
+ const fetch = (key: string) => new Promise(resolve => {
+ let entries = requests.get(key); if (!entries) requests.set(key, (entries = []));
+ entries.push({ resolve, landed: false });
+ });
+ (globalThis as any).__asyncControlFetch = fetch;
+ let entry: string;
+ if (native) {
+ entry = name + ".mts";
+ const code = transform(source, { filename: "main.tsx", generate: "universal", moduleName: STRING_RENDERER }).code.replaceAll('"./api"', '"./api.mjs"');
+ writeFileSync(output + "/" + entry, code.replaceAll(STRING_RENDERER, renderer));
+ } else {
+ const inputs: Record = { "main.tsx": source, "api.d.rx": declarations };
+ const built = buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" });
+ entry = built.entry;
+ for (const [path, code] of built.files) writeFileSync(output + "/" + path, code.replaceAll(STRING_RENDERER, renderer));
+ }
+ const module = await import(pathToFileURL(output + "/" + entry).href);
+ const app = createApp({ api: { fetch } });
+ const dispose = app.mount(native ? () => module.Counter() : module.default(app));
+ try {
+ await settle();
+ const frames = [app.frame()];
+ for (const step of steps) {
+ if ("click" in step) {
+ const handler = buttons(app.root)[step.click]?.props.onClick;
+ assert.equal(typeof handler, "function", name + " must use the authored handler"); handler();
+ } else {
+ const request = requests.get(step.land)?.find(entry => !entry.landed);
+ assert.ok(request, name + " must have an unsettled fetch(" + JSON.stringify(step.land) + ")");
+ request.landed = true; request.resolve(step.value);
+ }
+ await settle(); frames.push(app.frame());
+ assert.equal(frames.at(-1), expected[frames.length - 1], name + " instant " + (frames.length - 1));
+ }
+ assert.deepEqual(frames, expected, name);
+ assert.deepEqual(Object.fromEntries([...requests].map(([key, entries]) => [key, entries.length])), calls, name + " actual fetch calls");
+ traces.push(frames);
+ } finally { dispose(); }
+ }
+ for (const [name, source] of compiledSources) {
+ const inputs: Record = { "main.tsx": source, "api.d.rx": declarations };
+ const built = buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" });
+ const runtime = new Runtime(built.set);
+ const frames = [runtime.step([]).at(-1)!.frame];
+ for (const step of steps) {
+ if ("click" in step) {
+ const node = interpretedButtons(runtime.view.items)[step.click]?.attrs.find((attr: any) => attr.name === "onClick")?.node;
+ assert.ok(node, "interpreter must expose the authored button handler");
+ const handler = node.compute(runtime); assert.equal(typeof handler, "function"); handler();
+ frames.push(runtime.handler([]).at(-1)!.frame);
+ } else frames.push(runtime.land(JSON.stringify(step.land), step.value).at(-1)!.frame);
+ assert.equal(frames.at(-1), expected[frames.length - 1], "interpreter " + name + " instant " + (frames.length - 1));
+ }
+ assert.deepEqual(frames, expected, "interpreter " + name);
+ for (const trace of traces) assert.deepEqual(frames, trace, "all backends must match the full native trace");
+ }
+ } finally { delete (globalThis as any).__asyncControlFetch; rmSync(dir, { recursive: true, force: true }); }
+}
+
+const deferredView = 'return failed
}>}> setPeriod(period() + 1)}>Period {period()} fast {fast()} {isPending(() => fast()) ? "true" : "false"} slow {slow()} {isPending(() => slow()) ? "true" : "false"} ; }';
+const deferredStart = imports + "export function Counter() { const [period, setPeriod] = createSignal(1); ";
+const deferredDirect = deferredStart + 'const fast = createDeferred(async (): Promise => await fetch("f:" + period())); const slow = createDeferred(async (): Promise => await fetch("s:" + period())); ' + deferredView;
+const deferredComposed = deferredStart + 'const fastQuestion = createMemo(async (): Promise => await fetch("f:" + period())); const slowQuestion = createMemo(async (): Promise => await fetch("s:" + period())); const fast = createDeferred(() => fastQuestion()); const slow = createDeferred(() => slowQuestion()); ' + deferredView;
+
+test("S-solid-41 async deferred questions block initially, then land independently with live pending probes", async () => {
+ await differential([["native-direct", deferredDirect], ["native-composed", deferredComposed]], [["compiled-direct", deferredDirect], ["compiled-composed", deferredComposed]], "export pure fn fetch(key: string): number", [
+ { land: "f:1", value: 1 }, { land: "s:1", value: 1 }, { click: 0 },
+ { land: "f:2", value: 2 }, { land: "s:2", value: 2 }, { click: 0 },
+ { land: "s:3", value: 30 }, { land: "f:3", value: 3 },
+ ], [
+ "", "", "Period 1 fast 1 false slow 1 false", "Period 2 fast 1 true slow 1 true",
+ "Period 2 fast 2 false slow 1 true", "Period 2 fast 2 false slow 2 false", "Period 3 fast 2 true slow 2 true",
+ "Period 3 fast 2 true slow 30 false", "Period 3 fast 3 false slow 30 false",
+ ], { "f:1": 1, "s:1": 1, "f:2": 1, "s:2": 1, "f:3": 1, "s:3": 1 });
+});
+
+const reloadBody = 'try { affects(user); refresh(user); yield fetch("gate"); } catch (error) {}';
+const refreshSource = imports + 'export function Counter() { const [id, setId] = createSignal(1); const user = createMemo(async (): Promise<{ name: string }> => await fetch("u:" + id())); const reload = action(function*() { ' + reloadBody + ' }); return failed
}>loading}>{user().name} {isPending(() => user()) ? "updating" : ""}
setId(id() + 1)}> refresh(user)}> reload()}> ; }';
+const refreshInline = refreshSource.replace("const reload = action(function*() { " + reloadBody + " }); ", "").replace("onClick={() => reload()}", "onClick={() => action(function*() { " + reloadBody + " })()}");
+
+test("S-solid-26 quiet refresh stays quiet; affects holds an early reask landing until the gate commits", async () => {
+ await differential([["native-named-action", refreshSource], ["native-inline-action", refreshInline]], [["compiled-named-action", refreshSource], ["compiled-inline-action", refreshInline]], "export pure fn fetch(key: string): { name: string }", [
+ { land: "u:1", value: { name: "Ann" } }, { click: 0 }, { land: "u:2", value: { name: "Bob" } },
+ { click: 1 }, { land: "u:2", value: { name: "Bob2" } }, { click: 2 },
+ { land: "u:2", value: { name: "Bob3" } }, { land: "gate", value: 0 },
+ ], ["loading", "Ann ", "Ann updating", "Bob ", "Bob ", "Bob2 ", "Bob2 updating", "Bob2 updating", "Bob3 "],
+ { "u:1": 1, "u:2": 3, gate: 1 });
+});
diff --git a/tsx-frontend/tsx/async-handlers.test.ts b/tsx-frontend/tsx/async-handlers.test.ts
new file mode 100644
index 0000000..6ff8c18
--- /dev/null
+++ b/tsx-frontend/tsx/async-handlers.test.ts
@@ -0,0 +1,412 @@
+import { test, type TestContext } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { parseProgram } from "./rx/compiler.ts";
+import { check } from "./rx/compiler.ts";
+import { Runtime } from "./rx/runtime.ts";
+import { compile } from "./rx/compiler.ts";
+import { lowerViews, STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+
+type Step = {
+ before: string;
+ after: string;
+ result?: unknown;
+ failure?: string;
+ held?: boolean;
+ args?: unknown[];
+ microtasks?: string[];
+ land?: { value?: unknown; failure?: string };
+};
+type Driver = {
+ invoke: (...args: unknown[]) => unknown;
+ frame: () => string;
+ land: (answer: NonNullable) => void;
+ dispose: () => void;
+ held?: () => boolean;
+};
+const settle = async () => {
+ for (let i = 0; i < 3; i++) {
+ await new Promise(done => setImmediate(done));
+ flush();
+ }
+};
+
+async function compare(t: TestContext, source: string, rx: string, initial: string, steps: Step[],
+ options: { async?: boolean; authored?: boolean; refused?: { rx: RegExp; tsx: RegExp }; tsxRefused?: RegExp } = {}) {
+ const isAsync = options.async ?? true;
+ const out = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(out, { recursive: true });
+ const dir = mkdtempSync(out + "tsx-async-handlers-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ const exercise = async (driver: Driver) => {
+ try {
+ assert.equal(driver.frame(), initial);
+ for (const step of steps) {
+ const returned = driver.invoke(...(step.args ?? []));
+ assert.equal(returned instanceof Promise, isAsync, "actual callback return must preserve async versus sync");
+ const outcome = Promise.resolve(returned).then(value => ({ value }), error => ({ failure: error.message }));
+ assert.equal(driver.frame(), step.before, "before-await writes must form their own frame");
+ if (isAsync && driver.held) assert.equal(driver.held(), false, "ordinary callbacks must not hold their writes");
+ if (step.held !== undefined && driver.held) assert.equal(driver.held(), step.held);
+ if (step.land) {
+ await settle();
+ assert.equal(driver.frame(), step.before, "pending callbacks must leave before-await writes visible");
+ assert.doesNotThrow(() => driver.land(step.land!));
+ if (isAsync) assert.equal(driver.frame(), step.before, "a flight answer must queue, not synchronously run, its continuation");
+ }
+ for (const frame of step.microtasks ?? []) {
+ await Promise.resolve();
+ assert.equal(driver.frame(), frame);
+ }
+ assert.deepEqual(await outcome, step.failure ? { failure: step.failure } : { value: step.result });
+ await settle();
+ assert.equal(driver.frame(), step.after, "callback completion must include its finalizers");
+ if (step.held && driver.held) assert.equal(driver.held(), false, "completion must release the action's hold");
+ }
+ } finally { driver.dispose(); }
+ };
+ const interpreted = (program: ConstructorParameters[0]): Driver => {
+ const rt = new Runtime(program);
+ rt.step([]);
+ const button = (items: any[]): any => {
+ for (const item of items) {
+ if (item.k === "el" && item.tag === "button") return item;
+ const found = button(item.children ?? item.inst?.items ?? []);
+ if (found) return found;
+ }
+ };
+ return {
+ invoke: (...args) => {
+ const node = button(rt.view.items)?.attrs.find((attr: any) => attr.name === "onClick")?.node;
+ assert.ok(node, "must invoke the actual interpreter event handler");
+ return node.compute(rt)(...args);
+ },
+ frame: () => rt.handler([]).at(-1)!.frame,
+ land: answer => { rt.land('"request:value"', answer.failure ? new Error(answer.failure) : answer.value, !!answer.failure); },
+ dispose: () => rt.root.dispose(),
+ held: () => rt.holdOpen,
+ };
+ };
+ const rendered = async (name: string, code: string, native = false): Promise => {
+ writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(dir + "/" + name).href);
+ const app = createApp();
+ (globalThis as any).__tsxAsyncHandlerRequest = (...args: unknown[]) => (app.api("request")(...args) as any).first;
+ const dispose = app.mount(native ? () => mod.Counter() : mod.default(app));
+ const button = (node: any): any => node.tag === "button" ? node : node.children?.map(button).find(Boolean);
+ await settle();
+ return {
+ invoke: (...args) => {
+ const callback = button(app.root)?.props.onClick;
+ assert.equal(typeof callback, "function", "must invoke the actual rendered event handler");
+ return callback(...args);
+ },
+ frame: () => { flush(); return app.frame(); },
+ land: answer => {
+ const flight = app.flights.get("request:value");
+ assert.ok(flight, "the callback must have asked the authored request");
+ if (answer.failure) flight.reject(new Error(answer.failure)); else flight.resolve(answer.value);
+ },
+ dispose,
+ };
+ };
+ try {
+ let nativeProven = false;
+ await t.test("native Solid", async () => {
+ const native = transform(source, { filename: "Counter.tsx", generate: "universal", moduleName: STRING_RENDERER }).code
+ .replaceAll('"./api"', '"data:text/javascript,export const request=(...args)=>globalThis.__tsxAsyncHandlerRequest(...args)"');
+ await exercise(await rendered("native.mts", native, true));
+ nativeProven = true;
+ });
+ assert.equal(nativeProven, true, "prove native expectations before compiling RX or TSX");
+ // Every backend leg is registered whatever the pinned checker answers, so coverage stays accountable. With
+ // `refused`, the pinned upstream cannot express the native behavior proven above (AC-1b/AC-1c: a plain callback
+ // that writes and awaits is an inferred action whose pre-await writes are held): each leg then proves that exact
+ // refusal (RX: the shared checker's diagnostics; TSX: the frontend's refusal at the authored callback).
+ // `tsxRefused` refuses only the authored TSX (the temporary async-callback boundary, issue #20): the handwritten
+ // RX legs still execute against the native behavior, so their results stay visible.
+ const refusedRx = () => assert.match(check(parseProgram(rx)).map(d => d.code).join(" "), options.refused!.rx,
+ "the pinned checker refuses the handwritten RX spelling of this behavior");
+ const checkedRx = () => { const program = parseProgram(rx); assert.deepEqual(check(program), []); return program; };
+ await t.test("compiled handwritten RX", async () => {
+ if (options.refused) return refusedRx();
+ const code = lowerViews(new Map([["handwritten.mjs", compile(checkedRx())]]), "universal").get("handwritten.mjs")!;
+ await exercise(await rendered("handwritten.mjs", code));
+ });
+ await t.test("interpreted handwritten RX", () => options.refused ? refusedRx() : exercise(interpreted(checkedRx())));
+ if (options.authored !== false) {
+ const build = () => buildTsx("main.tsx", path => path === "main.tsx" ? source
+ : path === "api.d.rx" ? "export fn request(key: string): number" : undefined, { mount: "Counter" });
+ const tsxRefusal = options.refused?.tsx ?? options.tsxRefused;
+ const refusedTsx = () => assert.throws(build, tsxRefusal!, "the frontend refuses the authored TSX");
+ await t.test("compiled authored TSX", async () => {
+ if (tsxRefusal) return refusedTsx();
+ const built = build();
+ await exercise(await rendered(built.entry, built.files.get(built.entry)!));
+ });
+ await t.test("interpreted authored TSX", () => tsxRefusal ? refusedTsx() : exercise(interpreted(build().set)));
+ }
+ } finally {
+ delete (globalThis as any).__tsxAsyncHandlerRequest;
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+/** The temporary TSX boundary (https://github.com/ryansolid/reactivescript/issues/20): every authored ordinary async
+ * callback is refused at its async node with explicit generator-action guidance, whatever its body does. */
+const ASYNC_CALLBACK = /Ordinary async callbacks are refused for now \(https:\/\/github\.com\/ryansolid\/reactivescript\/issues\/20\): wrap the work in action\(function\* \(\) \{ \.\.\. yield request\(\); \.\.\. \}\) and use yield instead of await/;
+/** Unsupported at the pin (README: inferred actions): Solid applies a plain async callback's pre-await writes at once;
+ * upstream AC-1b infers the RX spelling as an action and refuses the held write (AC-1c); the frontend refuses the
+ * TSX as an ordinary async callback (ASYNC_CALLBACK). */
+const PRE_AWAIT_WRITE = { rx: /HELD_WRITE/, tsx: ASYNC_CALLBACK };
+const source = `import { createSignal } from "solid-js";
+import { request } from "./api";
+export function Counter() {
+ const [n, setN] = createSignal(0);
+ const [snapshot, setSnapshot] = createSignal(-1);
+ const [finalized, setFinalized] = createSignal(0);
+ return {
+ const before = n();
+ setN(1);
+ setSnapshot(before);
+ try {
+ const result: number = await request("value");
+ setN(result);
+ return result + before;
+ } finally {
+ setFinalized(value => value + 1);
+ }
+ }}>{n()}/{snapshot()}/{finalized()} ;
+}`;
+const rx = `import { request } from "./api";
+source n = 0; source snapshot = -1; source finalized = 0;export default component App() {
+ {
+ let before = n; n := 1; snapshot := before;
+ try {
+ let result = await request("value");
+ n := result;
+ return result + before;
+ } finally { finalized := finalized + 1; }
+}}>{n}/{snapshot}/{finalized} }`;
+
+test("non-held async callbacks expose fresh and cached before-await frames and return values", t =>
+ compare(t, source, rx, "0/-1/0", [
+ { before: "1/0/0", after: "42/0/1", result: 42, land: { value: 42 } },
+ { before: "1/42/1", after: "42/42/2", result: 84 },
+ ], { refused: PRE_AWAIT_WRITE }));
+
+test("fresh and cached callback rejections reject their promises after finally without throwing from land", t =>
+ compare(t, source, rx, "0/-1/0", [
+ { before: "1/0/0", after: "1/0/1", failure: "offline", land: { failure: "offline" } },
+ { before: "1/1/1", after: "1/1/2", failure: "offline" },
+ ], { refused: PRE_AWAIT_WRITE }));
+
+for (const failed of [false, true]) {
+ test("async callback " + (failed ? "catch recovery" : "return") + " survives an awaited finalizer", t =>
+ compare(t, `import { createSignal } from "solid-js";
+import { request } from "./api";
+export function Counter() {
+ const [n, setN] = createSignal(0);
+ const [finalized, setFinalized] = createSignal(0);
+ return {
+ setN(1);
+ try {
+ const result: number = await request("value");
+ setN(result);
+ return result;
+ } catch (error) {
+ setN(-1);
+ return 7;
+ } finally {
+ await 0;
+ setFinalized(value => value + 1);
+ }
+ }}>{n()}/{finalized()} ;
+}`, `import { request } from "./api";
+source n = 0; source finalized = 0;
+export default component App() { {
+ n := 1;
+ try { let result = await request("value"); n := result; return result; }
+ catch (error) { n := -1; return 7; }
+ finally { await 0; finalized := finalized + 1; }
+}}>{n}/{finalized} }`, "0/0", [
+ { before: "1/0", after: (failed ? "-1" : "42") + "/1", result: failed ? 7 : 42,
+ land: failed ? { failure: "offline" } : { value: 42 } },
+ { before: "1/1", after: (failed ? "-1" : "42") + "/2", result: failed ? 7 : 42 },
+ ], { refused: PRE_AWAIT_WRITE }));
+}
+
+test("plain-value awaits suspend and an awaited finalizer can override the return", t =>
+ compare(t, `import { createSignal } from "solid-js";
+export function Counter() {
+ const [stage, setStage] = createSignal("idle");
+ return {
+ setStage("before");
+ try {
+ const result: number = await 8;
+ setStage("body");
+ return result;
+ } finally {
+ const done: number = await 9;
+ setStage("finally");
+ return done;
+ }
+ }}>{stage()} ;
+}`, `source stage = "idle";
+export default component App() { {
+ stage := "before";
+ try { let result = await 8; stage := "body"; return result; }
+ finally { let done = await 9; stage := "finally"; return done; }
+}}>{stage} }`, "idle", [
+ { before: "before", microtasks: ["body"], after: "finally", result: 9 },
+ ], { refused: PRE_AWAIT_WRITE }));
+
+test("lexically async callbacks return promises even when returning or throwing before the await", t =>
+ compare(t, `import { defineError } from "reactivescript-tsx";
+const Early = defineError("Early", [], () => "early");
+export function Counter() {
+ return {
+ if (mode === 0) return 7;
+ if (mode === 1) throw Early();
+ const answer: number = await 8;
+ return answer;
+ }}>ready ;
+}`, `error Early() => "early";
+export default component App() { {
+ if (mode == 0) return 7;
+ if (mode == 1) throw Early();
+ let answer = await 8; return answer;
+}}>ready }`, "ready", [
+ { args: [0], before: "ready", after: "ready", result: 7 },
+ { args: [1], before: "ready", after: "ready", failure: "early" },
+ { args: [2], before: "ready", after: "ready", result: 8 },
+ ], { tsxRefused: ASYNC_CALLBACK }));
+
+test("handwritten RX callbacks assimilate native promise arguments and returned promises", t =>
+ compare(t, `import { createSignal } from "solid-js";
+export function Counter() {
+ const [n, setN] = createSignal(0);
+ return ) => {
+ setN(1);
+ const result: number = await answer;
+ setN(result);
+ return answer;
+ }}>{n()} ;
+}`, `source n = 0;
+export default component App() { { n := 1; let result = await answer; n := result; return answer; }}>{n} }`,
+ "0", [{ args: [Promise.resolve(8)], before: "1", after: "8", result: 8 }], { authored: false, refused: PRE_AWAIT_WRITE }));
+
+test("awaits in nested RX closures do not turn their outer callback async", t =>
+ compare(t, `import { request } from "./api";
+export function Counter() {
+ return {
+ const later = async () => { const result: number = await request("value"); return result; };
+ return 7;
+ }}>ready ;
+}`, `import { request } from "./api";
+export default component App() { { let later = () => { let result = await request("value"); return result; }; return 7; }}>ready }`,
+ "ready", [{ before: "ready", after: "ready", result: 7 }], { async: false, authored: false }));
+
+test("an RX callback launching an inline held action stays synchronous", t =>
+ compare(t, `import { createSignal, action } from "solid-js";
+import { request } from "./api";
+export function Counter() {
+ const [n, setN] = createSignal(0);
+ return {
+ action(function*() {
+ try { const result: number = yield request("value"); setN(result); }
+ catch (error) { setN(-1); }
+ })();
+ }}>{n()} ;
+}`, `import { request } from "./api"; source n = 0;
+export default component App() { { action {
+ try { let result = await request("value"); n := result; } catch (error) { n := -1; }
+} }}>{n} }`, "0", [{ before: "0", after: "42", land: { value: 42 } }], { async: false, authored: false }));
+
+test("the direct scenario handler API retains synchronous frame arrays across flights", () => {
+ // The control program writes before its await, which the pinned checker refuses (PRE_AWAIT_WRITE); the scenario
+ // handler API is exercised on the supported spelling, which writes only after the await and catches the failure.
+ assert.match(check(parseProgram(rx)).map(d => d.code).join(" "), PRE_AWAIT_WRITE.rx);
+ const program = parseProgram(`import { request } from "./api";
+source n = 0; source finalized = 0;
+export default component App() { {
+ try { let result = await request("value"); n := result; } catch (error) { n := -1; } finally { finalized := finalized + 1; }
+}}>{n}/{finalized} }`);
+ assert.deepEqual(check(program), []);
+ const app = program.decls.find(d => d.k === "component" && d.isDefault);
+ if (!app || app.k !== "component") throw new Error("the RX program's entry is its default component (MO-1c)");
+ const button = app.view[0];
+ assert.equal(button.k, "el");
+ if (button.k !== "el") return;
+ const callback = button.attrs[0].e;
+ assert.equal(callback.k, "fn");
+ if (callback.k !== "fn") return;
+ const rt = new Runtime(program);
+ try {
+ assert.equal(rt.step([]).at(-1)!.frame, "0/0");
+ const frames = rt.handler(callback.stmts!);
+ assert.ok(Array.isArray(frames));
+ assert.equal(frames.at(-1)!.frame, "0/0", "nothing is written before the await");
+ assert.equal(rt.holdOpen, true, "the body writes and awaits, so it is an inferred action (AC-1b) holding while its flight is out");
+ assert.equal(rt.land('"request:value"', 42).at(-1)!.frame, "42/1");
+ assert.equal(rt.holdOpen, false, "the action's hold ends with its body");
+ } finally { rt.root.dispose(); }
+});
+
+for (const optimistic of [false, true]) {
+ const source = `import { createSignal, createOptimistic, action, latest, isPending } from "solid-js";
+import { request } from "./api";
+export function Counter() {
+ const [n, setN] = createSignal(0);
+ const [draft, setDraft] = createSignal(() => 0);
+ const [status, setStatus] = createSignal("idle");
+ const [finalized, setFinalized] = createSignal(0);
+ ${optimistic ? "const [overlay, setOverlay] = createOptimistic(() => draft());" : ""}
+ const save = action(function*() {
+ ${optimistic ? "setOverlay(10);" : ""}
+ try {
+ const result: number = yield request("value");
+ setN(result);
+ setDraft(result);
+ setStatus("ok");
+ } catch (error) {
+ setStatus("failed");
+ } finally {
+ setFinalized(value => value + 1);
+ }
+ });
+ return {
+ setN(1);
+ setDraft(1);
+ setStatus("placing");
+ save();
+ }}>{n()}/{draft()}/{status()}/{finalized()}|{latest(() => n())}/{latest(() => draft())}|{isPending(() => n()) ? "pending" : "idle"}/{isPending(() => draft()) ? "pending" : "idle"}${optimistic ? "|{overlay()}" : ""} ;
+}`;
+ const rx = `import { request } from "./api";
+source n := 0; source draft = 0; source status := "idle"; source finalized := 0;
+${optimistic ? "source overlay = draft;" : ""}
+export default component App() { {
+ n := 1; draft := 1; status := "placing";
+ action {
+ ${optimistic ? "optimistic overlay := 10;" : ""}
+ try { let result = await request("value"); n := result; draft := result; status := "ok"; }
+ catch (error) { status := "failed"; }
+ finally { finalized := finalized + 1; }
+ }
+}}>{n}/{draft}/{status}/{finalized}|{latest n}/{latest draft}|{pending? n ? "pending" : "idle"}/{pending? draft ? "pending" : "idle"}${optimistic ? "|{overlay}" : ""} }`;
+ for (const failed of [false, true]) {
+ test("synchronous callback pre-writes join an inline held action through " + (failed ? "failure" : "success") +
+ (optimistic ? " with an optimistic overlay" : ""), t => {
+ const value = failed ? 1 : 42;
+ return compare(t, source, rx, "0/0/idle/0|0/0|idle/idle" + (optimistic ? "|0" : ""), [
+ { before: "0/0/idle/0|1/1|pending/pending" + (optimistic ? "|10" : ""),
+ after: `${value}/${value}/${failed ? "failed" : "ok"}/1|${value}/${value}|idle/idle` + (optimistic ? "|" + value : ""),
+ held: true, land: failed ? { failure: "offline" } : { value: 42 } },
+ ], { async: false });
+ });
+ }
+}
diff --git a/tsx-frontend/tsx/async-stream.test.ts b/tsx-frontend/tsx/async-stream.test.ts
new file mode 100644
index 0000000..971dc5b
--- /dev/null
+++ b/tsx-frontend/tsx/async-stream.test.ts
@@ -0,0 +1,151 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+
+for (const fixture of [
+ {
+ name: "async memo loading values",
+ api: "fetchValue",
+ memo: "createMemo(async (): Promise => await fetchValue(n()), { loadingValue: -1 })",
+ stream: false,
+ expected: [
+ "next|-1|idle|-1|0",
+ "next|0|idle|0|0",
+ "next|0|pending|0|1",
+ "next|10|idle|10|1",
+ ],
+ },
+ {
+ name: "direct async iterable memo generations",
+ api: "fetchValues",
+ memo: "createMemo((): AsyncIterable => fetchValues(n()))",
+ stream: true,
+ expected: [
+ "waiting",
+ "next|0|idle|0|0",
+ "next|1|idle|1|0",
+ "next|1|pending|1|1",
+ "next|1|pending|1|1",
+ "next|10|idle|10|1",
+ "next|11|idle|11|1",
+ ],
+ },
+]) {
+ test(`${fixture.name} match native Solid`, async t => {
+ const source = `
+import { createSignal, createMemo, Loading, Errored, isPending, latest } from "solid-js";
+import { ${fixture.api} } from "./api";
+export function Counter() {
+ const [n, setN] = createSignal(0);
+ const result = ${fixture.memo};
+ return failed
}>
+ waiting}>
+
+ setN(n() + 1)}>next|
+ {result()}
+ {isPending(() => result()) ? "|pending|" : "|idle|"}
+ {latest(() => result())}
+ |{latest(() => n())}
+
+
+ ;
+}`;
+ const inputs: Record = {
+ "main.tsx": source,
+ "api.d.rx": `export pure fn ${fixture.api}(n: number): number`,
+ };
+ const native = transform(source, {
+ filename: "Counter.tsx", generate: "universal", moduleName: STRING_RENDERER,
+ }).code.replaceAll('"./api"', '"./api.mjs"');
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-async-stream-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ try {
+ writeFileSync(dir + "/api.mjs", `export const ${fixture.api} = value => globalThis.__tsxAsyncStream(value);`);
+ const traces: string[][] = [];
+ for (const name of ["native.mts", "main.mjs"]) {
+ await t.test(name === "native.mts" ? "native Solid" : "emitted RX", async () => {
+ if (name === "main.mjs") assert.equal(traces.length, 1, "verify the native baseline before compiling RX");
+ const code = name === "native.mts" ? native
+ : buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" }).files.get(name)!;
+ const flights = new Map | AsyncIterable;
+ send: (value: number) => void;
+ returns: number;
+ }>();
+ const load = (key: number) => {
+ let flight = flights.get(key);
+ if (!flight) {
+ let send!: (value: number) => void;
+ const value = fixture.stream ? {
+ [Symbol.asyncIterator]() {
+ return {
+ next: () => new Promise>(resolve => {
+ send = value => resolve({ value, done: false });
+ }),
+ return: () => {
+ flights.get(key)!.returns++;
+ return Promise.resolve({ value: undefined, done: true as const });
+ },
+ };
+ },
+ } : new Promise(resolve => { send = resolve; });
+ flight = { value, send: value => send(value), returns: 0 };
+ flights.set(key, flight);
+ }
+ return flight.value;
+ };
+ (globalThis as any).__tsxAsyncStream = load;
+ writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const module = await import(pathToFileURL(dir + "/" + name).href);
+ const app = createApp({ api: { [fixture.api]: load } });
+ const dispose = app.mount(name === "native.mts" ? () => module.Counter() : module.default(app));
+ const settle = async () => {
+ for (let i = 0; i < 3; i++) {
+ await new Promise(resolve => setImmediate(resolve));
+ flush();
+ }
+ };
+ const frames: string[] = [];
+ try {
+ await settle();
+ frames.push(app.frame());
+ assert.ok(flights.has(0), name + " must start the initial question");
+ flights.get(0)!.send(0); await settle(); frames.push(app.frame());
+ if (fixture.stream) {
+ flights.get(0)!.send(1); await settle(); frames.push(app.frame());
+ }
+ const button = (node: any): any => node.tag === "button" ? node : node.children?.map(button).find(Boolean);
+ button(app.root).props.onClick(); await settle(); frames.push(app.frame());
+ assert.ok(flights.has(1), name + " must start the refreshed question");
+ if (fixture.stream) {
+ assert.equal(flights.get(0)!.returns, 1, name + " must cancel the superseded iterator");
+ // Deliver a late step even after return(), to exercise the generation guard.
+ flights.get(0)!.send(999); await settle(); frames.push(app.frame());
+ }
+ flights.get(1)!.send(10); await settle(); frames.push(app.frame());
+ if (fixture.stream) {
+ flights.get(1)!.send(11); await settle(); frames.push(app.frame());
+ }
+ assert.deepEqual(frames, fixture.expected, name);
+ if (fixture.stream) assert.equal(flights.get(1)!.returns, 0, name + " must keep the current iterator open");
+ } finally {
+ dispose();
+ delete (globalThis as any).__tsxAsyncStream;
+ }
+ if (fixture.stream) {
+ assert.deepEqual([...flights.values()].map(flight => flight.returns), [1, 1], name + " must close each iterator exactly once");
+ }
+ traces.push(frames);
+ });
+ }
+ if (traces.length === 2) assert.deepEqual(traces[1], traces[0]);
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+ });
+}
diff --git a/tsx-frontend/tsx/boundary.test.ts b/tsx-frontend/tsx/boundary.test.ts
new file mode 100644
index 0000000..465204a
--- /dev/null
+++ b/tsx-frontend/tsx/boundary.test.ts
@@ -0,0 +1,178 @@
+// Fast contracts for the moving-foundation layout: the import boundary, the pinned stack, and the upstream
+// representations the adapter constructs or reads. A failure here is adapter/type/API breakage, not a paired
+// behavior mismatch; scripts/canary.sh runs these first.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readdirSync, readFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { check, parseProgram, parseStmts, singleModule } from "./rx/compiler.ts";
+import { entryView, serverDecl, selectEntry } from "./rx/ast.ts";
+import { semanticFacts } from "./rx/facts.ts";
+import { actionInference } from "./rx/infer.ts";
+import { lowerTsx, TsxError } from "./lower.ts";
+
+const here = new URL(".", import.meta.url);
+const root = new URL("..", import.meta.url);
+
+test("only tsx/rx/ imports the upstream checkout", () => {
+ const banned = ["#" + "rx/", "../" + "src/", "../" + "compile/", "upstream" + "/ref/"];
+ const files = (readdirSync(here, { recursive: true }) as string[])
+ .filter(f => /\.(ts|tsx|mjs)$/.test(f) && !f.startsWith("rx/") && !f.startsWith("fixtures/"));
+ assert.ok(files.length > 20);
+ for (const f of files) {
+ const text = readFileSync(new URL(f, here), "utf8");
+ for (const b of banned) assert.ok(!text.includes(b), f + " reaches upstream directly (" + b + ")");
+ }
+});
+
+test("pinned stack: reactivescript, its ref/SOLID_NEXT and the Solid checkout agree" + (process.env.RX_CANARY ? " (canary: pins not enforced)" : " with pins.json"), () => {
+ const pins = JSON.parse(readFileSync(new URL("pins.json", root), "utf8"));
+ // In-tree: this package is tsx-frontend/ inside the reactivescript repository, whose HEAD is the branch commit.
+ const repo = fileURLToPath(new URL("../", root));
+ const git = (dir: string, ...args: string[]) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8" }).trim();
+ const solidNext = readFileSync(repo + "ref/SOLID_NEXT", "utf8").trim();
+ assert.equal(git(repo + "../solid-next", "rev-parse", "HEAD"), solidNext, "the Solid checkout is upstream's ref/SOLID_NEXT");
+ if (!process.env.RX_CANARY) {
+ assert.equal(solidNext, pins.solid.commit);
+ const pin = pins.reactivescript.commit;
+ assert.doesNotThrow(() => git(repo, "merge-base", "--is-ancestor", pin, "HEAD"), "the pinned commit is an ancestor of HEAD");
+ // Committed and uncommitted changes to tracked files, plus untracked files, relative to the pin.
+ const differing = [
+ ...git(repo, "diff", "--name-only", pin, "--").split("\n"),
+ ...git(repo, "ls-files", "--others", "--exclude-standard").split("\n"),
+ ].filter(Boolean);
+ assert.ok(differing.some(f => f.startsWith("tsx-frontend/")), "the comparison sees this package's own files");
+ assert.deepEqual(differing.filter(f => !f.startsWith("tsx-frontend/")), [], "nothing outside tsx-frontend/ differs from the pin");
+ }
+});
+
+test("entry representation: the adapter's default-component entry is what upstream's parser produces (MO-1c)", () => {
+ const parsed = parseProgram("export default component App() { x
}");
+ assert.equal(parsed.legacyView, undefined);
+ assert.deepEqual(parsed.view, entryView("App", 1));
+ const c = parsed.decls[0];
+ assert.ok(c.k === "component" && c.isDefault && c.exported);
+ const selected = parseProgram("export component App() { x
}");
+ selectEntry(selected, "App");
+ assert.deepEqual(selected.view, parsed.view);
+ assert.deepEqual(selected.decls, parsed.decls);
+});
+
+test("server representation: one server declaration kind, io inferred by the checker (SF-1)", () => {
+ const [parsed] = parseProgram("server fn load(a) { return a }").decls;
+ assert.equal(parsed.k, "server");
+ assert.ok(!("kind" in parsed), "no server fn/action split");
+ const built = serverDecl("load", ["a"], parseStmts("return a"), 1);
+ for (const key of Object.keys(built)) assert.ok(key in parsed, "upstream server decls still carry " + key);
+ assert.deepEqual({ ...built, body: undefined }, { k: parsed.k, name: (parsed as any).name, params: (parsed as any).params, body: undefined, line: parsed.line });
+});
+
+test("model contract: canonical facts keep read modes, writes and region nesting", () => {
+ const facts = semanticFacts(singleModule(parseProgram(
+ 'export default component App() { source n := 0; d = n * 2; s = peek(n); { n := n + 1 }}>{"" + d + s} }')));
+ assert.deepEqual(facts.diagnostics, []);
+ const m = facts.modules[0];
+ assert.ok(m.regions.some(r => r.endsWith("/component:App")));
+ const reads = m.computations.flatMap(c => c.reads);
+ assert.ok(reads.some(r => r.endsWith("::source:n tracked")));
+ assert.ok(reads.some(r => r.endsWith("::source:n snapshot")));
+ assert.ok(m.computations.some(c => c.kind === "handler" && c.writes.some(w => w.endsWith("::source:n write"))));
+});
+
+// Temporary TSX boundary while https://github.com/ryansolid/reactivescript/issues/20 is open: every authored ordinary
+// async callback is refused at its async node, never lowered or converted to an action, with explicit guidance.
+const ASYNC_ISSUE = "https://github.com/ryansolid/reactivescript/issues/20";
+const ASYNC_CALLBACK = "Ordinary async callbacks are refused for now (" + ASYNC_ISSUE + "): wrap the work in action(function* () { ... yield request(); ... }) and use yield instead of await. This opts into action semantics, which hold the action's writes until it completes; it is not a semantics-preserving rewrite of the async callback";
+const ASYNC_ACTION = "Actions take a synchronous generator, not an async function or async generator (" + ASYNC_ISSUE + "): write action(function* () { ... yield request(); ... }) and use yield instead of await";
+const refusedAt = (source: string, at: string, message: string) => {
+ assert.ok(source.includes(at), "the authored refusal site exists: " + at);
+ assert.throws(() => lowerTsx(source, "authored.tsx"), (error: unknown) => {
+ assert.ok(error instanceof TsxError);
+ assert.equal(error.start, source.indexOf(at), "refused at the authored async node");
+ assert.equal(error.message.slice(error.message.indexOf(": ") + 2), message);
+ return true;
+ });
+};
+
+test("inferred-action contract: upstream AC-1b/AC-1c facts; the frontend refuses that callback as ordinary async (issue #20)", () => {
+ const [decl] = parseProgram("source n := 0").decls;
+ assert.equal(decl.k, "source");
+ assert.deepEqual(actionInference(parseStmts("n := 1; let v = await f(); n := v")), { trigger: "write+await", why: "it writes and awaits", held: ["n"] });
+ assert.deepEqual(actionInference(parseStmts("let v = await f(); n := v")).held, []);
+ assert.equal(actionInference(parseStmts("n := 1")).trigger, null);
+ const source = `import { createSignal } from "solid-js";
+import { request } from "./api";
+export default function Counter() {
+ const [n, setN] = createSignal(0);
+ return {
+ setN(1);
+ const result: number = await request();
+ setN(result);
+ }}>{n()} ;
+}`;
+ // Formerly refused only for its held pre-await write; now refused for being an ordinary async callback.
+ refusedAt(source, "async () =>", ASYNC_CALLBACK);
+});
+
+const asyncHead = 'import { createSignal, createMemo, action } from "solid-js";\nimport { createRxEffect } from "reactivescript-tsx";\nimport { request } from "./api";\n';
+const asyncCounter = (setup: string, view: string, child = "() => props.onChange(2)") => asyncHead +
+ "function Child(props: { onChange: (value: number) => void }) { return ; }\n" +
+ "export function Counter() {\n const [n, setN] = createSignal(0);\n " + setup + "\n return " + view + ";\n}";
+const onClick = (handler: string) => asyncCounter("", "{n()} ");
+const untouched = " setN(1)}>{n()} ";
+
+test("ordinary async callbacks are refused at their authored async node on every resolved callback route (issue #20)", () => {
+ const routes: [string, string][] = [
+ [onClick("async () => { setN(1); const result: number = await request(); setN(result); }"), "async () =>"],
+ [onClick("async () => { const result: number = await request(); setN(result); }"), "async () =>"],
+ [onClick("async () => { await request(); }"), "async () =>"],
+ [onClick("async () => { setN(1); }"), "async () =>"],
+ [onClick("async () => setN(1)"), "async () =>"],
+ [onClick("async () => await request()"), "async () =>"],
+ [onClick("async (mode: number) => { if (mode === 0) return 7; if (mode === 1) throw 1; const answer: number = await 8; return answer; }"), "async (mode"],
+ [onClick("async (): Promise => { const result: number = await request(); return result; }"), "async ():"],
+ // Contextual void callback prop, and a child forwarding its callback prop through an async arrow.
+ [asyncCounter("", " { const result: number = await request(next); }}/>"), "async (next"],
+ [asyncCounter("", " setN(next)}/>", "async () => { await props.onChange(2); }"), "async () =>"],
+ // Stored callbacks: a const arrow, a nested function declaration, and an async closure inside a sync handler.
+ [asyncCounter("const save = async () => { const result: number = await request(); setN(result); };", " save()}>{n()} "), "async () =>"],
+ [asyncCounter("async function save() { const result: number = await request(); setN(result); }", untouched), "async function save"],
+ [onClick("() => { const later = async () => { const result: number = await request(); return result; }; return 7; }"), "async () =>"],
+ // Setup callbacks: an effect apply callback and its returned cleanup.
+ [asyncCounter("createRxEffect([() => n()], async (value: number) => { await request(); });", untouched), "async (value"],
+ [asyncCounter("createRxEffect([() => n()], (value: number) => { return async () => { await request(); }; });", untouched), "async () =>"],
+ ];
+ for (const [source, at] of routes) refusedAt(source, at, ASYNC_CALLBACK);
+});
+
+test("actions refuse async functions and async generators with synchronous-generator guidance (issue #20)", () => {
+ const body = "{ const result: number = yield request(); setN(result); }";
+ refusedAt(onClick("() => { action(async function* () " + body + ")(); }"), "async function*", ASYNC_ACTION);
+ refusedAt(onClick("() => { action(async () => { const result: number = await request(); setN(result); })(); }"), "async () =>", ASYNC_ACTION);
+ const declared = (fn: string) => asyncCounter("const save = action(" + fn + ");", " save()}>{n()} ");
+ refusedAt(declared("async function* () " + body), "async function*", ASYNC_ACTION);
+ refusedAt(declared("async function () { const result: number = await request(); setN(result); }"), "async function (", ASYNC_ACTION);
+ refusedAt(declared("async () => { const result: number = await request(); setN(result); }"), "async () =>", ASYNC_ACTION);
+});
+
+test("explicit generator actions and async memo/server computations stay accepted beside the refusal (issue #20)", () => {
+ const body = "{ try { const result: number = yield request(); setN(result); } catch (error) { setN(-1); } }";
+ const declared = asyncCounter("const save = action(function* () " + body + ");", " save()}>{n()} ");
+ const inline = onClick("() => { action(function* () " + body + ")(); }");
+ for (const source of [declared, inline]) {
+ const program = lowerTsx(source);
+ assert.deepEqual(check(program), []);
+ const counter = program.decls.find(d => d.k === "component" && d.name === "Counter");
+ assert.ok(counter?.k === "component");
+ const handler = counter.view[0].k === "el" ? counter.view[0].attrs.find(a => a.name === "onClick")?.e : undefined;
+ const action = source === declared ? counter.decls.find(d => d.k === "action") : handler?.k === "fn" ? handler.stmts?.[0] : undefined;
+ assert.ok(action?.k === "action", "the explicit generator lowers to an action");
+ const attempt = action.body[0];
+ assert.ok(attempt.k === "try" && attempt.body[0].k === "let");
+ assert.equal(attempt.body[0].e.k, "await", "yield lowers to the action body's await");
+ }
+ // Async computations are not callbacks: they keep their own routes and are not swept up by the refusal.
+ assert.doesNotThrow(() => lowerTsx(asyncCounter("const value = createMemo(async (): Promise => await request(n()));", " setN(1)}>{value()} ")));
+ assert.doesNotThrow(() => lowerTsx('import { serverFunction, createServerMemo } from "reactivescript-tsx";\nexport function Counter() {\n const load = serverFunction("load", function* (n: number): Generator { return n + 1; });\n const value = createServerMemo(async () => await load(0));\n return {value()} ;\n}'));
+});
diff --git a/tsx-frontend/tsx/browser.ts b/tsx-frontend/tsx/browser.ts
new file mode 100644
index 0000000..f42ccb5
--- /dev/null
+++ b/tsx-frontend/tsx/browser.ts
@@ -0,0 +1,177 @@
+// Real-browser adoption test, separate from the fast TSX suite.
+import { runtimeFiles } from "./rx/runtime.ts";
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { buildSync } from "esbuild";
+import { chromium } from "playwright";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+
+const scenarios = [
+ {
+ name: "async-handler", frames: ["0/0", "4/1", "44/2", "444/3"], hydrationCalls: 0,
+ // Temporary TSX boundary (https://github.com/ryansolid/reactivescript/issues/20): the authored TSX leg proves the
+ // refusal of its ordinary async callback instead of building; the native and RX legs still stream, hydrate, click.
+ tsxRefused: /Ordinary async callbacks are refused for now \(https:\/\/github\.com\/ryansolid\/reactivescript\/issues\/20\): wrap the work in action\(function\* \(\) \{ \.\.\. yield request\(\); \.\.\. \}\)/,
+ rx: 'import pure {fetchValue} from "./api"; export component Counter() { source n := 0; source runs := 0; { n := 1; let result = await fetchValue(n); n := result; runs := previous => previous + 1 }}>{"" + n}/{"" + runs} }',
+ tsx: 'import {createSignal} from "solid-js"; import {fetchValue} from "./api"; export function Counter() { const [n,setN]=createSignal(0); const [runs,setRuns]=createSignal(0); return {setN(1); const result:number=await fetchValue(n()); setN(result); setRuns(previous=>previous+1);}}>{n()}/{runs()} ; }',
+ },
+ {
+ name: "input-events", frames: ["empty", "one", "two", "three"], hydrationCalls: 0,
+ rx: 'component Field(props) { { props.onValue(event.currentTarget.value) }}/> } export component Counter() { source note := "empty"; { note := next }}/>{note} }',
+ tsx: 'import { createSignal } from "solid-js"; function Field(props: {value: string; onValue: (next: string) => void}) { return props.onValue(event.currentTarget.value)}/>; } export function Counter() { const [note,setNote] = createSignal("empty"); return <> setNote(next)}/>{note()} >; }',
+ },
+ {
+ name: "branch-remount", frames: ["0:0/0", "1:1/0", "2:hidden", "3:3/3"], hydrationCalls: 0,
+ rx: 'component Child(props: {n: number}) { source initial := peek(props.n); {"" + props.n}/{"" + initial} } export component Counter() { source n := 0; { n := n + 1 }}>{"" + n}:{if n % 3 != 2 { } else { hidden }} }',
+ tsx: 'import { createSignal, Show, untrack } from "solid-js"; function Child(props: {n: number}) { const initial = untrack(() => props.n); return {props.n}/{initial} ; } export function Counter() { const [n,setN] = createSignal(0); return setN(n()+1)}>{n()}:hidden}> ; }',
+ },
+ {
+ name: "keyed-rows", frames: ["1:A/A;2:B/B;", "2:b/B;1:a/A;", "1:A/A;2:B/B;", "2:b/B;1:a/A;"], hydrationCalls: 0,
+ rx: 'component Row(props: {row: {id: number; value: string}}) { source initial := peek(props.row.value); {"" + props.row.id}:{"" + props.row.value}/{"" + initial}; } export component Counter() { source n := 0; rows key id = n % 2 ? [{id:2,value:"b"},{id:1,value:"a"}] : [{id:1,value:"A"},{id:2,value:"B"}]; { n := n + 1 }}>{for row in rows { }}
}',
+ tsx: 'import { createSignal, createProjection, For, untrack } from "solid-js"; function Row(props: {row: {id: number; value: string}}) { const initial = untrack(() => props.row.value); return {props.row.id}:{props.row.value}/{initial}; ; } export function Counter() { const [n,setN] = createSignal(0); const rows = createProjection(() => n()%2 ? [{id:2,value:"b"},{id:1,value:"a"}] : [{id:1,value:"A"},{id:2,value:"B"}], [], {key:"id"}); return setN(n()+1)}>{row => }
; }',
+ },
+ {
+ name: "counter", frames: ["4", "6", "8", "10"], hydrationCalls: 0,
+ rx: 'export component Counter() { source n := 2; doubled = n * 2; { n := n + 1 }}>{"" + doubled} }',
+ tsx: 'import { createSignal, createMemo } from "solid-js"; export function Counter() { const [n, setN] = createSignal(2); const doubled = createMemo(() => n() * 2); return setN(n() + 1)}>{doubled()} ; }',
+ },
+ {
+ name: "async", frames: ["4", "14", "24", "34"], hydrationCalls: 1,
+ rx: 'import pure { fetchValue } from "./api"; export component Counter() { source n := 0; result = await fetchValue(n); errored fallback { failed
} { loading fallback { waiting
} { { n := n + 1 }}>{"" + result} } } }',
+ tsx: 'import { createSignal, createMemo, Loading, Errored } from "solid-js"; import { fetchValue } from "./api"; export function Counter() { const [n,setN] = createSignal(0); const result = createMemo(async (): Promise => await fetchValue(n())); return failed
}>waiting}> setN(n()+1)}>{result()} ; }',
+ },
+ {
+ name: "two-input-effect", frames: ["0/0", "1/2", "2/3", "3/4"], hydrationCalls: 0,
+ // A direct second input makes the apply rerun on every change of n (EF-2 compares each input).
+ rx: 'export component Counter() { source n := 0; source runs := 0; effect(n > 2, n) { runs := p => p + 1 } { n := n + 1 }}>{"" + n}/{"" + runs} }',
+ tsx: 'import { createSignal } from "solid-js"; import { createRxEffect } from "reactivescript-tsx"; export function Counter() { const [n,setN] = createSignal(0); const [runs,setRuns] = createSignal(0); createRxEffect([() => n() > 2, () => n()], (flag, value) => { setRuns(previous => previous + 1); }); return setN(n()+1)}>{n()}/{runs()} ; }',
+ },
+ {
+ name: "rx-effect", frames: ["0/initial", "1/#error", "2/false", "3/false"], hydrationCalls: 0,
+ rx: 'error Boom(); export component Counter() { source n := 0; source last := "initial"; value = n == 1 ? throw Boom() : false; effect(value) { last := "" + $0 } { n := n + 1 }}>{"" + n}/{last} }',
+ tsx: 'import { createSignal, createMemo } from "solid-js"; import { createRxEffect, defineError, raiseFailure } from "reactivescript-tsx"; const Boom = defineError("Boom", [], () => "boom"); export function Counter() { const [n,setN] = createSignal(0); const [last,setLast] = createSignal("initial"); const value = createMemo(() => n() === 1 ? raiseFailure(Boom()) : false); createRxEffect([() => value()], answer => { setLast("" + answer); }); return setN(n()+1)}>{n()}/{last()} ; }',
+ },
+ {
+ name: "stream", frames: ["4", "15", "25", "35"], hydrationCalls: 1,
+ rx: 'import pure { fetchValue } from "./api"; export component Counter() { source n := 0; value = await* fetchValue(n); errored fallback { failed
} { loading fallback { waiting
} { { n := n + 1 }}>{"" + value} } } }',
+ tsx: 'import { createSignal, createMemo, Loading, Errored } from "solid-js"; import { fetchValue } from "./api"; export function Counter() { const [n,setN] = createSignal(0); const value = createMemo((): AsyncIterable => fetchValue(n())); return failed
}>waiting}> setN(n()+1)}>{value()} ; }',
+ },
+ {
+ name: "context", frames: ["0/0", "1/0", "2/0", "3/0"], hydrationCalls: 0,
+ rx: 'context Theme: number = 7; export component Counter() { source n := 0; provide Theme = 0; { n := n + 1 }}>{"" + n}/{"" + use Theme} }',
+ tsx: 'import { createSignal, createContext, useContext } from "solid-js"; const Theme = createContext(7); function Value() { const theme = useContext(Theme); return {theme} ; } export function Counter() { const [n,setN] = createSignal(0); return setN(n()+1)}>{n()}/ ; }',
+ },
+ {
+ name: "source-context", frames: ["0", "1", "2", "3"], hydrationCalls: 0,
+ rx: 'context Count: source number; component Reader() { { use Count := use Count + 1 }}>{"" + use Count} } export component Counter() { source count := 0; provide Count = count; }',
+ tsx: 'import { createSignal } from "solid-js"; import { createSourceContext, useSourceContext } from "reactivescript-tsx"; const Count = createSourceContext(); function Reader() { const [count,setCount] = useSourceContext(Count); return setCount(count()+1)}>{count()} ; } export function Counter() { const [count,setCount] = createSignal(0); return ; }',
+ },
+ {
+ name: "typed-failure", frames: ["0/ok", "1/ok", "2/missing 2"], hydrationCalls: 0,
+ rx: 'error Missing(id) => "missing " + id; export component Counter() { source n := 0; value = n < 2 ? "ok" : throw Missing(n); { n := n + 1 }}>{"" + n}/{errored fallback(e: Missing) { {e.message} } { {value} }} }',
+ tsx: 'import { createSignal, createMemo } from "solid-js"; import { defineError, raiseFailure, FailureBoundary } from "reactivescript-tsx"; const Missing = defineError("Missing", ["id"], (id: number) => "missing " + id); export function Counter() { const [n,setN] = createSignal(0); const value = createMemo((): string => n() < 2 ? "ok" : raiseFailure(Missing(n()))); return setN(n()+1)}>{n()}/ {error().message} }>{value()} ; }',
+ },
+ {
+ // hydrationCalls records a shared limitation, not a goal: on the old pin both syntaxes re-invoked the loader's JS
+ // API once during hydration. At reactivescript aa85cbde both syntaxes reuse the streamed server answer (0 calls).
+ name: "server-loader", frames: ["4", "14", "24", "34"], hydrationCalls: 0,
+ rx: 'import pure { fetchValue } from "./api"; server fn load(n) { let value = await fetchValue(n); return value } export component Counter() { source n := 0; value = await load(n); errored fallback { failed
} { loading fallback { waiting
} { { n := n + 1 }}>{"" + value} } } }',
+ tsx: 'import { createSignal, createMemo, Loading, Errored } from "solid-js"; import { serverFunction } from "reactivescript-tsx"; import { fetchValue } from "./api"; const load = serverFunction("load", function* (n: number): Generator { const value: number = yield fetchValue(n); return value; }); export function Counter() { const [n,setN] = createSignal(0); const value = createMemo(async (): Promise => await load(n())); return failed
}>waiting}> setN(n()+1)}>{value()} ; }',
+ },
+ {
+ name: "server-factories", frames: ["4/5", "5/6", "6/7", "7/8"], hydrationCalls: 0,
+ rx: 'component Left(props: {n: number}) { server fn load(x) { return x + 4 } answer = await load(props.n); {"" + answer}/ } component Right(props: {n: number}) { server fn load(x) { return x + 5 } answer = await load(props.n); {"" + answer} } export component Counter() { source n := 0; { n := n + 1 }}>{errored fallback { failed } { loading fallback { waiting } { } }} }',
+ tsx: 'import { createSignal, createMemo, Loading, Errored } from "solid-js"; import { serverFunction } from "reactivescript-tsx"; function Left(props: {n: number}) { const load = serverFunction("load", function* (x: number): Generator { return x + 4; }); const answer = createMemo(async (): Promise => await load(props.n)); return {answer()}/ ; } function Right(props: {n: number}) { const load = serverFunction("load", function* (x: number): Generator { return x + 5; }); const answer = createMemo(async (): Promise => await load(props.n)); return {answer()} ; } export function Counter() { const [n,setN] = createSignal(0); return setN(n()+1)}>failed}>waiting}> ; }',
+ },
+];
+
+for (const scenario of scenarios) for (const syntax of (["rx-effect", "server-factories", "async-handler"].includes(scenario.name) ? ["rx", "tsx", "native"] : ["rx", "tsx"]) as ("rx" | "tsx" | "native")[]) test(syntax + " " + scenario.name + (syntax === "tsx" && scenario.tsxRefused ? " is refused at its authored async callback (issue #20)" : " streams HTML, adopts the same DOM on hydration, then updates on clicks"), async () => {
+ const source = syntax === "native" ? scenario.tsx : scenario[syntax], expectedFrames = scenario.frames;
+ if (syntax === "tsx" && scenario.tsxRefused) {
+ for (const target of ["ssr", "dom"] as const)
+ assert.throws(() => buildTsx("Counter.tsx", path => path === "Counter.tsx" ? source : path === "api.d.rx" ? "export pure fn fetchValue(n: number): number" : undefined, { target, mount: "Counter" }), scenario.tsxRefused);
+ return;
+ }
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-browser-");
+ try {
+ for (const target of ["ssr", "dom"] as const) {
+ mkdirSync(dir + "/" + target);
+ if (syntax === "native") {
+ const code = transform(source, { filename: "Counter.tsx", generate: target, hydratable: true, moduleName: "@solidjs/web" }).code.replaceAll('"./api"', '"./api.mjs"');
+ writeFileSync(dir + "/" + target + "/main.mts", code);
+ writeFileSync(dir + "/" + target + "/api.mjs", "export const fetchValue = value => globalThis.__browserLoad(value);");
+ continue;
+ }
+ const input = "Counter." + syntax;
+ const result = buildTsx(input, path => path === input ? source : path === "api.d.rx" ? "export pure fn fetchValue(n: number): number" : undefined, { target, mount: "Counter" });
+ for (const [name, code] of result.files) writeFileSync(dir + "/" + target + "/" + name, code);
+ }
+ const serverRuntime = runtimeFiles.server;
+ const clientRuntime = runtimeFiles.web;
+ const server = [
+ "import { createApp, generateHydrationScript } from " + JSON.stringify(serverRuntime) + ";",
+ syntax === "native" ? 'import { Counter } from "./ssr/main.mts";' : 'import main from "./ssr/main.mjs";',
+ scenario.name === "stream"
+ ? "const app = createApp({api: {fetchValue: async function* (value) { yield value * 10 + 4; yield value * 10 + 5; }}});"
+ : "const app = createApp({api: {fetchValue: value => Promise.resolve(value * 10 + 4)}});",
+ "let html = '';",
+ "await new Promise(resolve => { app.stream(" + (syntax === "native" ? "() => Counter()" : "main(app)") + ", { write: chunk => { html += chunk; }, end: resolve }); });",
+ "process.stdout.write(JSON.stringify({html, script: generateHydrationScript()}));",
+ ].join("\n");
+ writeFileSync(dir + "/server.mjs", server);
+ const rendered = spawnSync(process.execPath, ["--experimental-strip-types", "--conditions=development", dir + "/server.mjs"], { encoding: "utf8", timeout: 15000 });
+ assert.equal(rendered.status, 0, rendered.stderr);
+ const payload = JSON.parse(rendered.stdout);
+ const entry = [
+ "import { createApp, flush } from " + JSON.stringify(clientRuntime) + ";",
+ syntax === "native" ? 'import { Counter } from "./dom/main.mts";' : 'import main from "./dom/main.mjs";',
+ scenario.name === "stream"
+ ? 'window.__clientCalls = 0; const app = createApp({root: document.getElementById("app"), api: {fetchValue: async function* (value) { window.__clientCalls++; yield value * 10 + 4; yield value * 10 + 5; }}});'
+ : scenario.name === "async-handler"
+ ? 'window.__clientCalls = 0; globalThis.__browserLoad = value => { window.__clientCalls++; return new Promise(resolve => { window.__release = () => resolve(value * 10 + 4); }); }; const app = createApp({root: document.getElementById("app"), api: {fetchValue: globalThis.__browserLoad}});'
+ : 'window.__clientCalls = 0; const app = createApp({root: document.getElementById("app"), api: {fetchValue: value => { window.__clientCalls++; return Promise.resolve(value * 10 + 4); }}});',
+ "app.mount(" + (syntax === "native" ? "() => Counter()" : "main(app)") + ", true); flush(); window.__hydrated = true;",
+ ].join("\n");
+ writeFileSync(dir + "/client.mjs", entry);
+ const bundle = buildSync({ entryPoints: [dir + "/client.mjs"], bundle: true, write: false,
+ platform: "browser", format: "iife", conditions: ["browser", "development"], tsconfigRaw: "{}", logLevel: "silent" }).outputFiles[0].text;
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ page.setDefaultTimeout(5000);
+ const errors: string[] = [];
+ page.on("pageerror", error => errors.push(error.message));
+ await page.setContent('' + payload.script + '' + payload.html + '
');
+ assert.equal(await page.locator("button").textContent(), expectedFrames[0]);
+ await page.evaluate(() => { (window as any).__serverButton = document.querySelector("button"); });
+ await page.addScriptTag({ content: bundle });
+ assert.deepEqual(errors, [], "hydration must not raise browser errors");
+ assert.equal(await page.evaluate(() => (window as any).__hydrated), true);
+ // The current RX runtime invokes a direct JS API once during hydration too.
+ assert.equal(await page.evaluate(() => (window as any).__clientCalls), scenario.hydrationCalls, "TSX must retain the RX hydration baseline");
+ assert.equal(await page.evaluate(() => (window as any).__serverButton === document.querySelector("button")), true, "hydration must adopt rather than replace the server DOM");
+ if (scenario.name === "stream") await page.waitForFunction(() => document.querySelector("button")?.textContent === "5");
+ for (const [index, expected] of expectedFrames.slice(1).entries()) {
+ if (scenario.name === "input-events") await page.locator("input").fill(expected);
+ else await page.locator("button").click();
+ if (scenario.name === "async-handler") {
+ await page.waitForFunction(text => document.querySelector("button")?.textContent === text, "1/" + index);
+ assert.equal(await page.evaluate(() => (window as any).__clientCalls), index + 1);
+ await page.evaluate(() => (window as any).__release());
+ }
+ try { await page.waitForFunction(text => document.querySelector("button")?.textContent === text, expected); }
+ catch (error) {
+ assert.equal(await page.locator("button").textContent(), expected, syntax + " " + scenario.name + ": " + errors.join(", "));
+ throw error;
+ }
+ }
+ assert.deepEqual(errors, []);
+ } finally { await browser.close(); }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+});
diff --git a/tsx-frontend/tsx/build.test.ts b/tsx-frontend/tsx/build.test.ts
new file mode 100644
index 0000000..12b5071
--- /dev/null
+++ b/tsx-frontend/tsx/build.test.ts
@@ -0,0 +1,57 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { transformSync } from "esbuild";
+import { buildTsx } from "./build.ts";
+import { CheckError } from "./rx/compiler.ts";
+
+const source = 'import { createSignal } from "solid-js"; export function Counter() { const [n, setN] = createSignal(0); return setN(n()+1)}>{n()} ; }';
+
+for (const target of ["universal", "dom", "ssr"] as const) {
+ test("build uses Solid's " + target + " backend after checking the shared AST", () => {
+ const result = buildTsx("main.tsx", path => path === "main.tsx" ? source : undefined, { target, mount: "Counter" });
+ assert.equal(result.entry, "main.mjs");
+ assert.ok(result.signatures.has("main.tsx"));
+ assert.ok(result.files.get(result.entry)?.includes("export default function main"));
+ assert.doesNotThrow(() => transformSync(result.files.get(result.entry)!, { loader: "js" }), "JSX must be lowered");
+ });
+}
+
+test("exports do not mount without an explicit selection", () => {
+ const result = buildTsx("main.tsx", () => source);
+ assert.deepEqual(result.set.programs.get("main.tsx")!.view, []);
+});
+
+test("an unknown component selection is refused", () => {
+ assert.throws(() => buildTsx("main.tsx", () => source, { mount: "Missing" }), /No exported component Missing/);
+});
+
+test("a missing entry cannot produce an empty emitted build", () => {
+ assert.throws(() => buildTsx("missing.tsx", () => undefined), CheckError);
+});
+
+test("shared-checker errors stop emission", () => {
+ assert.throws(() => buildTsx("main.rx", path => path === "main.rx" ? "value = missing; {value}
" : undefined), CheckError);
+});
+
+test("CLI checks a TSX entry and writes the selected target's module factories", () => {
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-cli-");
+ try {
+ const result = spawnSync(process.execPath, ["--experimental-strip-types", "tsx/build.ts", "tsx/fixtures/counter.tsx",
+ "--mount", "Counter", "--target", "dom", "--out", dir], { cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8" });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /main.mjs/);
+ assert.match(readFileSync(dir + "/main.mjs", "utf8"), /export default function main/);
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+});
+
+test("CLI rejects unknown options instead of silently changing the build", () => {
+ const result = spawnSync(process.execPath, ["--experimental-strip-types", "tsx/build.ts", "tsx/fixtures/counter.tsx", "--target", "unknown", "--out", "unused"],
+ { cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8" });
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /Unknown option or target/);
+});
diff --git a/tsx-frontend/tsx/build.ts b/tsx-frontend/tsx/build.ts
new file mode 100644
index 0000000..e5fcef6
--- /dev/null
+++ b/tsx-frontend/tsx/build.ts
@@ -0,0 +1,49 @@
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { loadTsxModules } from "./modules.ts";
+import { selectEntry } from "./rx/ast.ts";
+import { checkModules } from "./rx/compiler.ts";
+import { CheckError } from "./rx/compiler.ts";
+import { compileModules } from "./rx/compiler.ts";
+import { lowerViews, type Target } from "./rx/compiler.ts";
+
+/** The same checked module graph and Solid backends for both authored syntaxes. */
+export function buildTsx(entry: string, read: (path: string) => string | undefined,
+ options: { target?: Target; mount?: string } = {}) {
+ const set = loadTsxModules(entry, read);
+ if (set.diags.length) throw new CheckError(set.diags);
+ // The entry is the entry module's default component (MO-1c); --mount selects an exported component as that entry.
+ if (options.mount) selectEntry(set.programs.get(set.entry)!, options.mount);
+ const checked = checkModules(set);
+ if (checked.diags.length) throw new CheckError(checked.diags);
+ const compiled = compileModules(set);
+ return { set, signatures: checked.sigs, entry: compiled.entry,
+ files: lowerViews(compiled.files, options.target ?? "universal") };
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ try {
+ const args = process.argv.slice(2);
+ const input = args.shift();
+ if (!input || input.startsWith("--")) throw new Error("Usage: npm run build:tsx -- entry.tsx --out directory [--mount App] [--target universal|dom|ssr]");
+ const options: { mount?: string; target?: Target } = {};
+ let out: string | undefined;
+ while (args.length) {
+ const name = args.shift(); const value = args.shift();
+ if (!value || value.startsWith("--")) throw new Error("Missing value for " + name);
+ if (name === "--out") out = value;
+ else if (name === "--mount") options.mount = value;
+ else if (name === "--target" && ["universal", "dom", "ssr"].includes(value)) options.target = value as Target;
+ else throw new Error("Unknown option or target: " + name + " " + value);
+ }
+ if (!out) throw new Error("--out directory is required");
+ const result = buildTsx(resolve(input), path => {
+ try { return readFileSync(path, "utf8"); }
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
+ }, options);
+ mkdirSync(resolve(out), { recursive: true });
+ for (const [name, code] of result.files) writeFileSync(resolve(out, name), code);
+ console.log(resolve(out, result.entry));
+ } catch (error) { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }
+}
diff --git a/tsx-frontend/tsx/callbacks.test.ts b/tsx-frontend/tsx/callbacks.test.ts
new file mode 100644
index 0000000..635d4f3
--- /dev/null
+++ b/tsx-frontend/tsx/callbacks.test.ts
@@ -0,0 +1,271 @@
+import { test, type TestContext } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+import { Runtime } from "./rx/runtime.ts";
+
+type Interaction = {
+ tag: string;
+ event: string;
+ args?: unknown[];
+ result?: unknown;
+ failure?: { name: string; message: string; value: number };
+};
+const click: Interaction = { tag: "button", event: "onClick" };
+
+async function compare(t: TestContext, source: string, steps: Interaction[], expected: string[]) {
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-callbacks-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ const build = () => buildTsx("main.tsx", path => path === "main.tsx" ? source : undefined,
+ { target: "universal", mount: "Counter" });
+ const invoke = (callback: any, step: Interaction) => {
+ assert.equal(typeof callback, "function", "must invoke the actual authored " + step.event);
+ if (step.failure) assert.throws(() => callback(...(step.args ?? [])), step.failure);
+ else {
+ const result = callback(...(step.args ?? []));
+ if ("result" in step) assert.equal(result, step.result);
+ }
+ };
+ const settle = async () => {
+ for (let i = 0; i < 3; i++) {
+ await new Promise(done => setImmediate(done));
+ flush();
+ }
+ };
+ const render = async (name: string, code: string, native: boolean) => {
+ writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(dir + "/" + name).href);
+ const app = createApp();
+ const dispose = app.mount(native ? () => mod.Counter() : mod.default(app));
+ const find = (node: any, tag: string): any => node.tag === tag
+ ? node : node.children?.map((child: any) => find(child, tag)).find(Boolean);
+ try {
+ await settle();
+ const frames = [app.frame()];
+ for (const step of steps) {
+ invoke(find(app.root, step.tag)?.props[step.event], step);
+ await settle();
+ frames.push(app.frame());
+ }
+ return frames;
+ } finally { dispose(); }
+ };
+ try {
+ let nativeFrames: string[] | undefined;
+ await t.test("native Solid", async () => {
+ const code = transform(source, { filename: "Counter.tsx", generate: "universal", moduleName: STRING_RENDERER }).code;
+ const frames = await render("native.mts", code, true);
+ assert.deepEqual(frames, expected);
+ nativeFrames = frames;
+ t.diagnostic("native frames: " + JSON.stringify(frames));
+ });
+ assert.ok(nativeFrames, "prove the native expectation before adapting TSX");
+ await t.test("compiled TSX", async () => {
+ const built = build();
+ assert.deepEqual(await render(built.entry, built.files.get(built.entry)!, false), nativeFrames);
+ });
+ await t.test("interpreter", () => {
+ const rt = new Runtime(build().set);
+ const find = (items: any[], tag: string): any => {
+ for (const item of items) {
+ if (item.k === "el" && item.tag === tag) return item;
+ const found = find(item.children ?? item.inst?.items ?? item.node?.value?.items ?? [], tag);
+ if (found) return found;
+ }
+ };
+ try {
+ const frames = [rt.step([]).at(-1)!.frame];
+ for (const step of steps) {
+ const node = find(rt.view.items, step.tag)?.attrs.find((attr: any) => attr.name === step.event)?.node;
+ assert.ok(node, "interpreter must expose the authored " + step.event);
+ invoke(node.compute(rt), step);
+ frames.push(rt.handler([]).at(-1)!.frame);
+ }
+ assert.deepEqual(frames, nativeFrames);
+ } finally { rt.root.dispose(); }
+ });
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+}
+
+test("inline typed onInput receives the actual event target value", async t => {
+ await compare(t, `import { createSignal } from "solid-js";
+export function Counter() {
+ const [note, setNote] = createSignal("initial");
+ return <> {
+ setNote(event.target.value);
+ }}/>{note()}
>;
+}`, [
+ { tag: "input", event: "onInput", args: [{ target: { value: "alpha" } }] },
+ { tag: "input", event: "onInput", args: [{ target: { value: "" } }] },
+ { tag: "input", event: "onInput", args: [{ target: { value: "omega" } }] },
+ ], ["initial", "alpha", "", "omega"]);
+});
+
+test("parameterized component callbacks forward numbers through a child and relay", async t => {
+ await compare(t, `import { createSignal } from "solid-js";
+function Button(props: { value: number; onChange: (next: number) => void }) {
+ return props.onChange(props.value + 2)}>{props.value} ;
+}
+function Relay(props: { value: number; onChange: (next: number) => void }) {
+ return ;
+}
+export function Counter() {
+ const [count, setCount] = createSignal(0);
+ const [calls, setCalls] = createSignal(0);
+ return <>{calls()}/ {
+ setCount(next);
+ setCalls(n => n + 1);
+ }}/>>;
+}`, [click, click, click], ["0/0", "1/2", "2/4", "3/6"]);
+});
+
+test("forwarded event callback props preserve the payload across child and parent", async t => {
+ await compare(t, `import { createSignal } from "solid-js";
+function Input(props: { onInput: (event: { target: { value: string } }) => void }) {
+ return ;
+}
+function Relay(props: { onInput: (event: { target: { value: string } }) => void }) {
+ return ;
+}
+export function Counter() {
+ const [note, setNote] = createSignal("initial");
+ return <> {
+ setNote(event.target.value);
+ }}/>{note()} >;
+}`, [
+ { tag: "input", event: "onInput", args: [{ target: { value: "forwarded" } }] },
+ { tag: "input", event: "onInput", args: [{ target: { value: "again" } }] },
+ ], ["initial", "forwarded", "again"]);
+});
+
+test("ordinary callback try catch finally handles success, failure, and early return", async t => {
+ await compare(t, `import { createSignal } from "solid-js";
+export function Counter() {
+ const [count, setCount] = createSignal(0);
+ const [status, setStatus] = createSignal("idle");
+ const [finalized, setFinalized] = createSignal(0);
+ return {
+ const next = count() + 1;
+ setCount(next);
+ try {
+ if (next === 2) throw "blocked";
+ if (next === 3) return;
+ setStatus("ok");
+ } catch (error) {
+ setStatus("caught");
+ } finally {
+ setFinalized(n => n + 1);
+ }
+ }}>{count()}/{status()}/{finalized()} ;
+}`, [click, click, click, click], ["0/idle/0", "1/ok/1", "2/caught/2", "3/caught/3", "4/ok/4"]);
+});
+
+// These are synchronous callbacks, not actions; no failure-row type notation is needed.
+test("a child catches its parent callback failure and still runs finally", async t => {
+ await compare(t, `import { createSignal } from "solid-js";
+import { defineError } from "reactivescript-tsx";
+const Broken = defineError("Broken", ["value"], (value: number) => "broken " + value);
+function Button(props: { onSave: () => void }) {
+ const [status, setStatus] = createSignal("idle");
+ const [finalized, setFinalized] = createSignal(0);
+ return {
+ try {
+ props.onSave();
+ setStatus("returned");
+ } catch (error) {
+ setStatus("caught");
+ } finally {
+ setFinalized(n => n + 1);
+ }
+ }}>{status()}/{finalized()} ;
+}
+export function Counter() {
+ const [attempts, setAttempts] = createSignal(0);
+ return <>{attempts()}/ {
+ setAttempts(n => n + 1);
+ throw Broken(7);
+ }}/>>;
+}`, [click, click], ["0/idle/0", "1/caught/1", "2/caught/2"]);
+});
+
+test("callback rethrows reach the event caller with their payload after both finalizers", async t => {
+ await compare(t, `import { createSignal } from "solid-js";
+import { defineError } from "reactivescript-tsx";
+const Broken = defineError("Broken", ["value"], (value: number) => "broken " + value);
+function Button(props: { onSave: () => void }) {
+ const [status, setStatus] = createSignal("idle");
+ const [finalized, setFinalized] = createSignal(0);
+ return {
+ try {
+ props.onSave();
+ setStatus("returned");
+ } catch (error) {
+ setStatus("rethrow");
+ throw error;
+ } finally {
+ setFinalized(n => n + 1);
+ }
+ }}>{status()}/{finalized()} ;
+}
+export function Counter() {
+ const [attempts, setAttempts] = createSignal(0);
+ const [finalized, setFinalized] = createSignal(0);
+ return <>{attempts()}/{finalized()}/ {
+ setAttempts(n => n + 1);
+ try {
+ throw Broken(7);
+ } finally {
+ setFinalized(n => n + 1);
+ }
+ }}/>>;
+}`, [
+ { ...click, failure: { name: "Broken", message: "broken 7", value: 7 } },
+ { ...click, failure: { name: "Broken", message: "broken 7", value: 7 } },
+ ], ["0/0/idle/0", "1/1/rethrow/1", "2/2/rethrow/2"]);
+});
+
+test("nested finalizers preserve completions unless their own return or throw overrides them", async t => {
+ await compare(t, `import { createSignal } from "solid-js";
+import { defineError } from "reactivescript-tsx";
+const Broken = defineError("Broken", ["value"], (value: number) => "broken " + value);
+export function Counter() {
+ const [attempts, setAttempts] = createSignal(0);
+ const [trace, setTrace] = createSignal("");
+ return {
+ const next = attempts() + 1;
+ setAttempts(next);
+ try {
+ try {
+ if (next < 3) return "body";
+ throw Broken(next);
+ } finally {
+ setTrace(v => v + "inner>");
+ }
+ } finally {
+ setTrace(v => v + "outer;");
+ if (next === 2) return "override";
+ if (next === 3) return "recovered";
+ if (next === 5) throw Broken(50);
+ }
+ }}>{attempts()}/{trace()} ;
+}`, [
+ { ...click, result: "body" },
+ { ...click, result: "override" },
+ { ...click, result: "recovered" },
+ { ...click, failure: { name: "Broken", message: "broken 4", value: 4 } },
+ { ...click, failure: { name: "Broken", message: "broken 50", value: 50 } },
+ ], [
+ "0/",
+ "1/inner>outer;",
+ "2/inner>outer;inner>outer;",
+ "3/inner>outer;inner>outer;inner>outer;",
+ "4/inner>outer;inner>outer;inner>outer;inner>outer;",
+ "5/inner>outer;inner>outer;inner>outer;inner>outer;inner>outer;",
+ ]);
+});
diff --git a/tsx-frontend/tsx/composition.test.ts b/tsx-frontend/tsx/composition.test.ts
new file mode 100644
index 0000000..cb27614
--- /dev/null
+++ b/tsx-frontend/tsx/composition.test.ts
@@ -0,0 +1,42 @@
+import { test } from "node:test";
+import { readFileSync } from "node:fs";
+import { traces } from "./test-utils.ts";
+
+const fixture = (name: string) =>
+ readFileSync(new URL("./fixtures/" + name, import.meta.url), "utf8");
+const composition = fixture("composition.tsx");
+
+test("later-declared children receive live direct and memo props and forward callbacks upward", async () => {
+ await traces(composition, fixture("composition.rx"), [
+ "0/0/10",
+ "1/4/10",
+ "2/8/10",
+ "3/12/10",
+ "4/16/10",
+ ]);
+});
+
+test("callback props can attach directly to onClick or forward through a zero-argument arrow", async () => {
+ await traces(
+ composition.replace(
+ "onClick={() => props.onIncrement()}",
+ "onClick={props.onIncrement}",
+ ),
+ fixture("composition.rx").replace(
+ "onClick={() => { props.onIncrement(); }}",
+ "onClick={props.onIncrement}",
+ ),
+ ["0/0/10", "1/4/10", "2/8/10", "3/12/10"],
+ );
+ await traces(
+ composition.replace(
+ "onIncrement={incoming.onIncrement}",
+ "onIncrement={() => incoming.onIncrement()}",
+ ),
+ fixture("composition.rx").replace(
+ "onIncrement={incoming.onIncrement}",
+ "onIncrement={() => { incoming.onIncrement(); }}",
+ ),
+ ["0/0/10", "1/4/10", "2/8/10", "3/12/10"],
+ );
+});
diff --git a/tsx-frontend/tsx/contracts.test.ts b/tsx-frontend/tsx/contracts.test.ts
new file mode 100644
index 0000000..e531498
--- /dev/null
+++ b/tsx-frontend/tsx/contracts.test.ts
@@ -0,0 +1,90 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildTsx } from "./build.ts";
+import { CheckError } from "./rx/compiler.ts";
+
+const build = (files: Record) => buildTsx("main.tsx", path => files[path], { mount: "Counter" });
+const rejects = (code: string) => (error: unknown) => error instanceof CheckError && error.diagnostics.some(d => d.code === code);
+
+const failures = {
+ "types.tsx": 'import { defineError } from "reactivescript-tsx"; export const Missing = defineError("Missing", ["id"], (id: number) => "missing " + id); export const Offline = defineError("Offline", [], () => "offline");',
+ "reader.rx": 'import { Missing } from "./types.tsx"; export component Reader() { value = throw Missing(1); {value}
}',
+ "main.tsx": 'import { FailureBoundary } from "reactivescript-tsx"; import { Missing as Lost } from "./types.tsx"; import { Reader } from "./reader.rx"; export function Counter() { return missing
}> ; }',
+};
+
+test("typed failure identities cross TSX exports, RX imports, and TSX boundary aliases", () => {
+ const result = build(failures);
+ assert.ok(result.set.programs.get("types.tsx")!.decls.some(d => d.k === "error" && d.name === "Missing" && d.exported));
+ assert.ok(result.files.has(result.entry));
+});
+
+test("a typed TSX boundary does not discharge a different imported error class", () => {
+ assert.throws(() => build({ ...failures, "main.tsx": failures["main.tsx"].replace("Missing as Lost", "Offline as Lost") }), rejects("UNHANDLED_FAILURE"));
+});
+
+test("a caller boundary cannot discharge failures read later by a child action", () => {
+ const files = {
+ "child.tsx": 'import {action} from "solid-js"; export function Child(props:{value:number}) { const go=action(function*(){const snapshot=props.value;}); return go()}>go ; }',
+ "main.tsx": 'import {createMemo,Errored} from "solid-js"; import {defineError,raiseFailure} from "reactivescript-tsx"; import {Child} from "./child.tsx"; const Broken=defineError("Broken",[],()=>"broken"); export function Counter(){const value=createMemo(()=>true?raiseFailure(Broken()):0);return failed}> ;}',
+ };
+ assert.throws(() => build(files), rejects("UNCAUGHT_ACTION_FAILURE"));
+ assert.ok(build({ ...files, "child.tsx": files["child.tsx"].replace("const snapshot=props.value;", "try {const snapshot=props.value;} catch {}") }));
+});
+
+test("content-model signatures cross both module frontends before emission", () => {
+ for (const entry of ["tsx", "rx"]) for (const dependency of ["tsx", "rx"]) {
+ const source = entry === "tsx"
+ ? 'import {Row} from "./row.EXT"; export function Counter(){return ;}'
+ : 'import {Row} from "./row.EXT"; export component Counter(){}';
+ const files = {
+ ["main." + entry]: source.replace("EXT", dependency),
+ ["row." + dependency]: dependency === "tsx"
+ ? 'export function Row(){return cell ;}'
+ : 'export component Row(){cell }',
+ };
+ const emit = (inputs: Record) => buildTsx("main." + entry, path => inputs[path], { mount: "Counter" });
+ assert.throws(() => emit(files), rejects("CONTENT_MODEL"));
+ assert.ok(emit({ ...files, ["main." + entry]: files["main." + entry].replace("
", "|
") }));
+ }
+});
+
+const contexts = {
+ "context.tsx": 'import { createSourceContext } from "reactivescript-tsx"; export const Count = createSourceContext();',
+ "reader.tsx": 'import { useSourceContext } from "reactivescript-tsx"; import { Count as Current } from "./context.tsx"; export function Reader() { const [count,setCount] = useSourceContext(Current); return setCount(count()+1)}>{count()} ; }',
+ "main.tsx": 'import { createSignal } from "solid-js"; import { Count as Current } from "./context.tsx"; import { Reader } from "./reader.tsx"; export function Counter() { const [count,setCount] = createSignal(0); return ; }',
+};
+
+test("source-context aliases retain their writable provision contract across modules", () => {
+ const result = build(contexts);
+ assert.ok(result.set.programs.get("context.tsx")!.decls.some(d => d.k === "context" && d.source && !d.dflt));
+});
+
+test("required source contexts are checked where the imported component is rendered", () => {
+ assert.throws(() => build({ ...contexts, "main.tsx": 'import { Reader } from "./reader.tsx"; export function Counter() { return ; }' }), rejects("CONTEXT_NOT_FOUND"));
+});
+
+test("a source-context provider cannot combine a reader with a different source's writer", () => {
+ assert.throws(() => build({ ...contexts, "main.tsx": contexts["main.tsx"].replace('const [count,setCount] = createSignal(0);', 'const [count,setCount] = createSignal(0); const [other,setOther] = createSignal(0);').replace('value={[count,setCount]}', 'value={[count,setOther]}') }), /same source|matching source|same signal/);
+});
+
+const server = {
+ "loaders.tsx": 'import { serverFunction } from "reactivescript-tsx"; import { lookup } from "./api"; export const load = serverFunction("load", function* (id: number): Generator { const value: number = yield lookup(id); return value + 1; });',
+ "api.d.rx": 'export server pure fn lookup(id: number): number',
+ "main.tsx": 'import { createMemo, Loading, Errored } from "solid-js"; import { load as read } from "./loaders.tsx"; export function Counter() { const value = createMemo(async (): Promise => await read(1)); return failed
}>waiting}>{value()}
; }',
+};
+
+test("TSX server loaders retain side contracts and imported aliases in the shared compiler", () => {
+ const result = build(server);
+ // Upstream SF-1 has one server declaration (no fn/action kind); whether it is io is inferred from its body.
+ assert.ok(result.set.programs.get("loaders.tsx")!.decls.some(d => d.k === "server" && d.exported && !("kind" in d)));
+});
+
+test("an IO declaration does not become pure inside a TSX server loader", () => {
+ // Pinned upstream SF-1: a server fn is io when its body calls io, and an io call in reactive scope is refused
+ // (IO-2 IO_IN_REACTIVE); the old pin refused the loader body itself (SERVER_FN_IO). Either way it is not pure.
+ assert.throws(() => build({ ...server, "api.d.rx": 'export server fn lookup(id: number): number' }), rejects("IO_IN_REACTIVE"));
+});
+
+test("server generators cannot capture component state", () => {
+ assert.throws(() => build({ "main.tsx": 'import { createSignal } from "solid-js"; import { serverFunction } from "reactivescript-tsx"; export function Counter() { const [count,setCount] = createSignal(0); const load = serverFunction("load", function* (): Generator { return count(); }); return blocked
; }' }), rejects("SERVER_CAPTURE"));
+});
diff --git a/tsx-frontend/tsx/demo.test.ts b/tsx-frontend/tsx/demo.test.ts
new file mode 100644
index 0000000..931f609
--- /dev/null
+++ b/tsx-frontend/tsx/demo.test.ts
@@ -0,0 +1,176 @@
+// Demo for review (not a parity claim beyond its script): an authored Solid 2 issue tracker,
+// tsx/fixtures/demo/tracker.tsx, and its handwritten RX counterpart, tracker.rx. Both must have the same canonical
+// checker facts, a deliberate read-mode change must still be a fact difference, and every backend must render the
+// same exact frames for one scripted session: the RX interpreter (TSX-derived and handwritten), the RX compiler for
+// both programs, and native Solid's universal renderer. Assignment requests are controllable flights.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { loadTsxModules } from "./modules.ts";
+import { semanticFacts } from "./rx/facts.ts";
+import { compileModules, lowerViews, STRING_RENDERER, type ModuleSet } from "./rx/compiler.ts";
+import { createApp, flush, Runtime } from "./rx/runtime.ts";
+
+const fixture = (name: string) => readFileSync(new URL("./fixtures/demo/" + name, import.meta.url), "utf8");
+const load = (name: string, text = fixture(name)): ModuleSet => {
+ const files: Record = { [name]: text, "api.d.rx": "export fn assignIssue(id: number): string" };
+ const set = loadTsxModules(name, path => files[path]);
+ assert.deepEqual(set.diags, [], name + " loads and checks");
+ return set;
+};
+const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+const outRoot = fileURLToPath(new URL("../.out/", import.meta.url));
+
+/** Click the n-th rendered button (0 is the filter, then one "take" per visible row), or answer an issue's request. */
+type Step = { click: number } | { answer: number; owner?: string; failure?: string };
+type Answer = Extract;
+type Driver = { frame: () => string; click: (n: number) => void; answer: (step: Answer) => void; settle: () => Promise; dispose: () => void };
+
+const SCRIPT: Step[] = [
+ { click: 2 }, // take "Slow search": the claim shows at once
+ { answer: 2, failure: "offline" }, // the request fails: roll back to "ana" and explain
+ { click: 0 }, // filter to open issues
+ { click: 1 }, // take "Crash on save"
+ { answer: 1, owner: "dev" }, // success commits the owner; the row leaves the open view
+ { click: 1 }, // take "Typo in footer"
+ { answer: 3, owner: "dev" }, // success: nothing open, so the fallback shows
+];
+const EXPECTED = [
+ "Filter: all2 openCrash on save: nobodytakeSlow search: anatakeTypo in footer: nobodytake",
+ "Filter: all2 openCrash on save: nobodytakeSlow search: metakeTypo in footer: nobodytake",
+ "Filter: all2 openCrash on save: nobodytakeSlow search: anatakeTypo in footer: nobodytakeCould not assign #2",
+ "Filter: open2 openCrash on save: nobodytakeTypo in footer: nobodytakeCould not assign #2",
+ "Filter: open2 openCrash on save: metakeTypo in footer: nobodytakeCould not assign #2",
+ "Filter: open1 openTypo in footer: nobodytake",
+ "Filter: open1 openTypo in footer: metake",
+ "Filter: open0 openNothing to triage",
+];
+
+async function session(driver: Driver): Promise {
+ try {
+ const frames = [driver.frame()];
+ for (const step of SCRIPT) {
+ if ("click" in step) driver.click(step.click); else driver.answer(step);
+ await driver.settle();
+ frames.push(driver.frame());
+ }
+ return frames;
+ } finally { driver.dispose(); }
+}
+
+function interpreted(set: ModuleSet): Driver {
+ const rt = new Runtime(set);
+ let frame = rt.step([]).at(-1)!.frame;
+ const found = (items: any[]): any[] => items.flatMap((item: any) => item.k === "el" && item.tag === "button" ? [item]
+ : found(item.children ?? item.inst?.items ?? item.committed?.inst?.items ?? item.committedRows?.flatMap((r: any) => r.inst.items) ?? []));
+ return {
+ frame: () => frame,
+ click: n => {
+ const handler = found(rt.view.items)[n]?.attrs.find((a: any) => a.name === "onClick")?.node;
+ assert.ok(handler, "the interpreter exposes authored button " + n);
+ handler.compute(rt)();
+ frame = rt.handler([]).at(-1)!.frame;
+ },
+ answer: step => {
+ frame = rt.land('"assignIssue:' + step.answer + '"', step.failure ? new Error(step.failure) : step.owner, !!step.failure).at(-1)!.frame;
+ },
+ settle: async () => {},
+ dispose: () => rt.root.dispose(),
+ };
+}
+
+async function rendered(files: Map, entry: string, native: boolean): Promise {
+ mkdirSync(outRoot, { recursive: true });
+ const dir = mkdtempSync(outRoot + "demo-");
+ try {
+ for (const [name, code] of files) writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(dir + "/" + entry).href);
+ const app = createApp();
+ // Native Solid imports ./api as plain JS; route it to the same controllable flights the compiled RX uses.
+ (globalThis as any).__demoAssignIssue = (...args: unknown[]) => (app.api("assignIssue")(...args) as any).first;
+ const buttons = (node: any): any[] => node?.tag === "button" ? [node] : (node?.children ?? []).flatMap(buttons);
+ let frame = "";
+ const settle = async () => {
+ for (let i = 0; i < 3; i++) {
+ await new Promise(done => setImmediate(done));
+ flush();
+ }
+ frame = app.frame();
+ };
+ const dispose = app.mount(native ? () => mod.default() : mod.default(app));
+ await settle();
+ assert.equal(app.flights.size, 0, "no assignment is requested before a click");
+ return await session({
+ frame: () => frame,
+ click: n => {
+ const button = buttons(app.root)[n];
+ assert.equal(typeof button?.props.onClick, "function", "button " + n + " has its authored handler");
+ button.props.onClick();
+ },
+ answer: step => {
+ const flight = app.flights.get("assignIssue:" + step.answer);
+ assert.ok(flight, "issue " + step.answer + " has an open assignment request");
+ if (step.failure) flight.reject(new Error(step.failure)); else flight.resolve(step.owner);
+ },
+ settle,
+ dispose,
+ });
+ } finally {
+ delete (globalThis as any).__demoAssignIssue;
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+const compiled = (set: ModuleSet) => {
+ const built = compileModules(set);
+ return rendered(lowerViews(built.files, "universal"), built.entry, false);
+};
+
+test("demo tracker: TSX and handwritten RX have the same canonical checker facts", () => {
+ const tsx = semanticFacts(load("tracker.tsx"));
+ assert.deepEqual(tsx.diagnostics, []);
+ const computations = tsx.modules[0].computations;
+ const memo = computations.filter(c => c.kind === "memo");
+ assert.ok(memo.some(c => c.reads.some(r => r.endsWith("::source:mode tracked"))), "the visible rows track the filter");
+ const claim = computations.find(c => c.kind === "action");
+ assert.ok(claim, "claim is an explicit action");
+ assert.ok(claim.writes.some(w => w.endsWith("::source:claiming? optimistic")), "the claim is an optimistic write");
+ assert.ok(claim.writes.some(w => w.endsWith("::source:issues write path")), "success writes the store path");
+ assert.ok(claim.calls.some(c => c.endsWith("::import:assignIssue!io")), "the action calls the request");
+ assert.ok(computations.some(c => c.kind === "handler" && c.at === "IssueRow onAssign" && c.calls.some(x => x.endsWith("::action:claim!io"))),
+ "the row callback invokes the action");
+ assert.deepEqual(tsx, semanticFacts(load("tracker.rx")));
+});
+
+test("demo tracker: snapshotting the filter in the handwritten RX is a fact difference, not normalized away", () => {
+ const rx = fixture("tracker.rx");
+ const changed = rx.replace('issue => mode == "all"', 'issue => peek(mode) == "all"');
+ assert.notEqual(changed, rx, "the variant changes one read");
+ const variant = semanticFacts(load("tracker.rx", changed));
+ assert.ok(variant.modules[0].computations.some(c => c.kind === "memo" && c.reads.some(r => r.endsWith("::source:mode snapshot"))));
+ assert.notDeepEqual(semanticFacts(load("tracker.tsx")), variant);
+});
+
+test("demo tracker frames: native Solid", async () => {
+ const code = transform(fixture("tracker.tsx"), { filename: "tracker.tsx", generate: "universal", moduleName: STRING_RENDERER }).code
+ .replaceAll('"./api"', '"data:text/javascript,export const assignIssue=(...args)=>globalThis.__demoAssignIssue(...args)"');
+ assert.deepEqual(await rendered(new Map([["native.mts", code]]), "native.mts", true), EXPECTED);
+});
+
+test("demo tracker frames: compiled TSX", async () => {
+ assert.deepEqual(await compiled(load("tracker.tsx")), EXPECTED);
+});
+
+test("demo tracker frames: compiled handwritten RX", async () => {
+ assert.deepEqual(await compiled(load("tracker.rx")), EXPECTED);
+});
+
+test("demo tracker frames: interpreted TSX", async () => {
+ assert.deepEqual(await session(interpreted(load("tracker.tsx"))), EXPECTED);
+});
+
+test("demo tracker frames: interpreted handwritten RX", async () => {
+ assert.deepEqual(await session(interpreted(load("tracker.rx"))), EXPECTED);
+});
diff --git a/tsx-frontend/tsx/derived-stores.test.ts b/tsx-frontend/tsx/derived-stores.test.ts
new file mode 100644
index 0000000..e1ffa65
--- /dev/null
+++ b/tsx-frontend/tsx/derived-stores.test.ts
@@ -0,0 +1,247 @@
+import { test, type TestContext } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { loadModules, type ModuleSet } from "./rx/compiler.ts";
+import { checkModules } from "./rx/compiler.ts";
+import { CheckError } from "./rx/compiler.ts";
+import { Runtime } from "./rx/runtime.ts";
+import { compileModules } from "./rx/compiler.ts";
+import { lowerViews, STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+
+type Step = { click: number } | { land: string; value: unknown };
+const declarations = `export pure fn fetchState(id: number): { a: number; b: number }
+export pure fn gate(key: string): number`;
+const settle = async () => {
+ for (let i = 0; i < 4; i++) {
+ await new Promise(resolve => setImmediate(resolve));
+ flush();
+ }
+};
+const buttons = (node: any): any[] => node.tag === "button" ? [node] : (node.children ?? []).flatMap(buttons);
+function interpretedButtons(items: any[]): any[] {
+ return items.flatMap(item => {
+ if (item.k === "el" && item.tag === "button") return [item];
+ const nested = item.children ?? item.inst?.items ?? item.committed?.inst?.items
+ ?? item.committedRows?.flatMap((row: any) => row.inst.items)
+ ?? ((item.k === "loading" || item.k === "errored") ? (item.committedFallback ? item.fallback : item.content)?.items : []);
+ return interpretedButtons(nested ?? []);
+ });
+}
+
+async function differential(t: TestContext, source: string, rx: string, steps: Step[], expected: string[],
+ calls: Record = {}, nativeAlternatives: [string, string][] = [], refusedTsx?: RegExp) {
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-derived-stores-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ const traces: string[][] = [];
+ const rendered = async (name: string, entry: string, files: Map, native = false) => {
+ const output = dir + "/" + name;
+ mkdirSync(output);
+ writeFileSync(output + "/api.mjs", `
+export const fetchState = id => globalThis.__tsxDerivedStoreApi("fetchState", id);
+export const gate = key => globalThis.__tsxDerivedStoreApi("gate", key);`);
+ for (const [path, code] of files) writeFileSync(output + "/" + path, code.replaceAll(STRING_RENDERER, renderer));
+ const requests = new Map void; landed: boolean }[]>();
+ const ask = (name: string, arg: unknown) => new Promise(resolve => {
+ const key = name + ":" + arg;
+ let entries = requests.get(key);
+ if (!entries) requests.set(key, (entries = []));
+ entries.push({ resolve, landed: false });
+ });
+ (globalThis as any).__tsxDerivedStoreApi = ask;
+ const module = await import(pathToFileURL(output + "/" + entry).href);
+ const app = createApp({ api: { fetchState: id => ask("fetchState", id), gate: key => ask("gate", key) } });
+ const dispose = app.mount(native ? () => module.Counter() : module.default(app));
+ try {
+ await settle();
+ const frames = [app.frame()];
+ assert.equal(frames[0], expected[0], name + " mount");
+ for (const step of steps) {
+ if ("click" in step) {
+ const callback = buttons(app.root)[step.click]?.props.onClick;
+ assert.equal(typeof callback, "function", "invoke the actual authored button handler");
+ callback();
+ } else {
+ const request = requests.get(step.land)?.find(request => !request.landed);
+ assert.ok(request, name + " must have asked " + step.land);
+ request.landed = true;
+ request.resolve(structuredClone(step.value));
+ }
+ await settle();
+ frames.push(app.frame());
+ assert.equal(frames.at(-1), expected[frames.length - 1], name + " instant " + (frames.length - 1));
+ }
+ assert.deepEqual(frames, expected, name);
+ assert.deepEqual(Object.fromEntries([...requests].map(([key, entries]) => [key, entries.length])), calls, name + " actual API calls");
+ traces.push(frames);
+ } finally { dispose(); }
+ };
+ const interpreted = (name: string, set: ModuleSet) => {
+ const runtime = new Runtime(set);
+ try {
+ const frames = [runtime.step([]).at(-1)!.frame];
+ assert.equal(frames[0], expected[0], name + " mount");
+ for (const step of steps) {
+ if ("click" in step) {
+ const node = interpretedButtons(runtime.view.items)[step.click]?.attrs.find((attr: any) => attr.name === "onClick")?.node;
+ assert.ok(node, "interpreter must expose the actual authored handler");
+ const callback = node.compute(runtime);
+ assert.equal(typeof callback, "function");
+ callback();
+ frames.push(runtime.handler([]).at(-1)!.frame);
+ } else frames.push(runtime.land(JSON.stringify(step.land), structuredClone(step.value)).at(-1)!.frame);
+ assert.equal(frames.at(-1), expected[frames.length - 1], name + " instant " + (frames.length - 1));
+ }
+ assert.deepEqual(frames, expected, name);
+ for (const trace of traces) assert.deepEqual(frames, trace, "all backends must match the full native trace");
+ } finally { runtime.root.dispose(); }
+ };
+ try {
+ let nativesProven = 0;
+ for (const [name, nativeSource] of [["native Solid", source], ...nativeAlternatives]) {
+ await t.test(name, async () => {
+ const code = transform(nativeSource, { filename: "Counter.tsx", generate: "universal", moduleName: STRING_RENDERER }).code
+ .replaceAll('"./api"', '"./api.mjs"');
+ await rendered("native-" + traces.length, "native.mts", new Map([["native.mts", code]]), true);
+ nativesProven++;
+ });
+ }
+ assert.equal(nativesProven, nativeAlternatives.length + 1, "prove native expectations before checking RX or TSX");
+ const inputs: Record = { "main.rx": rx, "main.tsx": source, "api.d.rx": declarations };
+ const handwritten = loadModules("main.rx", path => inputs[path]);
+ assert.deepEqual(handwritten.diags, []);
+ assert.deepEqual(checkModules(handwritten).diags, []);
+ await t.test("compiled handwritten RX", async () => {
+ const compiled = compileModules(handwritten);
+ await rendered("handwritten", compiled.entry, lowerViews(compiled.files, "universal"));
+ });
+ await t.test("interpreted handwritten RX", () => interpreted("handwritten RX", handwritten));
+ await t.test("compiled authored TSX", async () => {
+ // A TSX spelling with no faithful RX lowering at the pin must be refused, while native and RX legs still run.
+ if (refusedTsx) return assert.throws(() => buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" }), refusedTsx);
+ const built = buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" });
+ await rendered("authored", built.entry, built.files);
+ });
+ await t.test("interpreted authored TSX", () => {
+ if (refusedTsx) return assert.throws(() => buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" }), refusedTsx);
+ const built = buildTsx("main.tsx", path => inputs[path], { target: "universal", mount: "Counter" });
+ interpreted("authored TSX", built.set);
+ });
+ } finally {
+ delete (globalThis as any).__tsxDerivedStoreApi;
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+const asyncSource = `import { createSignal, createStore, action, refresh, isPending, latest, Loading, Errored } from "solid-js";
+import { fetchState, gate } from "./api";
+export function Counter() {
+ const [id, setId] = createSignal(1);
+ const [state, setState] = createStore(async (): Promise<{ a: number; b: number }> => await fetchState(id()));
+ const patch = action(function*() {
+ try { setState(d => { d.a = 5; }); yield gate("w"); } catch (error) {}
+ });
+ return failed
}>loading}>
+
{state.a} {state.b} {isPending(() => state.a) ? "a!" : "-"} {isPending(() => state.b) ? "b!" : "-"}|{latest(() => state.a)}/{latest(() => state.b)}
+
setId(id() + 1)}>
+
refresh(state)}>
+
patch()}>
+
setState(d => { d.b = 8; })}>
+
;
+}`;
+const asyncRx = `import { fetchState, gate } from "./api";
+source id := 1;
+source state = await fetchState(id);
+action patch() { try { state.a := 5; await gate("w"); } catch {} }
+export default component App() { errored fallback(error) { failed
} {
+ {loading fallback { loading
} {
+
{state.a} {state.b} {pending? state.a ? "a!" : "-"} {pending? state.b ? "b!" : "-"}|{latest state.a}/{latest state.b}
+
{ id := id + 1; }}>
+
{ refresh state; }}>
+
{ patch(); }}>
+
{ state.b := 8; }}>
+
}}
+} }`;
+
+test("S-solid-37 seedless derived stores suspend initially, re-ask by id and refresh quietly", t =>
+ differential(t, asyncSource, asyncRx, [
+ { land: "fetchState:1", value: { a: 1, b: 1 } }, { click: 0 },
+ { land: "fetchState:2", value: { a: 2, b: 2 } }, { click: 1 },
+ { land: "fetchState:2", value: { a: 3, b: 3 } },
+ ], [
+ "loading", "1 1 - -|1/1", "1 1 a! b!|1/1", "2 2 - -|2/2", "2 2 - -|2/2", "3 3 - -|3/3",
+ ], { "fetchState:1": 1, "fetchState:2": 2 }, [
+ ["native Solid explicit null seed", asyncSource.replace("await fetchState(id()))", "await fetchState(id()), null)")],
+ ]));
+
+test("S-solid-37 held path writes pend only their key and latest sees the uncommitted path", t =>
+ differential(t, asyncSource, asyncRx, [
+ { land: "fetchState:1", value: { a: 1, b: 1 } }, { click: 2 },
+ { land: "gate:w", value: 0 }, { click: 3 }, { click: 0 },
+ { land: "fetchState:2", value: { a: 2, b: 2 } },
+ ], [
+ "loading", "1 1 - -|1/1", "1 1 a! -|5/1", "5 1 - -|5/1", "5 8 - -|5/8", "5 8 a! b!|5/8", "2 2 - -|2/2",
+ ], { "fetchState:1": 1, "gate:w": 1, "fetchState:2": 1 }));
+
+test("sync derived stores retain manual paths until an input changes, with stable root and captured nested aliases", t =>
+ differential(t, `import { createSignal, createStore, untrack } from "solid-js";
+export function Counter() {
+ const [id, setId] = createSignal(1);
+ const [tick, setTick] = createSignal(0);
+ const [state, setState] = createStore((): { a: number; b: number; nested: { value: number } } => ({ a: id(), b: id() * 10, nested: { value: id() } }));
+ const alias = state;
+ const nested = untrack(() => state.nested);
+ return {state.a}/{state.b}|{alias.a}/{alias.b}|{state.nested.value}/{nested.value}|{tick()}
+
setState(d => { d.a = 5; })}>
+
setTick(tick() + 1)}>
+
setId(id() + 1)}>
+
setState(d => { d.nested.value = 9; })}>
+
;
+}`, `source id := 1; source tick := 0;
+source state = { a: id, b: id * 10, nested: { value: id } };
+alias = state;
+nested = state.nested;
+export default component App() { {state.a}/{state.b}|{alias.a}/{alias.b}|{state.nested.value}/{nested.value}|{tick}
+
{ state.a := 5; }}>
+
{ tick := tick + 1; }}>
+
{ id := id + 1; }}>
+
{ state.nested.value := 9; }}>
+
}`, [
+ { click: 0 }, { click: 1 }, { click: 2 }, { click: 3 }, { click: 1 }, { click: 2 },
+ ], [
+ "1/10|1/10|1/1|0", "5/10|5/10|1/1|0", "5/10|5/10|1/1|1", "2/20|2/20|2/2|1",
+ "2/20|2/20|9/9|1", "2/20|2/20|9/9|2", "3/30|3/30|3/3|2",
+ // `untrack(() => state.nested)` returns Solid's live nested proxy; the pinned RX has only a value snapshot (peek),
+ // so the authored TSX is refused (README: semantic limits). The handwritten RX spells the alias as an equation.
+ ], {}, [], /untrack of a structured (value of a derived or computed store|store value that is later written inside) is unsupported/));
+
+test("read-only derived stores keep a live root alias without path writers or pending probes", t =>
+ differential(t, `import { createSignal, createStore } from "solid-js";
+export function Counter() {
+ const [id, setId] = createSignal(1);
+ const [state, setState] = createStore((): { a: number; b: number } => ({ a: id(), b: id() * 10 }));
+ const alias = state;
+ return {state.a}/{state.b}|{alias.a}/{alias.b}
+
setId(id() + 1)}>
+
;
+}`, `source id := 1;
+source state = { a: id, b: id * 10 };
+alias = state;
+export default component App() { {state.a}/{state.b}|{alias.a}/{alias.b}
+
{ id := id + 1; }}>
+
}`, [{ click: 0 }, { click: 0 }], [
+ "1/10|1/10", "2/20|2/20", "3/30|3/30",
+ ]));
+
+test("derived store result annotations do not invent purity for an imported loader", () => {
+ const build = (contract: string) => buildTsx("main.tsx", path => path === "main.tsx" ? asyncSource
+ : path === "api.d.rx" ? contract : undefined, { target: "universal", mount: "Counter" });
+ assert.doesNotThrow(() => build(declarations));
+ assert.throws(() => build(declarations.replace("export pure fn fetchState", "export fn fetchState")),
+ (error: unknown) => error instanceof CheckError && error.diagnostics.some(diagnostic => diagnostic.code === "IO_IN_REACTIVE"));
+});
diff --git a/tsx-frontend/tsx/effect-semantics.test.ts b/tsx-frontend/tsx/effect-semantics.test.ts
new file mode 100644
index 0000000..abf82f1
--- /dev/null
+++ b/tsx-frontend/tsx/effect-semantics.test.ts
@@ -0,0 +1,261 @@
+import { test } from "node:test";
+import { runtimeFiles } from "./rx/runtime.ts";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { createRoot, createSignal, createMemo, createEffect } from "solid-js";
+import type { Decl, Expr, Markup, Program, Stmt } from "./rx/ast.ts";
+import { check } from "./rx/compiler.ts";
+import { Runtime } from "./rx/runtime.ts";
+import { compile } from "./rx/compiler.ts";
+import { lowerViews, STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+
+type Options = { memo?: boolean; cleanup?: boolean; previous?: boolean; pending?: boolean; errors?: boolean; fresh?: boolean; initial?: number; lifecycle?: boolean };
+type Step = { n: number } | { land: boolean };
+type Trace = { frames: string[]; log: string[]; phases: string[][] };
+type Packet = "none" | "identity";
+const steps: Step[] = [1, 2, 3, 4].map(n => ({ n }));
+const every = ["0/1", "1/2", "2/3", "3/4", "4/5"];
+const changed = ["0/1", "1/1", "2/1", "3/2", "4/2"];
+const id = (name: string): Expr => ({ k: "id", name });
+const lit = (v: unknown): Expr => ({ k: "lit", v });
+const member = (o: Expr, p: string): Expr => ({ k: "member", o, p });
+const call = (name: string, ...args: Expr[]): Expr => ({ k: "call", f: id(name), args });
+const bin = (op: string, l: Expr, r: Expr): Expr => ({ k: "bin", op, l, r });
+const log = (...args: Expr[]): Stmt => ({ k: "expr", e: call("log", ...args) });
+const settle = async () => { await new Promise(resolve => setTimeout(resolve, 0)); flush(); await new Promise(resolve => setTimeout(resolve, 0)); flush(); };
+
+async function native(options: Options = {}, changes = steps): Promise {
+ const events: string[] = [];
+ const record = (...xs: unknown[]) => events.push(`log(${xs.map(String).join(", ")})`);
+ let land!: (value: boolean) => void;
+ const waiting = new Promise(resolve => { land = resolve; });
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(options.initial ?? 0);
+ const [runs, setRuns] = createSignal(0);
+ let last: unknown = null;
+ const threshold = createMemo(() => n() > 2);
+ const [question] = createSignal(() => n() === 1 ? waiting : n() > 2);
+ const compute = (previous: unknown) => {
+ if (options.errors && n() === 1) throw new Error("boom");
+ if (options.pending) return question();
+ if (options.previous) return ((previous as number | undefined) ?? 0) + n();
+ const value = options.memo ? threshold() : n() > 2;
+ return options.fresh ? { value } : value;
+ };
+ const apply = (value: unknown, previous?: unknown) => {
+ if (options.fresh) { record("apply", (value as { value: boolean }).value, value === last); last = value; }
+ else if (options.previous) record("apply", value, previous); else record("apply", value);
+ if (options.previous) setRuns(value as number); else setRuns(p => p + 1);
+ if (options.cleanup) return () => record("cleanup", value);
+ };
+ createEffect(compute, apply);
+ if (options.lifecycle) {
+ createEffect(n, value => { record("sibling", value); return () => record("sibling-cleanup", value); });
+ createRoot(() => {
+ for (const label of ["child-a", "child-b"])
+ createEffect(n, value => { record(label, value); return () => record(label + "-cleanup", value); });
+ });
+ }
+ return { dispose, setN, frame: () => `${n()}/${runs()}` };
+ });
+ try {
+ flush(); await settle();
+ const frames = [app.frame()]; const phases = [[...events]];
+ for (const change of changes) {
+ if ("n" in change) app.setN(change.n); else land(change.land);
+ flush(); await settle(); frames.push(app.frame()); phases.push([...events]);
+ }
+ return { frames, log: events, phases };
+ } finally { app.dispose(); }
+}
+
+function program(packet: Packet, options: Options = {}): Program {
+ const decls: Decl[] = [
+ { k: "source", name: "n", init: lit(options.initial ?? 0), line: 1 },
+ { k: "source", name: "runs", init: lit(0), line: 2 },
+ ];
+ const threshold = bin(">", id("n"), lit(2));
+ let compute = threshold;
+ if (options.memo) { decls.push({ k: "eq", name: "threshold", e: threshold, line: 3 }); compute = id("threshold"); }
+ if (options.pending) {
+ decls.push({ k: "import", names: [{ name: "fetch", alias: "fetch" }], from: "./prelude", pure: true, fails: [], line: 3 });
+ decls.push({ k: "eq", name: "question", line: 4, e: { k: "await", e: { k: "if",
+ c: bin("==", id("n"), lit(1)), t: call("fetch", lit("pending")), f: threshold,
+ } } });
+ compute = id("question");
+ }
+ if (options.errors) {
+ decls.push({ k: "error", name: "Boom", params: [], msg: lit("boom"), line: 3 });
+ compute = { k: "if", c: bin("==", id("n"), lit(1)), t: { k: "throw", e: call("Boom") }, f: compute };
+ }
+ const previous = member({ k: "pre", name: "packet", init: { k: "rec", fields: [{ key: "value", v: lit(undefined) }] } }, "value");
+ if (options.previous) compute = bin("+", bin("??", previous, lit(0)), id("n"));
+ if (options.fresh) {
+ decls.push({ k: "source", name: "last", init: lit(null), line: 5 });
+ compute = { k: "rec", fields: [{ key: "value", v: compute }] };
+ }
+ let input: Expr = packet === "none" ? compute : { k: "rec", fields: [
+ { key: "value", v: compute },
+ ...(options.previous ? [{ key: "previous", v: previous }] : []),
+ ...(packet === "identity" ? [{ key: "token", v: { k: "fn", params: [], body: lit(undefined) } as Expr }] : []),
+ ] };
+ if (options.previous) { decls.push({ k: "eq", name: "packet", e: input, line: 5 }); input = id("packet"); }
+ const value = packet === "none" ? id("$0") : member(id("$0"), "value");
+ const body: Stmt[] = [{ k: "let", name: "value", e: value }];
+ if (options.fresh) {
+ body.push(log(lit("apply"), member(id("value"), "value"), bin("==", id("value"), id("last"))));
+ body.push({ k: "write", target: { name: "last", path: [] }, e: id("value") });
+ }
+ else if (options.previous) body.push(log(lit("apply"), id("value"), member(id("$0"), "previous")));
+ else body.push(log(lit("apply"), id("value")));
+ body.push({ k: "write", target: { name: "runs", path: [] }, e: options.previous ? id("value") : { k: "fn", params: ["p"], body: bin("+", id("p"), lit(1)) } });
+ if (options.cleanup) body.push({ k: "return", e: { k: "fn", params: [], body: lit(undefined), stmts: [log(lit("cleanup"), id("value"))] } });
+ decls.push({ k: "effect", inputs: [input], body, line: 6 });
+ if (options.lifecycle) {
+ const effect = (label: string): Decl => ({ k: "effect", inputs: [id("n")], line: 7, body: [
+ { k: "let", name: "value", e: id("$0") }, log(lit(label), id("value")),
+ { k: "return", e: { k: "fn", params: [], body: lit(undefined), stmts: [log(lit(label + "-cleanup"), id("value"))] } },
+ ] });
+ decls.push(effect("sibling"), { k: "component", name: "Child", props: "props", decls: [effect("child-a"), effect("child-b")], view: [], line: 8 });
+ }
+ return { decls, view: [{ k: "read", e: id("n") }, { k: "text", s: "/" }, { k: "read", e: id("runs") },
+ ...(options.lifecycle ? [{ k: "el" as const, tag: "Child", attrs: [], children: [] }] : []),
+ ] };
+}
+
+async function backends(p: Program, changes = steps): Promise<{ interpreter: Trace; compiled: Trace }> {
+ assert.deepEqual(check(p), []);
+ const rt = new Runtime(p);
+ const interpreted = [rt.step([]).at(-1)!.frame]; const phases = [[...rt.log]];
+ for (const change of changes) {
+ const frames = "n" in change ? rt.step([{ node: rt.findNode("n")!, path: [], value: change.n }]) : rt.land("question", change.land);
+ interpreted.push(frames.at(-1)!.frame); phases.push([...rt.log]);
+ }
+ rt.root.dispose(); rt.step([]);
+ const interpreter = { frames: interpreted, log: rt.log, phases };
+ const out = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(out, { recursive: true });
+ const dir = mkdtempSync(out + "effect-semantics-");
+ const renderer = runtimeFiles.stringRenderer;
+ try {
+ const code = lowerViews(new Map([["main.mjs", compile(p)]]), "universal").get("main.mjs")!;
+ const path = dir + "/main.mjs";
+ writeFileSync(path, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(path).href);
+ const app = createApp(); const dispose = app.mount(mod.default(app));
+ try {
+ flush(); await settle(); const frames = [app.frame()]; const phases = [[...app.log]];
+ for (const change of changes) {
+ if ("n" in change) app.dset("n", [], change.n); else app.flights.get("pending")!.resolve(change.land);
+ flush(); await settle(); frames.push(app.frame()); phases.push([...app.log]);
+ }
+ return { interpreter, compiled: { frames, log: app.log, phases } };
+ } finally { dispose(); }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+}
+
+test("plain Solid effects rerun for equal computes while RX effects keep EF-2 filtering", async () => {
+ const expected = await native();
+ assert.deepEqual(expected.frames, every);
+ const actual = await backends(program("none"));
+ assert.deepEqual(actual.interpreter.frames, changed);
+ assert.deepEqual(actual.compiled.frames, changed);
+});
+
+test("RX effects over a memo equality cut match Solid's effect", async () => {
+ const expected = await native({ memo: true });
+ assert.deepEqual(expected.frames, changed);
+ const actual = await backends(program("none", { memo: true }));
+ assert.deepEqual(actual.interpreter, expected);
+ assert.deepEqual(actual.compiled, expected);
+});
+
+test("RX effect disposal follows Solid's reverse ownership order across sibling and nested effects", async () => {
+ const options = { cleanup: true, lifecycle: true };
+ const expected = await native(options);
+ const actual = await backends(program("none", options));
+ // EF-2 skips Solid's equal-value reruns, so only the disposal order is compared.
+ const order = ["log(child-b-cleanup, 4)", "log(child-a-cleanup, 4)", "log(sibling-cleanup, 4)", "log(cleanup, true)"];
+ assert.deepEqual(expected.log.slice(-4), order);
+ assert.deepEqual(actual.interpreter.log.slice(-4), order);
+ assert.deepEqual(actual.compiled.log.slice(-4), order);
+});
+
+test("a compute previous parameter uses a named packet equation, not the previous apply result", async () => {
+ const expected = await native({ previous: true, cleanup: true });
+ assert.deepEqual(expected.frames, ["0/0", "1/1", "2/3", "3/6", "4/10"]);
+ const actual = await backends(program("identity", { previous: true, cleanup: true }));
+ assert.deepEqual(actual.interpreter, expected);
+ assert.deepEqual(actual.compiled, expected);
+});
+
+test("RX effects retain cleanup through pending and suppress equal recovery, as Solid does", async () => {
+ const changes: Step[] = [{ n: 1 }, { land: false }, { n: 2 }];
+ const options = { pending: true, cleanup: true };
+ const expected = await native(options, changes);
+ const actual = await backends(program("none", options), changes);
+ assert.deepEqual(expected.frames, ["0/1", "1/1", "1/1", "2/1"]);
+ assert.deepEqual(actual.interpreter, expected);
+ assert.deepEqual(actual.compiled, expected);
+ assert.deepEqual(expected.phases[1], ["log(apply, false)"]);
+ assert.deepEqual(actual.interpreter.phases[1], expected.phases[1]);
+});
+
+test("RX effects apply changed async answers once after pending, as Solid does", async () => {
+ const changes: Step[] = [{ n: 1 }, { land: true }, { n: 2 }];
+ const options = { pending: true, cleanup: true };
+ const expected = await native(options, changes);
+ const actual = await backends(program("none", options), changes);
+ assert.deepEqual(actual.interpreter, expected);
+ assert.deepEqual(actual.compiled, expected);
+});
+
+test("RX effects apply the first async answer after mounting pending, as Solid does", async () => {
+ const changes: Step[] = [{ land: false }, { n: 2 }];
+ const options = { pending: true, cleanup: true, initial: 1 };
+ const expected = await native(options, changes);
+ const actual = await backends(program("none", options), changes);
+ assert.deepEqual(actual.interpreter, expected); assert.deepEqual(actual.compiled, expected);
+});
+
+test("ordinary RX effects still deliver compute errors as answers", async () => {
+ const actual = await backends(program("none", { errors: true }), [{ n: 1 }, { n: 2 }]);
+ assert.deepEqual(actual.interpreter.frames, ["0/1", "1/2", "2/3"]);
+ assert.deepEqual(actual.compiled, actual.interpreter);
+});
+
+for (const nested of [false, true]) test(`${nested ? "nested filtered" : "matching"} boundary payloads update without remounting fallback snapshots and state`, async () => {
+ const error = member(id("props"), "error");
+ const fallback: Decl = { k: "component", name: "Fallback", props: "props", line: 4, decls: [
+ { k: "source", name: "captured", init: { k: "peek", e: member(error, "id") }, line: 5 }, // a mount snapshot (E-4)
+ { k: "source", name: "local", init: lit(10), line: 6 },
+ { k: "effect", inputs: [id("captured")], line: 7, body: [
+ log(lit("enter"), id("captured")),
+ { k: "write", target: { name: "local", path: [] }, e: { k: "fn", params: ["p"], body: bin("+", id("p"), lit(1)) } },
+ { k: "return", e: { k: "fn", params: [], body: lit(undefined), stmts: [log(lit("leave"), id("captured"))] } },
+ ] },
+ ], view: [
+ { k: "read", e: member(error, "id") }, { k: "text", s: "/" },
+ { k: "read", e: id("captured") }, { k: "text", s: "/" }, { k: "read", e: id("local") },
+ ] };
+ const result: Expr = { k: "if", c: id("n"), t: { k: "throw", e: call("Missing", id("n")) }, f: lit("ok") };
+ const content: Markup[] = [{ k: "read", e: id("result") }];
+ const p: Program = { decls: [
+ { k: "error", name: "Missing", params: ["id"], msg: bin("+", lit("Missing "), id("id")), line: 1 },
+ { k: "error", name: "Denied", params: [], msg: lit("Denied"), line: 1 },
+ { k: "source", name: "n", init: lit(0), line: 2 },
+ { k: "eq", name: "result", e: nested ? { k: "if", c: bin("==", id("n"), lit(4)), t: { k: "throw", e: call("Denied") }, f: result } : result, line: 3 },
+ fallback,
+ ], view: [{
+ k: "errored", classes: ["Missing"], errName: "err", decls: [],
+ fallback: [{ k: "el", tag: "Fallback", attrs: [{ name: "error", e: id("err") }], children: [] }],
+ body: nested ? [{ k: "errored", classes: ["Denied"], errName: "other", decls: [], body: content, fallback: [{ k: "read", e: member(id("other"), "message") }] }] : content,
+ }] };
+ const changes = (nested ? [1, 2, 0, 4, 5] : [1, 2, 0, 3]).map(n => ({ n }));
+ const actual = await backends(p, changes);
+ assert.deepEqual(actual.interpreter.frames, nested ? ["ok", "1/1/11", "2/1/11", "ok", "Denied", "5/5/11"] : ["ok", "1/1/11", "2/1/11", "ok", "3/3/11"]);
+ assert.deepEqual(actual.interpreter.log, nested ? ["log(enter, 1)", "log(leave, 1)", "log(enter, 5)", "log(leave, 5)"] : ["log(enter, 1)", "log(leave, 1)", "log(enter, 3)", "log(leave, 3)"]);
+ assert.deepEqual(actual.compiled, actual.interpreter);
+});
diff --git a/tsx-frontend/tsx/effects.ts b/tsx-frontend/tsx/effects.ts
new file mode 100644
index 0000000..a4e9a0f
--- /dev/null
+++ b/tsx-frontend/tsx/effects.ts
@@ -0,0 +1,45 @@
+import { createEffect, createMemo, createSignal, NotReadyError, untrack, type Accessor } from "solid-js";
+import { createErrorBoundary } from "@solidjs/signals";
+import { isServer } from "@solidjs/web";
+
+/** E-12's error answer, shared by every failed input (not the thrown exception). */
+export const RX_ERROR = Object.freeze({ toString: () => "#error" });
+export type RxEffectError = typeof RX_ERROR;
+type Answers = { -readonly [I in keyof Inputs]: Awaited | RxEffectError };
+
+/** RX EF-2/E-12 semantics; ordinary Solid createEffect deliberately remains unchanged. */
+export function createRxEffect(
+ inputs: { readonly [I in keyof Inputs]: Accessor },
+ apply: (...values: Answers>) => void | (() => void),
+): void {
+ if (isServer) {
+ const values = createMemo(() => inputs.map(input => {
+ try { return input(); } catch (error) { if (error instanceof NotReadyError) throw error; return RX_ERROR; }
+ }));
+ createEffect(values, { effect() {}, error() {} });
+ return;
+ }
+ const waiting = Symbol();
+ const computes = inputs.map(input => {
+ // Client-only plumbing must not consume the server tuple/effect's hydration slots.
+ const compute = createMemo(input, { equals: Object.is, transparent: true });
+ const [answer, setAnswer] = createSignal(waiting, { equals: Object.is });
+ // Error arms publish settled answers; boundary redraws alone can briefly expose stale values.
+ createEffect(compute, {
+ effect: value => { setAnswer(() => value); },
+ error: () => { setAnswer(RX_ERROR); },
+ }, { transparent: true });
+ return { compute, answer };
+ });
+ const values = createMemo(() => computes.map(({ compute, answer }) => {
+ // Suspend the tuple on pending. A record protects function/array answers from view flattening.
+ createErrorBoundary(() => ({ value: compute() }), () => ({ value: RX_ERROR }))();
+ return answer();
+ }), {
+ equals: (previous, next) => previous.every((value, i) => Object.is(value, next[i])),
+ });
+ // Filter before the effect phase: a skipped answer must not dispose the previous cleanup.
+ createEffect(values, next => {
+ if (!next.includes(waiting)) return untrack(() => apply(...next as Answers));
+ });
+}
diff --git a/tsx-frontend/tsx/failure-identities.test.ts b/tsx-frontend/tsx/failure-identities.test.ts
new file mode 100644
index 0000000..8cfe6d2
--- /dev/null
+++ b/tsx-frontend/tsx/failure-identities.test.ts
@@ -0,0 +1,193 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { loadModules } from "./rx/compiler.ts";
+import { checkModules } from "./rx/compiler.ts";
+import { printSignature } from "./rx/compiler.ts";
+import { Runtime } from "./rx/runtime.ts";
+import { compileModules } from "./rx/compiler.ts";
+import { lowerViews, STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+
+const types = 'export error Missing(id) => "missing"';
+const reader = 'import { Missing as Absent } from "./types.rx"; export value = throw Absent(1); export component Reader() { {value}
}';
+
+function checked(files: Record) {
+ const set = loadModules("main.rx", path => files[path]);
+ return { set, ...checkModules(set) };
+}
+
+async function trace(files: Record, expected: string) {
+ const { set, diags } = checked(files);
+ assert.deepEqual(diags, []);
+ assert.equal(new Runtime(set).step([]).at(-1)!.frame, expected);
+ const built = compileModules(set);
+ const output = lowerViews(built.files, "universal");
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "failure-identities-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ try {
+ for (const [name, text] of output) writeFileSync(dir + "/" + name, text.replaceAll(STRING_RENDERER, renderer));
+ const module = await import(pathToFileURL(dir + "/" + built.entry).href);
+ const app = createApp();
+ const dispose = app.mount(module.default(app));
+ try { flush(); assert.equal(app.frame(), expected); } finally { dispose(); }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+}
+
+test("RX imported component failures and aliased boundaries share declaration identity", async () => {
+ const files = {
+ "types.rx": types,
+ "reader.rx": reader,
+ "main.rx": 'import { Missing as Lost } from "./types.rx"; import { Reader } from "./reader.rx"; export default component App() { errored fallback(e: Lost) { {e.message}
} { } }',
+ };
+ const { diags, sigs } = checked(files);
+ assert.deepEqual(diags, []);
+ const error = sigs.get("types.rx")!.exports.get("Missing")!;
+ const component = sigs.get("reader.rx")!.exports.get("Reader")!;
+ const value = sigs.get("reader.rx")!.exports.get("value")!;
+ assert.equal(error.kind, "errclass");
+ assert.equal(component.kind, "comp");
+ assert.equal(value.kind, "eq");
+ if (error.kind !== "errclass" || component.kind !== "comp" || value.kind !== "eq") return;
+ assert.deepEqual(value.fails, [error.key]);
+ assert.deepEqual(component.sig.own.map(x => x.cls), [error.key]);
+ assert.match(printSignature(sigs.get("reader.rx")!), /fails\(Missing\)/);
+ assert.doesNotMatch(printSignature(sigs.get("reader.rx")!), /types\.rx#|Absent/);
+ await trace(files, "missing");
+});
+
+test("RX expression catches resolve aliases from a value signature", async () => {
+ await trace({
+ "types.rx": types,
+ "reader.rx": reader,
+ "main.rx": 'import { Missing as Lost } from "./types.rx"; import { value } from "./reader.rx"; export default component App() { {value catch (e: Lost) => "caught"}
}',
+ }, "caught");
+});
+
+test("RX component prop and children catch summaries retain class identity", async () => {
+ const { sigs, diags } = checked({
+ "types.rx": types,
+ "safe.rx": 'import { Missing as Absent } from "./types.rx"; export component Safe(props) { errored fallback(e: Absent) { safe
} { {props.children} } }',
+ "main.rx": 'import { Missing as Lost } from "./types.rx"; import { Safe } from "./safe.rx"; value = throw Lost(1); export default component App() { {value}
}',
+ });
+ assert.deepEqual(diags, []);
+ assert.match(printSignature(sigs.get("safe.rx")!), /children: catches\(Missing\)/);
+ await trace({
+ "types.rx": types,
+ "safe.rx": 'import { Missing as Absent } from "./types.rx"; export component Safe(props) { {props.text catch (e: Absent) => "safe"}
}',
+ "main.rx": 'import { Missing as Lost } from "./types.rx"; import { Safe } from "./safe.rx"; value = throw Lost(1); export default component App() { }',
+ }, "safe");
+});
+
+test("same-named classes in separate RX modules do not discharge each other", async () => {
+ const files = {
+ "types.rx": types,
+ "other.rx": types,
+ "reader.rx": reader,
+ "main.rx": 'import { Missing } from "./other.rx"; import { Reader } from "./reader.rx"; export default component App() { errored fallback(e: Missing) { wrong
} { } }',
+ };
+ const { diags } = checked(files);
+ assert.deepEqual(diags.map(d => d.code), ["UNHANDLED_FAILURE"]);
+ assert.match(diags[0].msg, /can fail with Missing/);
+ assert.doesNotMatch(diags[0].msg, /types\.rx#/);
+ await trace({ ...files, "main.rx": 'import { Missing } from "./other.rx"; import { Reader } from "./reader.rx"; export default component App() { errored fallback(e: Error) { outer
} { errored fallback(e: Missing) { wrong
} { } } }' }, "outer");
+});
+
+test("nested shadowed RX error declarations have distinct identities", async () => {
+ const decls = 'error Missing(); component Reader() { error Missing(); value = throw Missing(); {value}
}';
+ const body = 'export default component App() { errored fallback(e: Missing) { wrong
} { } }';
+ assert.deepEqual(checked({ "main.rx": decls + body }).diags.map(d => d.code), ["UNHANDLED_FAILURE"]);
+ await trace({ "main.rx": decls + 'errored fallback(e: Error) { outer
} { ' + body + " }" }, "outer");
+});
+
+test("same class imported under two aliases matches typed action catches and rethrows", () => {
+ const { diags } = checked({
+ "types.rx": types,
+ "main.rx": 'import { Missing as First, Missing as Second } from "./types.rx"; action run() { try { try { throw First(1) } catch (e: Second) { throw e } } catch (e: First) { } } export default component App() { { run() }}>run }',
+ });
+ assert.deepEqual(diags, []);
+});
+
+test("RX rethrows retain every identity from a union catch", () => {
+ const { diags } = checked({
+ "types.rx": types + "; export error Other()",
+ "main.rx": 'import { Missing as Lost, Other as Else } from "./types.rx"; source choose := true; value = choose ? throw Lost(1) : throw Else(); result = value catch (e: Lost | Else) => throw e; export default component App() { {result catch (e: Lost | Else) => "caught"}
}',
+ });
+ assert.deepEqual(diags, []);
+});
+
+test("JS fails clauses and exported fn rejection rows resolve forward RX aliases", () => {
+ const { diags, sigs } = checked({
+ "types.rx": types,
+ "request.rx": 'import pure { fetch } from "./api" fails Absent; import { Missing as Absent } from "./types.rx"; export fn request() => fetch("item")',
+ "main.rx": 'import { Missing as Lost } from "./types.rx"; import { request } from "./request.rx"; value = await request(); export default component App() { {value catch (e: Lost) => "caught"}
}',
+ });
+ assert.deepEqual(diags, []);
+ assert.match(printSignature(sigs.get("request.rx")!), /rejects\(Missing\)/);
+ assert.doesNotMatch(printSignature(sigs.get("request.rx")!), /types\.rx#|Absent/);
+});
+
+test("server function and action signatures print parameters and readable rejection identities", () => {
+ const { diags, sigs } = checked({
+ "types.rx": types,
+ // Pinned SF-1: one `server fn` form (the old `server action` spelling is gone); the printer marks purity itself.
+ "main.rx": 'import { Missing as Lost } from "./types.rx"; export server fn read(id) { throw Lost(id) } export server fn save(id, next) { throw Lost(next) } export server fn ping() { return 1 }',
+ });
+ assert.deepEqual(diags, []);
+ const text = printSignature(sigs.get("main.rx")!);
+ assert.deepEqual(text.trimEnd().split("\n").slice(1), [
+ // Readable identities name the declared class (Missing), never the importer's local alias (Lost).
+ "export server pure fn read(id) rejects(Error | Missing)",
+ "export server pure fn save(id, next) rejects(Error | Missing)",
+ "export server pure fn ping() rejects(Error)",
+ ]);
+ assert.doesNotMatch(text, /types\.rx#|Lost|undefined/);
+});
+
+test("declared JS errors match aliases and declaration-local rejects through RX signatures", () => {
+ const files = {
+ "api.d.rx": "export error Missing\nexport pure fn query() rejects(Missing)\n",
+ "request.rx": 'import { query } from "./api.js"; export fn request() => query()',
+ "main.rx": 'import { Missing as Lost } from "./api.js"; import { request } from "./request.rx"; value = await request(); export default component App() { {value catch (e: Lost) => "caught"}
}',
+ };
+ assert.deepEqual(checked(files).diags, []);
+ assert.deepEqual(checked({ ...files, "main.rx": 'import { Missing as First, Missing as Second } from "./api.js"; value = throw First(1); export default component App() { {value catch (e: Second) => "caught"}
}' }).diags, []);
+ assert.deepEqual(checked({ ...files, "other.d.rx": "export error Missing\n", "main.rx": 'import { Missing } from "./other.js"; import { request } from "./request.rx"; value = await request(); export default component App() { {value catch (e: Missing) => "wrong"}
}' }).diags.map(d => d.code), ["UNHANDLED_FAILURE"]);
+});
+
+test("unknown JS promise defaults remain Error and declared io calls stay io", () => {
+ const main = 'import { Missing as Lost } from "./types.rx"; import pure { query } from "./unknown.js"; value = await query(); export default component App() { {value catch (e: Lost) => "wrong"}
}';
+ const { diags } = checked({ "types.rx": types, "main.rx": main });
+ assert.deepEqual(diags.map(d => d.code), ["UNHANDLED_FAILURE"]);
+ assert.match(diags[0].msg, /can fail with Error/);
+ assert.deepEqual(checked({ "types.rx": types, "main.rx": main.replace("e: Lost", "e: Error") }).diags, []);
+ const io = checked({
+ "api.d.rx": "export error Missing\nexport fn query() rejects(Missing)\n",
+ "main.rx": 'import { query, Missing as Lost } from "./api.js"; value = await query(); export default component App() { {value catch (e: Lost) => "caught"}
}',
+ }).diags;
+ assert.ok(io.some(d => d.code === "IO_IN_REACTIVE"));
+ assert.ok(!io.some(d => d.code === "UNHANDLED_FAILURE"));
+});
+
+test("Panic boundaries do not discharge declared failure identities", () => {
+ const { diags } = checked({
+ "types.rx": types,
+ "reader.rx": reader,
+ "main.rx": 'import { Reader } from "./reader.rx"; export default component App() { errored fallback(e: Panic) { panic
} { } }',
+ });
+ assert.deepEqual(diags.map(d => d.code), ["UNHANDLED_FAILURE"]);
+});
+
+test("written fails annotations and context contracts resolve imported class identities", () => {
+ const files = {
+ "types.rx": types,
+ "context.rx": 'import { Missing as Absent } from "./types.rx"; export context result: fails(Absent) string',
+ "main.rx": 'import { Missing as Lost } from "./types.rx"; import { result } from "./context.rx"; value: fails(Lost) string = throw Lost(1); provide result = value; export default component App() { {use result catch (e: Lost) => "caught"}
}',
+ };
+ assert.deepEqual(checked(files).diags, []);
+ const wrong = checked({ ...files, "other.rx": types, "main.rx": 'import { Missing as Lost } from "./other.rx"; import { result } from "./context.rx"; value = throw Lost(1); provide result = value; export default component App() { {use result catch (e: Error) => "caught"}
}' }).diags;
+ assert.deepEqual(wrong.map(d => d.code), ["ROW_NARROWER_THAN_INFERRED"]);
+});
diff --git a/tsx-frontend/tsx/features.test.ts b/tsx-frontend/tsx/features.test.ts
new file mode 100644
index 0000000..882edd9
--- /dev/null
+++ b/tsx-frontend/tsx/features.test.ts
@@ -0,0 +1,1318 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ readFileSync,
+ mkdirSync,
+ mkdtempSync,
+ writeFileSync,
+ rmSync,
+} from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+import { traces, snapshotSource } from "./test-utils.ts";
+import { lowerTsx, TsxError } from "./lower.ts";
+import { parseProgram } from "./rx/compiler.ts";
+import { parseDeclarations } from "./rx/compiler.ts";
+import { buildTsx } from "./build.ts";
+import { loadTsxModules } from "./modules.ts";
+import { Runtime } from "./rx/runtime.ts";
+import type { Decl } from "./rx/ast.ts";
+const fixture = (name: string) =>
+ readFileSync(new URL("./fixtures/" + name, import.meta.url), "utf8");
+
+test("native and emitted async handlers expose pre-await writes and return their Promise result", async () => {
+ const source =
+ 'import {createSignal} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);return =>{setN(1);await 0;setN(2);return 3}}>{n()} }';
+ const built = buildTsx(
+ "main.tsx",
+ (path) => (path === "main.tsx" ? source : undefined),
+ { target: "universal", mount: "Counter" },
+ );
+ const out = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(out, { recursive: true });
+ const dir = mkdtempSync(out + "tsx-async-handler-");
+ const renderer = fileURLToPath(
+ new URL("./test-renderer.ts", import.meta.url),
+ );
+ try {
+ for (const name of ["native.mts", "main.mjs"]) {
+ const code =
+ name === "native.mts"
+ ? transform(source, {
+ filename: "Counter.tsx",
+ generate: "universal",
+ moduleName: STRING_RENDERER,
+ }).code
+ : built.files.get(name)!;
+ writeFileSync(
+ dir + "/" + name,
+ code.replaceAll(STRING_RENDERER, renderer),
+ );
+ const mod = await import(pathToFileURL(dir + "/" + name).href);
+ const app = createApp();
+ const dispose = app.mount(
+ name === "native.mts" ? () => mod.Counter() : mod.default(app),
+ );
+ try {
+ flush();
+ assert.equal(app.frame(), "0", name);
+ const button = (node: any): any =>
+ node.tag === "button"
+ ? node
+ : node.children?.map(button).find(Boolean);
+ const done = button(app.root).props.onClick();
+ assert.ok(done instanceof Promise, name + " must return a Promise");
+ flush();
+ assert.equal(
+ app.frame(),
+ "1",
+ name + " writes before await must not be held",
+ );
+ assert.equal(await done, 3, name + " must preserve its returned value");
+ flush();
+ assert.equal(app.frame(), "2", name);
+ } finally {
+ dispose();
+ }
+ }
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("structured optimistic roots roll back across suspended actions without store conversion", async () => {
+ const source =
+ 'import {createSignal,createOptimistic,action,untrack} from "solid-js";import {request} from "./api";export function Counter(){const [cart,setCart]=createOptimistic([1,2]);const captured=untrack(()=>cart());const [status,setStatus]=createSignal("");const [orders,setOrders]=createSignal(0);const run=action(function*(){setCart([]);setStatus("placing");try{yield request()}catch(error){setStatus("failed")}finally{setOrders(p=>p+1)}});return run()}>{cart().length}/{captured.length}/{status()}/{orders()} }';
+ const files: Record = {
+ "main.tsx": source,
+ "api.d.rx": "export fn request(): number",
+ };
+ const built = buildTsx("main.tsx", (path) => files[path], {
+ target: "universal",
+ mount: "Counter",
+ });
+ const expected = ["2/2//0", "0/2//0", "2/2/failed/1"];
+ const rt = new Runtime(built.set);
+ const interpreted = [rt.step([]).at(-1)!.frame];
+ interpreted.push(
+ rt
+ .handler([
+ { k: "expr", e: { k: "call", f: { k: "id", name: "run" }, args: [] } },
+ ])
+ .at(-1)!.frame,
+ );
+ interpreted.push(
+ rt.land('"request:"', new Error("offline"), true).at(-1)!.frame,
+ );
+ assert.deepEqual(interpreted, expected, "interpreter");
+ const out = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(out, { recursive: true });
+ const dir = mkdtempSync(out + "tsx-optimistic-roots-");
+ const renderer = fileURLToPath(
+ new URL("./test-renderer.ts", import.meta.url),
+ );
+ try {
+ writeFileSync(
+ dir + "/api.mjs",
+ "export const request = () => globalThis.__tsxOptimisticRequest();",
+ );
+ for (const name of ["native.mts", "main.mjs"]) {
+ let reject!: (error: Error) => void;
+ const request = () =>
+ new Promise((_, no) => {
+ reject = no;
+ });
+ (globalThis as any).__tsxOptimisticRequest = request;
+ const code =
+ name === "native.mts"
+ ? transform(source, {
+ filename: "Counter.tsx",
+ generate: "universal",
+ moduleName: STRING_RENDERER,
+ }).code.replaceAll('"./api"', '"./api.mjs"')
+ : built.files.get(name)!;
+ writeFileSync(
+ dir + "/" + name,
+ code.replaceAll(STRING_RENDERER, renderer),
+ );
+ const mod = await import(pathToFileURL(dir + "/" + name).href);
+ const app = createApp({ api: { request } });
+ const dispose = app.mount(
+ name === "native.mts" ? () => mod.Counter() : mod.default(app),
+ );
+ try {
+ flush();
+ const frames = [app.frame()];
+ const button = (node: any): any =>
+ node.tag === "button"
+ ? node
+ : node.children?.map(button).find(Boolean);
+ const done = button(app.root).props.onClick();
+ flush();
+ frames.push(app.frame());
+ reject(new Error("offline"));
+ await done;
+ flush();
+ frames.push(app.frame());
+ assert.deepEqual(frames, expected, name);
+ } finally {
+ dispose();
+ }
+ }
+ } finally {
+ delete (globalThis as any).__tsxOptimisticRequest;
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("authored RX effects use tuple samples and run returned cleanup before replacement", async () => {
+ const source =
+ 'import {createSignal} from "solid-js";import {createRxEffect} from "reactivescript-tsx";export function Counter(){const [n,setN]=createSignal(0);const [seen,setSeen]=createSignal(0);const [cleanups,setCleanups]=createSignal(0);createRxEffect([()=>n(),()=>n()*2],(first,second)=>{setSeen(first+second);return ()=>{setCleanups(p=>p+1)}});return setN(n()+1)}>{n()}/{seen()}/{cleanups()} }';
+ const rx =
+ 'export component Counter(){source n := 0;source seen := 0;source cleanups := 0;effect(n,n*2){seen := $0+$1;return ()=>{cleanups := p=>p+1}}{n := n+1}}>{""+n}/{""+seen}/{""+cleanups} }';
+ await traces(source, rx, ["0/0/0", "1/3/1", "2/6/2"]);
+});
+
+test("authored RX effects filter equal computed inputs without changing Solid effects", async () => {
+ const source =
+ 'import {createSignal} from "solid-js";import {createRxEffect} from "reactivescript-tsx";export function Counter(){const [n,setN]=createSignal(0);const [calls,setCalls]=createSignal(0);createRxEffect([()=>n()%2],sample=>{setCalls(p=>p+1)});return setN(n()+2)}>{n()}/{calls()} }';
+ const rx =
+ 'export component Counter(){source n := 0;source calls := 0;effect(n%2){calls := p=>p+1}{n := n+2}}>{""+n}/{""+calls} }';
+ await traces(source, rx, ["0/1", "2/1", "4/1"]);
+});
+
+test("RX effects retain tuple samples and default scheduling in the shared AST", () => {
+ const source =
+ 'import {createSignal} from "solid-js";import {createRxEffect} from "reactivescript-tsx";export function Counter(){const [n,setN]=createSignal(0);const [seen,setSeen]=createSignal(0);createRxEffect([()=>n()%2,()=>n()*2],(parity,total)=>{setSeen(parity+total);return ()=>{setSeen(0)}});return setN(n()+1)}>{seen()} }';
+ const program = lowerTsx(source);
+ const counter = program.decls.find((d) => d.k === "component")!;
+ assert.equal(counter.k, "component");
+ if (counter.k !== "component") return;
+ const effect = counter.decls.find((d) => d.k === "effect")!;
+ assert.equal(effect.k, "effect");
+ if (effect.k !== "effect") return;
+ assert.deepEqual(Object.keys(effect).sort(), ["body", "inputs", "k", "line"]); // RX's one effect form: no extra fields
+ assert.equal(effect.inputs.length, 2);
+ assert.deepEqual(effect.body[0], {
+ k: "write",
+ target: { name: "seen", path: [], ctx: false },
+ e: {
+ k: "bin",
+ op: "+",
+ l: { k: "id", name: "$0" },
+ r: { k: "id", name: "$1" },
+ },
+ });
+ assert.equal(effect.body[1].k, "return");
+ assert.throws(
+ () => lowerTsx(source.replace("[()=>n()%2,()=>n()*2]", "()=>n()")),
+ /literal array/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("(parity,total)", "(parity)")),
+ /2 parameter/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("return ()=>{setSeen(0)}", "return 1")),
+ /only a synchronous zero-argument cleanup or undefined/,
+ );
+});
+
+test("non-self previous preserves lazy fallback tracking and distinguishes null from undefined", async () => {
+ const source =
+ 'import {createSignal,createMemo} from "solid-js";import {createPrevious} from "reactivescript-tsx";export function Counter(){const [n,setN]=createSignal(0);const [seed,setSeed]=createSignal(10);const [step,setStep]=createSignal(0);const double=createMemo(()=>n()*2);const previous=createPrevious(double,()=>seed());return {if(step()===0)setSeed(20);else if(step()===1)setN(1);else if(step()===2)setSeed(30);else setN(2);setStep(step()+1)}}>{previous()}/{double()} }';
+ await traces(source, lowerTsx(source), ["10/0", "20/0", "0/2", "0/2", "2/4"]);
+ const nullable =
+ 'import {createSignal,createMemo} from "solid-js";import {createPrevious} from "reactivescript-tsx";export function Counter(){const [n,setN]=createSignal(0);const current=createMemo(()=>n()===1 ? null : n());const previous=createPrevious(current,()=>-1);return setN(n()+1)}>{previous()}/{current()} }';
+ await traces(nullable, lowerTsx(nullable), ["-1/0", "0/", "/2", "2/3"]);
+});
+
+test("same-setup previous accessors can be passed into later-mounted owners", async () => {
+ const source =
+ 'import {createSignal,createMemo,Show} from "solid-js";import {createPrevious} from "reactivescript-tsx";function Panel(props:{current:number}){return {props.current} }export function Counter(){const [n,setN]=createSignal(0);const double=createMemo(()=>n()*2);const previous=createPrevious(double,()=>-1);return setN(n()+1)}>{n()}/=2} fallback={hidden }> }';
+ await traces(source, lowerTsx(source), [
+ "0/hidden",
+ "1/hidden",
+ "2/2",
+ "3/4",
+ ]);
+});
+
+test("array map preserves result shapes and caller tracking in memos and root updaters", async () => {
+ const source =
+ 'import {createSignal,createStore,createMemo} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const [rows,setRows]=createStore([1,2]);const records=createMemo(()=>rows.map(value=>({id:value,name:"v"+(value+n())})));const labels=createMemo(()=>rows.map(value=>"n"+(value+n())));const numbers=createMemo(()=>rows.map(value=>value+n()));return {setN(n()+1);setRows(previous=>previous.map(value=>value+1))}}>{records()[0]?.name}/{labels()[1]}/{numbers()[0]} }';
+ await traces(source, lowerTsx(source), ["v1/n2/1", "v3/n4/3", "v5/n6/5"]);
+});
+
+test("map refuses unsupported callback arities and projection-root snapshots", () => {
+ const prefix =
+ 'import {createSignal,createProjection} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const projected=createProjection(d=>{d.count=n()},{count:0});';
+ for (const expression of [
+ "[1].map(item=>projected)",
+ "[projected].map(root=>root)",
+ "[1].map(item=>({root:projected}))",
+ ]) {
+ assert.throws(
+ () =>
+ lowerTsx(prefix + "const snapshot=" + expression + ";return }"),
+ /projection-root proxies require live alias/,
+ );
+ }
+ assert.doesNotThrow(() =>
+ lowerTsx(
+ prefix +
+ "const snapshot=[projected].map(root=>root.count);return {snapshot[0]} }",
+ ),
+ );
+ for (const callback of [
+ "()=>1",
+ "(item,index)=>item+index",
+ "async item=>item",
+ ]) {
+ assert.throws(
+ () =>
+ lowerTsx(
+ prefix + "const snapshot=[1].map(" + callback + ");return }",
+ ),
+ /synchronous arrow with 1 parameter/,
+ );
+ }
+});
+
+test("typed store root updaters compose array spreads and tracked predicates", async () => {
+ const source =
+ 'import {createStore} from "solid-js";export function Counter(){const [rows,setRows]=createStore([0,1]);return setRows(previous=>previous.length===3 ? previous.filter(value=>value%2===0) : [...previous,previous.length])}>{rows.length}/{rows.every(value=>value%2===0)?"even":"mixed"} }';
+ const rx =
+ 'export component Counter(){source rows := [0,1];{rows := previous=>previous.length==3 ? filter(previous,value=>value%2==0):[...previous,previous.length]}}>{""+rows.length}/{if every(rows,value=>value%2==0) {{""+"even"}}else{{""+"mixed"}}} }';
+ await traces(source, rx, ["2/mixed", "3/mixed", "2/even", "3/even"]);
+});
+
+test("module sources and memos share component setup lowering", async () => {
+ const tsx =
+ 'import {createSignal,createMemo} from "solid-js";const [n,setN]=createSignal(0);const doubled=createMemo(()=>n()*2);export function Counter(){return setN(n()+1)}>{n()}/{doubled()} }';
+ const rx =
+ 'source n := 0;doubled = n * 2;export component Counter(){{n := n + 1}}>{"" + n}/{"" + doubled} }';
+ await traces(tsx, rx, ["0/0", "1/2", "2/4"]);
+});
+
+test("typed generator action arguments are passed through forwarding handlers", async () => {
+ const tsx =
+ 'import {createSignal,action} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const add=action(function*(step:number){setN(p=>p+step)});return add(2)}>{n()} }';
+ const rx =
+ 'export component Counter(){source n := 0;action add(step){n := p => p + step}{add(2)}}>{"" + n} }';
+ await traces(tsx, rx, ["0", "2", "4"]);
+ assert.throws(
+ () => lowerTsx(tsx.replace("add(2)", "add(true)")),
+ /argument.*type/,
+ );
+ assert.throws(
+ () => lowerTsx(tsx.replace("()=>add(2)", "add")),
+ /forwarding handler/,
+ );
+});
+
+test("deferred scalar computations use the shared deferred equation", async () => {
+ const tsx =
+ 'import {createSignal,createDeferred} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const lag=createDeferred(()=>n()*2);return setN(n()+1)}>{n()}/{lag()} }';
+ const rx =
+ 'export component Counter(){source n := 0;deferred lag = n * 2;{n := n + 1}}>{"" + n}/{"" + lag} }';
+ await traces(tsx, rx, ["0/0", "1/2", "2/4"]);
+});
+
+test("typed children slots forward live markup without stringification", async () => {
+ const tsx =
+ 'import {createSignal,type JSX} from "solid-js";function Frame(props:{children:JSX.Element}){return {props.children}
}function Forward(props:{children:JSX.Element}){return {props.children}}export function Counter(){const [n,setN]=createSignal(0);return setN(n()+1)}>value {n()} }';
+ const rx =
+ 'component Frame(props: { children: JSX.Element }){{props.children}
}component Forward(props: { children: JSX.Element }){ {props.children}}export component Counter(){source n := 0;{n := n + 1}}>value {"" + n} }';
+ await traces(tsx, rx, ["value 0", "value 1", "value 2"]);
+ assert.throws(
+ () =>
+ lowerTsx(
+ tsx.replace(
+ "return {props.children}
",
+ "const saved=props.children;return {saved}
",
+ ),
+ ),
+ /snapshots/,
+ );
+});
+
+for (const local of [false, true])
+ test(
+ (local ? "local" : "module") +
+ " accessor-returning ref helpers instantiate once",
+ async () => {
+ const helper =
+ "function useDouble(input:()=>number):()=>number{const initial=untrack(()=>input());const doubled=createMemo(()=>input()*2+initial);return doubled}";
+ const tsx =
+ 'import {createSignal,createMemo,untrack} from "solid-js";' +
+ (local ? "" : helper) +
+ "export function Counter(){" +
+ (local ? helper : "") +
+ "const [n,setN]=createSignal(1);const result=useDouble(n);return setN(n()+1)}>{n()}/{result()} }";
+ const helperRx =
+ "fn useDouble(ref input: number): number {source initial := peek(input);doubled = input * 2 + initial;doubled}";
+ const rx = parseProgram(
+ (local ? "" : helperRx) +
+ "export component Counter(){source n := 1;result = useDouble(n);" +
+ (local ? helperRx : "") +
+ '{n := n + 1}}>{"" + n}/{"" + result} }',
+ );
+ await traces(tsx, rx, ["1/3", "2/5", "3/7"]);
+ assert.throws(
+ () => lowerTsx(tsx.replace("useDouble(n)", "useDouble(n())")),
+ /original accessor/,
+ );
+ },
+ );
+
+test("keyed projections preserve row snapshots through reorder and replacement", async () => {
+ const tsx =
+ 'import {createSignal,createProjection,For,untrack} from "solid-js";function Row(props:{row:{id:number,value:string}}){const captured=untrack(()=>props.row.value);return {props.row.id}:{props.row.value}/{captured}; }export function Counter(){const [n,setN]=createSignal(0);const rows=createProjection(()=>n()%2 ? [{id:2,value:"b"},{id:1,value:"a"}] : [{id:1,value:"A"},{id:2,value:"B"}],[],{key:"id"});return setN(n()+1)}>{row=>}
}';
+ const rx = parseProgram(
+ 'component Row(props: { row: { id: number; value: string } }){captured = props.row.value;{"" + props.row.id}:{"" + props.row.value}/{"" + captured}; }export component Counter(){source n := 0;rows key id = n % 2 ? [{id:2,value:"b"},{id:1,value:"a"}] : [{id:1,value:"A"},{id:2,value:"B"}];{n := n + 1}}>{for row in rows {} }
}',
+ );
+ const row = rx.decls[0];
+ if (row.k === "component")
+ row.decls = row.decls.map((d) =>
+ d.k === "eq" ? snapshotSource(d) : d,
+ );
+ await traces(tsx, rx, ["1:A/A;2:B/B;", "2:b/B;1:a/A;", "1:A/A;2:B/B;"]);
+});
+
+test("exported module signals expose reader and setter aliases through shared modules", () => {
+ const files: Record = {
+ "state.tsx":
+ 'import {createSignal,createMemo} from "solid-js";export const [count,setCount]=createSignal(0);export const doubled=createMemo(()=>count()*2);',
+ "main.tsx":
+ 'import {count as read,setCount as write,doubled as twice} from "./state.tsx";export function Counter(){return write(read()+1)}>{read()}/{twice()} }',
+ };
+ const result = buildTsx("main.tsx", (path) => files[path], {
+ mount: "Counter",
+ });
+ const rt = new Runtime(result.set);
+ const frames = [rt.step([]).at(-1)!.frame];
+ const component = rt.view.items[0] as any;
+ const button = component.inst.items[0];
+ const click = button.attrs
+ .find((a: any) => a.name === "onClick")
+ .node.compute(rt);
+ for (let i = 0; i < 2; i++) {
+ click();
+ frames.push(rt.handler([]).at(-1)!.frame);
+ }
+ assert.deepEqual(frames, ["0/0", "1/2", "2/4"]);
+});
+
+test("source context children read and update the original source", async () => {
+ const tsx =
+ 'import {createSignal} from "solid-js";import {createSourceContext,useSourceContext} from "reactivescript-tsx";const Count=createSourceContext();function Reader(){const [n,setN]=useSourceContext(Count);return setN(p=>p+1)}>{n()} }export function Counter(){const [n,setN]=createSignal(0);return }';
+ const rx = parseProgram(
+ 'context Count: source number;component Reader(){n = ()=>use Count;{use Count := p => p + 1}}>{"" + n()} }export component Counter(){source n := 0;component Tsx__tsx0(__tsx1){provide Count = n; } }',
+ );
+ await traces(tsx, rx, ["0", "1", "2"]);
+ const unprovided = tsx
+ .replace(" ", " ")
+ .replace("{n()} ", "unused ")
+ .replace(" onClick={()=>setN(p=>p+1)}", "");
+ assert.throws(
+ () =>
+ buildTsx(
+ "main.tsx",
+ (path) => (path === "main.tsx" ? unprovided : undefined),
+ { mount: "Counter" },
+ ),
+ /CONTEXT_NOT_FOUND/,
+ );
+});
+
+test("structured source contexts read value rows and write the original store paths", async () => {
+ const source =
+ 'import {createStore} from "solid-js";import {createSourceContext,useSourceContext} from "reactivescript-tsx";const Rows=createSourceContext<{value:number}[]>();function Reader(){const [rows,setRows]=useSourceContext(Rows);return setRows(d=>{d[0].value=rows[0]?.value+1})}>{rows[0]?.value+0} }export function Counter(){const [rows,setRows]=createStore([{value:0}]);return }';
+ const rx = parseProgram(
+ 'context Rows: source ({ value: number })[];component Reader(){rows = ()=>use Rows;{use Rows[0].value := rows()[0]?.value+1}}>{""+(rows()[0]?.value+0)} }export component Counter(){source rows := [{value:0}];component Tsx__tsx0(__tsx1){provide Rows = rows; } }',
+ );
+ await traces(source, rx, ["0", "1", "2"]);
+ assert.throws(
+ () => lowerTsx(source.replace("rows[0]?.value+0}", "rows()[0]?.value+0}")),
+ /Only known zero-argument accessors/,
+ );
+});
+
+test("read-only accessor contexts preserve live payloads and default accessors", async () => {
+ const tsx =
+ 'import {createSignal,createContext,useContext} from "solid-js";const Count=createContext<()=>number>(()=>0);function Reader(){const read=useContext(Count);return {read()} }export function Counter(){const [n,setN]=createSignal(1);return setN(n()+1)}> / }';
+ const rx = parseProgram(
+ 'context Count: number = () => 0;component Reader(){read = peek(use Count);{"" + read()} }export component Counter(){source n := 1;component Tsx__tsx0(__tsx1: { value: number }){__tsx2 = peek(__tsx1.value);provide Count = __tsx2; }{n := n + 1}}> /n}/> }',
+ );
+ const context = rx.decls[0];
+ if (context.k === "context") context.type = "() => number";
+ // `read` and the provider's seed are equations over peek: an accessor-typed value is a function captured once.
+ for (const decl of rx.decls)
+ if (decl.k === "component")
+ for (const nested of decl.decls)
+ if (nested.k === "component")
+ nested.propsType = "{ value: () => number }";
+ await traces(tsx, rx, ["0/1", "0/2", "0/3"]);
+});
+
+test("default-less contexts require a provider and preserve primitive snapshots", async () => {
+ const tsx =
+ 'import {createSignal,createContext,useContext} from "solid-js";const Count=createContext();function Reader(){const current=useContext(Count);return {current} }export function Counter(){const [n,setN]=createSignal(1);return setN(n()+1)}> }';
+ const rx = parseProgram(
+ 'context Count: number;component Reader(){current = use Count;{"" + current} }export component Counter(){source n := 1;component Tsx__tsx0(__tsx1: { value: number }){__tsx2 = __tsx1.value;provide Count = __tsx2; }{n := n + 1}}> }',
+ );
+ for (const decl of rx.decls)
+ if (decl.k === "component") {
+ decl.decls = decl.decls.map((d) =>
+ d.k === "eq" ? snapshotSource(d) : d,
+ );
+ for (const nested of decl.decls)
+ if (nested.k === "component")
+ nested.decls = nested.decls.map((d) =>
+ d.k === "eq"
+ ? snapshotSource(d)
+ : d,
+ );
+ }
+ await traces(tsx, rx, ["1", "1", "1"]);
+ const unprovided = tsx.replace(
+ " ",
+ " ",
+ );
+ assert.throws(
+ () =>
+ buildTsx(
+ "main.tsx",
+ (path) => (path === "main.tsx" ? unprovided : undefined),
+ { mount: "Counter" },
+ ),
+ /CONTEXT_NOT_FOUND/,
+ );
+});
+for (const boundary of [false, true])
+ test(
+ "built-in Error filters catch declared failures" +
+ (boundary ? " at boundaries" : " in expressions"),
+ async () => {
+ const prefix =
+ 'import {createSignal,createMemo} from "solid-js";import {defineError,raiseFailure,catchFailure,FailureBoundary} from "reactivescript-tsx";const Missing=defineError("Missing",["id"],(id:number)=>"missing "+id);export function Counter(){const [n,setN]=createSignal(0);';
+ const tsx =
+ prefix +
+ (boundary
+ ? 'const result=createMemo(()=>n()%2 ? raiseFailure(Missing(n())) : "ok");return setN(n()+1)}>{err().message+"/"} }>{result()} }'
+ : 'const result=createMemo(()=>catchFailure(()=>n()%2 ? raiseFailure(Missing(n())) : "ok",[Error],err=>err.message+"/"));return setN(n()+1)}>{result()} }');
+ const rx =
+ 'error Missing(id) => "missing " + id;export component Counter(){source n := 0;result = ' +
+ (boundary
+ ? 'n % 2 ? throw Missing(n) : "ok";'
+ : '(n % 2 ? throw Missing(n) : "ok") catch (err: Error) => err.message + "/";') +
+ "{n := n + 1}}>" +
+ (boundary
+ ? '{errored fallback(err: Error){{"" + (err.message + "/")} }{{"" + result}}}'
+ : '{"" + result}') +
+ " }";
+ await traces(tsx, rx, ["ok", "missing 1/", "ok", "missing 3/"]);
+ },
+ );
+
+test("imported failure aliases cannot use reserved Error or Panic identities", () => {
+ const errors = parseProgram(
+ 'export error Missing()=>"missing";export error Other()=>"other";',
+ );
+ for (const name of ["Error", "Panic"])
+ for (const boundary of [false, true]) {
+ const source =
+ "import {Missing as " +
+ name +
+ ',Other} from "./errors.rx";import {FailureBoundary} from "reactivescript-tsx";export function Counter(){return ' +
+ (boundary
+ ? " }> "
+ : "{try{throw Other()}catch(error){if(error instanceof " +
+ name +
+ ".cls){return}else{throw error}}}}/>") +
+ "}";
+ assert.throws(
+ () =>
+ lowerTsx(source, "aliases.tsx", {
+ modules: new Map([
+ ["./errors.rx", { path: "errors.rx", program: errors }],
+ ]),
+ }),
+ (error: unknown) => {
+ assert.ok(error instanceof TsxError);
+ assert.match(
+ error.message,
+ /Imported failure aliases Error and Panic are reserved/,
+ );
+ assert.equal(error.start, source.indexOf("Missing as "));
+ return true;
+ },
+ );
+ }
+});
+
+test("array predicate builtin references reject preceding and later lexical collisions", () => {
+ for (const method of ["filter", "every", "map"]) {
+ const prefix =
+ 'import {createStore} from "solid-js";export function Counter(){const [rows,setRows]=createStore([1,2]);';
+ const call = "const result=rows." + method + "(item=>item>0);";
+ for (const body of [
+ "const " + method + "=0;" + call,
+ call + "const " + method + "=0;",
+ call + "function " + method + "(n:number):number{return n}",
+ ]) {
+ assert.throws(
+ () => lowerTsx(prefix + body + "return {rows.length} }"),
+ /Array predicate builtin/,
+ );
+ }
+ const ancestor =
+ 'import {createStore} from "solid-js";function Child(){const [rows,setRows]=createStore([1,2]);' +
+ call +
+ "return {rows.length} }const " +
+ method +
+ "=0;export function Counter(){return }";
+ assert.throws(() => lowerTsx(ancestor), /Array predicate builtin/);
+ const imported =
+ "import {token as " +
+ method +
+ '} from "./api";' +
+ prefix +
+ call +
+ "return }";
+ assert.throws(() => lowerTsx(imported), /Array predicate builtin/);
+ }
+});
+
+test("authored effect-sample names are reserved in apply scopes and cleanup closures", () => {
+ const prefix =
+ 'import {createSignal} from "solid-js";import {createRxEffect} from "reactivescript-tsx";export function Counter(){const [n,setN]=createSignal(1);const [seen,setSeen]=createSignal(0);createRxEffect([()=>n()],value=>{';
+ for (const apply of [
+ "const $0=99;return ()=>{setSeen($0)}",
+ "return ()=>{const $0=99;setSeen(value)}",
+ ]) {
+ const source = prefix + apply + "});return {seen()} }";
+ assert.throws(() => lowerTsx(source), /reserved for RX effect samples/);
+ }
+ for (const source of [
+ "const $1=1;export function Counter(){return }",
+ "export function Counter(){return {}}/>}",
+ 'import {createSignal as $3} from "solid-js";export function Counter(){return }',
+ "function $4(n:number):number{return n}export function Counter(){return }",
+ ])
+ assert.throws(() => lowerTsx(source), /reserved for RX effect samples/);
+});
+
+test("failure filters distinguish the global Error from shadowed or reserved names", () => {
+ for (const name of ["Error", "Panic"])
+ assert.throws(
+ () =>
+ lowerTsx(
+ 'import {defineError} from "reactivescript-tsx";const ' +
+ name +
+ '=defineError("' +
+ name +
+ '",[],()=>"failure");export function Counter(){return }',
+ ),
+ /reserved RX identities/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ 'import {createMemo} from "solid-js";import {catchFailure} from "reactivescript-tsx";const Error=0;export function Counter(){const result=createMemo(()=>catchFailure(()=>1,[Error],()=>0));return {result()} }',
+ ),
+ /declared failure constructors or the built-in Error/,
+ );
+});
+
+test("parameterized callback contracts reject mismatched arguments and handlers", () => {
+ const source =
+ "function Child(props:{onChange:(value:number)=>void}){return props.onChange(1)}>go }export function Counter(){return {}}/>}";
+ assert.doesNotThrow(() => lowerTsx(source));
+ assert.throws(
+ () => lowerTsx(source.replace("props.onChange(1)", "props.onChange()")),
+ /declared parameter count/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(source.replace("props.onChange(1)", 'props.onChange("wrong")')),
+ /declared type/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("next:number", "next:string")),
+ /Handler parameter does not match/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("next:number", "next:number,extra:number")),
+ /exceed its declared contract/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("next:number", "next?:number")),
+ /identifier binding/,
+ );
+});
+
+test("optimistic roots refuse projection proxies and mixed replacement element types", () => {
+ const prefix =
+ 'import {createSignal,createProjection,createOptimistic} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const projected=createProjection(d=>{d.count=n()},{count:0});';
+ assert.throws(
+ () =>
+ lowerTsx(
+ prefix +
+ "const [cart,setCart]=createOptimistic([projected]);return }",
+ ),
+ /projection proxies/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ prefix +
+ "const [cart,setCart]=createOptimistic([{count:0}]);return setCart([projected])}/> }",
+ ),
+ /projection proxies/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ prefix +
+ 'const [cart,setCart]=createOptimistic([1,2]);return setCart(["wrong"])}/> }',
+ ),
+ /preserve the signal/,
+ );
+});
+
+test("server cache invalidation refuses ambiguous Solid and ordinary memo getters", () => {
+ const prefix =
+ 'import {createMemo,refresh} from "solid-js";import {serverFunction} from "reactivescript-tsx";const load=serverFunction("load",function*():Generator{return 1});export function Counter(){const result=createMemo(async ():Promise=>await load());return refresh(result)}>{result()} }';
+ assert.throws(
+ () => lowerTsx(prefix),
+ /Solid refresh cannot invalidate a serverFunction factory cache/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ prefix
+ .replace("createMemo,refresh", "createMemo")
+ .replace("{serverFunction}", "{serverFunction,refresh}"),
+ ),
+ /Server cache refresh requires an original createServerMemo getter/,
+ );
+ const source = prefix
+ .replace("createMemo,refresh", "createMemo")
+ .replace("{serverFunction}", "{serverFunction,createServerMemo,refresh}")
+ .replace("result=createMemo(", "result=createServerMemo(");
+ const program = lowerTsx(source);
+ const counter = program.decls.find((d) => d.k === "component")!;
+ assert.equal(counter.k, "component");
+ if (counter.k === "component")
+ assert.equal(counter.decls.find((d) => d.k === "eq")?.e.k, "await");
+ const raw = source.replace(
+ "async ():Promise=>await load()",
+ "()=>load()",
+ );
+ const rawCounter = lowerTsx(raw).decls.find((d) => d.k === "component")!;
+ if (rawCounter.k === "component")
+ assert.equal(rawCounter.decls.find((d) => d.k === "eq")?.e.k, "await");
+ assert.throws(
+ () => lowerTsx(source.replace("Promise", "Promise")),
+ /Memo result does not match/,
+ );
+ assert.throws(
+ () => lowerTsx(raw.replace("()=>load()", "()=>load()+1")),
+ /a Promise is not a scalar/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("await load()", "await (load()+1)")),
+ /a Promise is not a scalar/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ source
+ .replace(
+ "return result());return {
+ const source =
+ 'import {createSourceContext,useSourceContext} from "reactivescript-tsx";const C=createSourceContext();function Child(){const [read,set]=useSourceContext(C);return {read()} }export function Counter(){return }';
+ assert.throws(() => lowerTsx(source), /compatible authoritative value/);
+});
+
+test("untyped imperative catch fields do not assume thrown JS values are Errors", async () => {
+ const source =
+ 'import {createSignal} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const [status,setStatus]=createSignal("idle");return {try{throw n()%2?7:null}catch(error){setN(n()+1);setStatus((error===7?"number":"null")+"/"+error?.message)}}}>{n()}/{status()} }';
+ const rx =
+ 'export component Counter(){source n := 0;source status := "idle";{try{throw n%2?7:null}catch(error){n := n+1;status := (error==7?"number":"null")+"/"+error?.message}}}>{""+n}/{""+status} }';
+ await traces(source, rx, [
+ "0/idle",
+ "1/null/undefined",
+ "2/number/undefined",
+ ]);
+});
+
+test("selective imperative catches preserve payloads, unmatched failures, and finalizers", async () => {
+ const source =
+ 'import {createSignal,action} from "solid-js";import {defineError} from "reactivescript-tsx";const Missing=defineError("Missing",["id"],(id:number)=>"missing "+id);const Offline=defineError("Offline",[],()=>"offline");export function Counter(){const [n,setN]=createSignal(0);const [status,setStatus]=createSignal("idle");const [count,setCount]=createSignal(0);const run=action(function*(step:number){setN(step);try{try{if(step%3===1)throw Missing(step);if(step%3===2)throw Offline();setStatus("ok")}catch(error){if(error instanceof Missing.cls){setStatus(error.message+"/"+error.id)}else{throw error}}}catch(outer){setStatus("outer")}finally{setCount(p=>p+1)}});return run(n()+1)}>{n()}/{status()}/{count()} }';
+ const rx =
+ 'error Missing(id)=>"missing "+id;error Offline()=>"offline";export component Counter(){source n := 0;source status := "idle";source count := 0;action run(step){n := step;try{try{if(step%3==1){throw Missing(step)}if(step%3==2){throw Offline()}status := "ok"}catch(error:Missing){status := error.message+"/"+error.id}}catch(outer){status := "outer"}finally{count := p=>p+1}}{run(n+1)}}>{""+n}/{""+status}/{""+count} }';
+ await traces(source, rx, [
+ "0/idle/0",
+ "1/missing 1/1/1",
+ "2/outer/2",
+ "3/ok/3",
+ ]);
+ const unmatched = source.replace(
+ '}catch(outer){setStatus("outer")}finally',
+ "}finally",
+ );
+ assert.throws(
+ () =>
+ buildTsx(
+ "main.tsx",
+ (path) => (path === "main.tsx" ? unmatched : undefined),
+ { mount: "Counter" },
+ ),
+ /UNCAUGHT_ACTION_FAILURE/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ source.replace("else{throw error}", 'else{setStatus("swallowed")}'),
+ ),
+ /instanceof/,
+ );
+});
+
+for (const broad of [false, true])
+ test(
+ broad
+ ? "raw Error guards are refused rather than widened to RX catch-all"
+ : "selective imperative union guards leave primitive throws unmatched",
+ async () => {
+ const source =
+ 'import {createSignal,action} from "solid-js";import {defineError} from "reactivescript-tsx";const Missing=defineError("Missing",["id"],(id:number)=>"missing "+id);const Offline=defineError("Offline",[],()=>"offline");export function Counter(){const [n,setN]=createSignal(0);const [status,setStatus]=createSignal("idle");const run=action(function*(step:number){setN(step);try{try{if(step%3===1)throw Missing(step);if(step%3===2)throw Offline();throw 7}catch(error){if(' +
+ (broad
+ ? "error instanceof Error"
+ : "error instanceof Missing.cls || error instanceof Offline.cls") +
+ '){setStatus(error.message)}else{throw error}}}catch(outer){setStatus("outer")}});return run(n()+1)}>{n()}/{status()} }';
+ const rx = parseProgram(
+ 'error Missing(id)=>"missing "+id;error Offline()=>"offline";export component Counter(){source n := 0;source status := "idle";action run(step){n := step;try{try{if(step%3==1){throw Missing(step)}if(step%3==2){throw Offline()}throw 7}catch(error:Missing|Offline){status := error.message}}catch(outer){status := "outer"}}{run(n+1)}}>{""+n}/{""+status} }',
+ );
+ if (broad) {
+ assert.throws(() => lowerTsx(source), /instanceof/);
+ return;
+ }
+ await traces(source, rx, [
+ "0/idle",
+ "1/missing 1",
+ "2/offline",
+ "3/outer",
+ ]);
+ },
+ );
+
+test("declared failures preserve fields and filtered catch expressions", async () => {
+ const tsx =
+ 'import {createSignal,createMemo} from "solid-js";import {defineError,raiseFailure,catchFailure} from "reactivescript-tsx";const Missing=defineError("Missing",["id"],(id:number)=>"missing "+id);export function Counter(){const [n,setN]=createSignal(0);const result=createMemo(()=>catchFailure(()=>n()%2 ? raiseFailure(Missing(n())) : "ok",[Missing],err=>err.message+"/"+err.id));return setN(n()+1)}>{result()} }';
+ const rx =
+ 'error Missing(id) => "missing " + id;export component Counter(){source n := 0;result = (n % 2 ? throw Missing(n) : "ok") catch (err: Missing) => err.message + "/" + err.id;{n := n + 1}}>{"" + result} }';
+ await traces(tsx, rx, ["ok", "missing 1/1", "ok", "missing 3/3"]);
+});
+
+for (const recover of [false, true])
+ test(
+ "failure boundaries update filtered error accessors" +
+ (recover ? " and recover" : ""),
+ async () => {
+ const tsx =
+ 'import {createSignal,createMemo} from "solid-js";import {defineError,raiseFailure,FailureBoundary} from "reactivescript-tsx";const Missing=defineError("Missing",["id"],(id:number)=>"missing "+id);const Other=defineError("Other",[],()=>"other");export function Counter(){const [n,setN]=createSignal(0);const result=createMemo(()=>n() ? raiseFailure(Missing(n())) : "ok");return setN(n()+1)}>{err().message}/{err().id} }>wrong }>{result()} }';
+ const rx =
+ 'error Missing(id) => "missing " + id;error Other() => "other";export component Counter(){source n := 0;result = n ? throw Missing(n) : "ok";{n := n + 1}}>{errored fallback(err: Missing){{"" + err.message}/{"" + err.id} }{errored fallback(other: Other){wrong }{{"" + result}}}} }';
+ await traces(
+ recover ? tsx.replace("()=>n() ?", "()=>n()%2 ?") : tsx,
+ recover ? rx.replace("result = n ?", "result = n % 2 ?") : rx,
+ recover
+ ? ["ok", "missing 1/1", "ok", "missing 3/3"]
+ : ["ok", "missing 1/1", "missing 2/2", "missing 3/3"],
+ );
+ },
+ );
+
+test("JS call result annotations do not replace declaration purity contracts", () => {
+ const source =
+ 'import {createMemo,Errored} from "solid-js";import {fetchValue as load} from "./api";export function Counter(){const result=createMemo(async ():Promise=>await load(0));return failed}>{result()}
}';
+ const declarations = new Map([
+ [
+ "./api",
+ parseDeclarations("export pure fn fetchValue(n: number): number"),
+ ],
+ ]);
+ const p = lowerTsx(source, "Counter.tsx", { declarations });
+ const imp = p.decls[0];
+ assert.equal(imp.k, "import");
+ if (imp.k === "import") {
+ assert.equal(imp.pure, false);
+ assert.deepEqual(imp.names, [{ name: "fetchValue", alias: "load" }]);
+ }
+ const inputs: Record = {
+ "main.tsx": source,
+ "api.d.rx": "export pure fn fetchValue(n: number): number",
+ };
+ assert.ok(
+ buildTsx("main.tsx", (path) => inputs[path], {
+ target: "universal",
+ mount: "Counter",
+ }).files.get("main.mjs"),
+ );
+ assert.throws(
+ () =>
+ buildTsx(
+ "main.tsx",
+ (path) => (path === "main.tsx" ? source : undefined),
+ { target: "universal", mount: "Counter" },
+ ),
+ /IMPURE|pure|io/i,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(source.replace(":Promise", ""), "Counter.tsx", {
+ declarations,
+ }),
+ /return annotation/,
+ );
+});
+
+test("bare JS imports remain usable as imperative calls without inventing result shapes", () => {
+ const source =
+ 'import {notify} from "./api";export function Counter(){return notify(1)}>send }';
+ const inputs: Record = { "main.tsx": source };
+ assert.ok(
+ buildTsx("main.tsx", (path) => inputs[path], {
+ target: "universal",
+ mount: "Counter",
+ }).files.get("main.mjs"),
+ );
+});
+
+test("draft reads that require staged sequential values are explicitly rejected", () => {
+ const source =
+ 'import {createStore} from "solid-js";export function Counter(){const [s,setS]=createStore({n:0});return setS(d=>{d.n+=1;d.n+=1})}>{s.n} }';
+ assert.throws(() => lowerTsx(source), /sequential draft semantics/);
+ assert.throws(
+ () => lowerTsx(source.replace("d.n+=1;d.n+=1", "d.n+=1;d.n=s.n+1")),
+ /sequential draft semantics/,
+ );
+});
+
+test("heterogeneous structured arrays cannot silently borrow the first item shape", () => {
+ assert.throws(
+ () =>
+ lowerTsx(
+ "export function Counter(){const rows=[{a:1},{b:2}];return {rows}
}",
+ ),
+ /structured value kinds/,
+ );
+});
+
+test("typed memo previous values use pre and recompute in the caller's reactive instant", async () => {
+ const tsx =
+ 'import {createSignal,createMemo} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const total=createMemo((previous:number|undefined)=>(previous??0)+n());return setN(n()+1)}>{n()}/{total()} }';
+ const rx =
+ 'export component Counter(){source n := 0;total = (pre total ?? 0) + n;{n := n + 1}}>{"" + n}/{"" + total} }';
+ await traces(tsx, rx, ["0/0", "1/1", "2/3", "3/6", "4/10"]);
+});
+
+test("derived scalar signals retain manual overrides until their input changes", async () => {
+ const tsx =
+ 'import {createSignal} from "solid-js";export function Counter(){const [base,setBase]=createSignal(1);const [step,setStep]=createSignal(0);const [draft,setDraft]=createSignal(()=>base()*10);return {setStep(step()+1);if(step()%2===0){setDraft(99)}else{setBase(base()+1)}}}>{base()}/{draft()} }';
+ const rx =
+ 'export component Counter(){source base := 1;source step := 0;source draft = base * 10;{step := step + 1;if (step % 2 == 0) {draft := 99} else {base := base + 1}}}>{"" + base}/{"" + draft} }';
+ await traces(tsx, rx, ["1/10", "1/99", "2/20", "2/99", "3/30"]);
+});
+
+test("store root aliases remain live while captured nested objects survive path replacement", async () => {
+ const tsx =
+ 'import {createStore,untrack} from "solid-js";export function Counter(){const [s,setS]=createStore({row:{n:0}});const alias=s;const row=untrack(()=>s.row);return setS(d=>{d.row={n:s.row.n+1}})}>{alias.row.n}/{row.n} }';
+ const rx = parseProgram(
+ // Naming a store reads nothing, so `alias` is the store itself; `row` is a mount snapshot of the nested object.
+ 'export component Counter(){source s := {row:{n:0}};source row := peek(s.row);{s.row := {n:s.row.n+1}}}>{"" + s.row.n}/{"" + row.n} }',
+ );
+ await traces(tsx, rx, ["0/0", "1/0", "2/0"]);
+});
+
+test("projection aliases and nested captures preserve native replacement identity", async () => {
+ const tsx =
+ 'import {createSignal,createProjection,untrack} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const projected=createProjection(draft=>{draft.row={n:n()}},{row:{n:0}});const alias=projected;const row=untrack(()=>projected.row);return setN(n()+1)}>{alias.row.n}/{row.n} }';
+ const rx = parseProgram(
+ 'export component Counter(){source n := 0;__tsx0 = {row:{n:0}};projected = draft(pre projected -> __tsx0){d.row := {n:n}};row = projected.row;{n := n + 1}}>{"" + projected.row.n}/{"" + row.n} }',
+ );
+ const component = rx.decls[0];
+ if (component.k === "component")
+ component.decls = component.decls.map((d) =>
+ d.k === "eq" && d.name !== "projected"
+ ? snapshotSource(d)
+ : d,
+ );
+ await traces(tsx, rx, ["0/0", "1/0", "2/0"]);
+});
+
+test("structured projection-root snapshots require live alias support", () => {
+ const tsx =
+ 'import {createSignal,createProjection} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const projected=createProjection(draft=>{draft.row={n:n()}},{row:{n:0}});const box={root:projected};return setN(n()+1)}>{box.root.row.n} }';
+ // Native/compiled frames were [0,1,2], while a raw interpreter const stayed [0,0,0].
+ for (const expression of [
+ "{root:projected}",
+ "[projected]",
+ "n() ? projected : projected",
+ ])
+ assert.throws(
+ () => lowerTsx(tsx.replace("{root:projected}", expression)),
+ /projection-root proxies require live alias/,
+ );
+});
+
+for (const wrapped of [false, true])
+ test(
+ "generator action handlers" +
+ (wrapped ? " through a forwarding arrow" : " attached directly"),
+ async () => {
+ const tsx =
+ 'import {createSignal,action} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const increment=action(function*(){setN(n()+1)});return increment()" : "increment") +
+ "}>{n()} }";
+ const rx =
+ "export component Counter(){source n := 0;action increment(){n := n + 1}{increment()}" : "increment") +
+ '}>{"" + n} }';
+ await traces(tsx, rx, ["0", "1", "2", "3"]);
+ },
+ );
+
+test("actions reject async arrows, delegated yields and untyped JS results", () => {
+ const source =
+ 'import {createSignal,action} from "solid-js";import {request} from "./api";export function Counter(){const [n,setN]=createSignal(0);const run=action(function*(){const next:number=yield request();setN(next)});return {n()} }';
+ const p = lowerTsx(source);
+ const component = p.decls.find((d) => d.k === "component");
+ assert.ok(
+ component?.k === "component" &&
+ component.decls.some(
+ (d) =>
+ d.k === "action" &&
+ d.body[0].k === "let" &&
+ d.body[0].e.k === "await",
+ ),
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ source
+ .replace("function*()", "async ()=>")
+ .replace("yield request()", "await request()"),
+ ),
+ /synchronous generator/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("yield request()", "yield* request()")),
+ /non-delegating yields/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("next:number", "next")),
+ /return annotation/,
+ );
+});
+
+for (const rejected of [false, true])
+ test(
+ "interpreter optimistic action " + (rejected ? "rollback" : "truth commit"),
+ () => {
+ const source =
+ 'import {createSignal,createOptimistic,action} from "solid-js";import {request} from "./api";export function Counter(){const [truth,setTruth]=createSignal(0);const [count,setCount]=createOptimistic(()=>truth());const [status,setStatus]=createSignal("");const save=action(function*(){setCount(1);setStatus("placing");try{const receipt:number=yield request();setTruth(receipt);setStatus("ok")}catch(error){setStatus("failed")}});return save()}>{count()}/{status()} }';
+ const set = loadTsxModules("main.tsx", (path) =>
+ path === "main.tsx" ? source : undefined,
+ );
+ set.programs.get(set.entry)!.view = [
+ { k: "el", tag: "Counter", attrs: [], children: [] },
+ ];
+ const rt = new Runtime(set);
+ const frames = [rt.step([]).at(-1)!.frame];
+ frames.push(
+ rt
+ .handler([
+ {
+ k: "expr",
+ e: { k: "call", f: { k: "id", name: "save" }, args: [] },
+ },
+ ])
+ .at(-1)!.frame,
+ );
+ frames.push(
+ rt
+ .land('"request:"', rejected ? new Error("offline") : 42, rejected)
+ .at(-1)!.frame,
+ );
+ assert.deepEqual(frames, ["0/", "1/", rejected ? "0/failed" : "42/ok"]);
+ },
+ );
+
+test("primitive context providers snapshot once in owned regions and do not leak to siblings", async () => {
+ const tsx =
+ 'import {createSignal,createContext,useContext} from "solid-js";const C=createContext(7);export function Counter(){const [n,setN]=createSignal(0);return setN(n()+1)}>{n()}/ / / }function Child(){const current=useContext(C);return {current} }';
+ const rx = parseProgram(
+ 'context C:number = 7;export component Counter(){source n := 0;component Tsx__tsx0(__tsx1:{ value: number }){__tsx2 = __tsx1.value;provide C = __tsx2; }component Tsx__tsx3(__tsx4:{ value: number }){__tsx5 = __tsx4.value;provide C = __tsx5; }{n := n + 1}}>{"" + n}/// }component Child(){current = use C;{"" + current} }',
+ );
+ const snapshots = (decls: Decl[]): Decl[] =>
+ decls.map((d) =>
+ d.k === "component"
+ ? { ...d, decls: snapshots(d.decls) }
+ : d.k === "eq"
+ ? snapshotSource(d)
+ : d,
+ );
+ rx.decls = snapshots(rx.decls);
+ await traces(tsx, rx, ["0/0/100/7", "1/0/100/7", "2/0/100/7"]);
+});
+
+test("primitive context providers capture the new mount value after Show remounts", async () => {
+ const tsx =
+ 'import {createSignal,createContext,useContext,Show} from "solid-js";const C=createContext(7);export function Counter(){const [n,setN]=createSignal(0);return setN(n()+1)}>{n()}off}> / }function Child(){const current=useContext(C);return {current} }';
+ const rx = parseProgram(
+ 'context C:number = 7;export component Counter(){source n := 0;component Tsx__tsx0(__tsx1:{ value: number }){__tsx2 = __tsx1.value;provide C = __tsx2; }{n := n + 1}}>{"" + n}{if n % 3 != 0 {} else {off }}/ }component Child(){current = use C;{"" + current} }',
+ );
+ const snapshots = (decls: Decl[]): Decl[] =>
+ decls.map((d) =>
+ d.k === "component"
+ ? { ...d, decls: snapshots(d.decls) }
+ : d.k === "eq"
+ ? snapshotSource(d)
+ : d,
+ );
+ rx.decls = snapshots(rx.decls);
+ await traces(tsx, rx, ["0off/7", "11/7", "21/7", "3off/7", "44/7"]);
+});
+
+test("context forms outside the tested primitive ownership subset fail explicitly", () => {
+ const source =
+ 'import {createContext,useContext} from "solid-js";const C=createContext(7);export function Counter(){const current=useContext(C);return {current}
}';
+ assert.throws(
+ () => lowerTsx(source.replace("createContext(7)", "createContext()")),
+ /explicit primitive type without a default/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(source.replace("createContext(7)", "createContext({value:7})")),
+ /primitive literal/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("createContext(7)", "createContext(1+2)")),
+ /primitive literal/,
+ );
+ assert.throws(
+ () => lowerTsx(source + "function read():number{return useContext(C)}"),
+ /dynamic caller ownership/,
+ );
+});
+
+test("non-keyed Show callback receives a live accessor, not a snapshotted value", async () => {
+ const tsx =
+ 'import {createSignal,Show} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);return setN(n()+1)}>{n()}off}>{shown=>{shown()} } }';
+ const rx =
+ 'export component Counter(){source n := 0;{n := n + 1}}>{"" + n}{if n % 3 {shown = n % 3; {"" + shown} } else {off }} }';
+ await traces(tsx, rx, ["0off", "11", "22", "3off", "41", "52"]);
+});
+
+for (const marked of [false, true])
+ test(
+ "fire-and-forget memo refresh" + (marked ? " with ambient affects" : ""),
+ async () => {
+ const tsx =
+ 'import {createMemo,refresh,affects,isPending} from "solid-js";export function Counter(){const total=createMemo((previous:number|undefined)=>(previous??0)+1);return {' +
+ (marked ? "affects(total);" : "") +
+ 'refresh(total)}}>{total()}/{isPending(()=>total())?"pending":"idle"} }';
+ const rx =
+ "export component Counter(){total = (pre total ?? 0) + 1;{" +
+ (marked ? "affects total;" : "") +
+ 'refresh total}}>{"" + total}/{if pending? total {{"" + "pending"}} else {{"" + "idle"}}} }';
+ await traces(tsx, rx, ["1/idle", "2/idle", "3/idle", "4/idle"]);
+ },
+ );
+
+test("synchronous projection drafts start from an eager seed and retain their previous draft", async () => {
+ const tsx =
+ 'import {createSignal,createProjection} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const projection=createProjection(draft=>{draft.count=n();draft.sum+=draft.count;},{count:0,sum:0});return setN(n()+1)}>{projection.count}/{projection.sum} }';
+ const rx = parseProgram(
+ 'export component Counter(){source n := 0;__tsx0 = {count:0,sum:0};projection = draft(pre projection -> __tsx0){d.count := n;d.sum := d.sum + d.count};{n := n + 1}}>{"" + projection.count}/{"" + projection.sum} }',
+ );
+ const component = rx.decls[0];
+ if (component.k === "component")
+ component.decls = component.decls.map((d) =>
+ d.k === "eq" && d.name === "__tsx0"
+ ? snapshotSource(d)
+ : d,
+ );
+ await traces(tsx, rx, ["0/0", "1/1", "2/3", "3/6", "4/10"]);
+});
+
+test("eager module/component snapshots, pure typed helpers, strings, booleans, null, and untrack", async () => {
+ // TSX setup consts are RX snapshot sources: the fixture spells `initial` as `source initial := peek count`, and the typed
+ // module constant `factor` is written as an equation and converted.
+ const rx = parseProgram(fixture("values.rx"));
+ rx.decls = rx.decls.map((d) =>
+ d.k === "eq" && d.name === "factor"
+ ? snapshotSource(d)
+ : d,
+ );
+ await traces(fixture("values.tsx"), rx, [
+ "0/n=0/0/few//",
+ "0/n=2/0/few//",
+ "0/n=4/0/many//",
+ "0/n=6/0/many//",
+ ]);
+});
+
+test("Solid 2 store drafts, path writes, Show and default value-item For", async () => {
+ await traces(fixture("store.tsx"), fixture("store.rx"), [
+ "0/2empty",
+ "1/3XB",
+ "2/4XB",
+ "3/5XB",
+ ]);
+});
+
+test("split compute/apply createRxEffect lowers to RX's one effect form", async () => {
+ await traces(fixture("effect.tsx"), parseProgram(fixture("effect.rx")), ["0/0", "1/10", "2/20", "3/30"]);
+});
+
+test("plain Solid createEffect is refused instead of being given RX EF-2 semantics", () => {
+ const tsx =
+ 'import {createSignal,createEffect} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);const [seen,setSeen]=createSignal(0);createEffect(()=>n()%2,value=>{setSeen(p=>p+1)});return setN(n()+2)}>{n()}/{seen()} }';
+ assert.throws(
+ () => lowerTsx(tsx),
+ (e: unknown) =>
+ e instanceof TsxError &&
+ /createEffect is not lowered: ReactiveScript has one effect form with per-input value comparison \(EF-2\)\. Use createRxEffect from reactivescript-ref\/tsx/.test(e.message),
+ );
+});
+
+test("structured signal identity is not silently changed to store semantics", () => {
+ assert.throws(
+ () =>
+ lowerTsx(
+ 'import {createSignal} from "solid-js"; export function Counter(){const [state,setState]=createSignal({n:0});return {state().n}
}',
+ ),
+ (e: unknown) => e instanceof TsxError && /identity/.test(e.message),
+ );
+});
+
+test("Show preserves snapshots for truthy changes and resets them after hiding", async () => {
+ const tsx =
+ 'import {createSignal,Show,untrack} from "solid-js"; export function Counter(){const [n,setN]=createSignal(0);return setN(n()+1)}>{n()}off}> } function Child(props:{value:number}){const initial=untrack(()=>props.value);return {initial}/{props.value} }';
+ const rx = parseProgram(
+ 'export component Counter(){source n := 0; {n := n + 1}}>{"" + n}{if n % 3 != 0 {} else {off }} } component Child(props:{value:number}){initial = props.value; {"" + initial}/{"" + props.value} }',
+ );
+ const child = rx.decls.find(
+ (d) => d.k === "component" && d.name === "Child",
+ )!;
+ if (child.k === "component") {
+ child.propsType = "{ value: number }";
+ child.decls = child.decls.map((d) =>
+ d.k === "eq" ? snapshotSource(d) : d,
+ );
+ }
+ await traces(tsx, rx, [
+ "0off",
+ "11/1",
+ "21/2",
+ "3off",
+ "44/4",
+ "54/5",
+ "6off",
+ ]);
+});
+
+test("default For passes values and preserves row snapshots when identities reorder", async () => {
+ const tsx =
+ 'import {createStore,For,untrack} from "solid-js"; export function Counter(){const [s,setS]=createStore({n:0,rows:[10,20]});return setS(d=>{d.rows=s.n%2===0?[20,10]:[10,20];d.n+=1})}>{s.n}:{row=>} } function Child(props:{value:number}){const initial=untrack(()=>props.value);return {initial}/{props.value}; }';
+ const rx = parseProgram(
+ 'export component Counter(){source s := {n:0,rows:[10,20]}; {s.rows := s.n % 2 == 0 ? [20,10] : [10,20]; s.n := s.n + 1}}>{"" + s.n}:{for row in s.rows {}} } component Child(props:{value:number}){initial = props.value; {"" + initial}/{"" + props.value}; }',
+ );
+ const child = rx.decls.find(
+ (d) => d.k === "component" && d.name === "Child",
+ )!;
+ if (child.k === "component") {
+ child.propsType = "{ value: number }";
+ child.decls = child.decls.map((d) =>
+ d.k === "eq" ? snapshotSource(d) : d,
+ );
+ }
+ await traces(tsx, rx, [
+ "0:10/10;20/20;",
+ "1:20/21;10/11;",
+ "2:10/12;20/22;",
+ "3:20/23;10/13;",
+ ]);
+});
+
+test("JSX entities decode strictly without decoding JS string expressions", async () => {
+ const tsx =
+ 'import {createSignal} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);return setN(n()+1)}>A & A B © &unknown; & bare; x
y{"&"}:{n()} }';
+ const rx = parseProgram(
+ 'export component Counter(){source n := 0;{n := n + 1}}>PLACEHOLDER{"" + "&"}:{"" + n} }',
+ );
+ const prefix = "A & A B \u00a9 &unknown; & bare; x\ny";
+ const counter = rx.decls[0];
+ if (
+ counter.k === "component" &&
+ counter.view[0].k === "el" &&
+ counter.view[0].children[0].k === "text"
+ )
+ counter.view[0].children[0].s = prefix;
+ await traces(tsx, rx, [prefix + "&:0", prefix + "&:1"]);
+});
+
+for (const [name, authored, expected] of [
+ [
+ "legacy entities",
+ "A © A B & &unknown; ",
+ "A \u00a9 A B & &unknown; ",
+ ],
+ ["CRLF tabs and blank lines", " A \r\n\t B \n \n\tC ", " A B C "],
+ ["bare carriage returns", "A\rB", "AB"],
+ ["Unicode line separators", "A\u2028 \u2029\tB", "A B"],
+ [
+ "Unicode whitespace",
+ "\u0085A\u00a0\u1680\u2003B\u202f\u205f\u3000",
+ " A B ",
+ ],
+ ["empty continuation lines", " \t\n\t\n ", ""],
+ ["BOM is not Rust whitespace", "\ufeffA \ufeff", "\ufeffA \ufeff"],
+])
+ test("native JSX text normalization: " + name, async () => {
+ const tsx =
+ 'import {createSignal} from "solid-js";export function Counter(){const [n,setN]=createSignal(0);return setN(n()+1)}>' +
+ authored +
+ "{n()} }";
+ const rx = parseProgram(
+ 'export component Counter(){source n := 0;{n := n + 1}}>PLACEHOLDER{"" + n} }',
+ );
+ const component = rx.decls[0];
+ if (component.k === "component" && component.view[0].k === "el") {
+ const children = component.view[0].children;
+ if (children[0].k === "text") children[0].s = expected;
+ if (!expected) children.shift();
+ }
+ await traces(tsx, rx, [expected + "0", expected + "1"], {
+ title: "\u00a9 A B & &unknown;",
+ });
+ });
diff --git a/tsx-frontend/tsx/fixtures/composition.rx b/tsx-frontend/tsx/fixtures/composition.rx
new file mode 100644
index 0000000..588b857
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/composition.rx
@@ -0,0 +1,15 @@
+export component Counter() {
+ source count := 0;
+ doubled = count * 2;
+ { count := count + 1 }} />
+}
+
+component CounterButton(incoming: { direct: number; value: number; onIncrement: action catches(nothing) }) {
+ source count := 10;
+ doubled = incoming.value * 2;
+
+}
+
+component Button(props: { direct: number; value: number; local: number; onIncrement: action catches(nothing) }) {
+ { props.onIncrement(); }}>{"" + props.direct}/{"" + props.value}/{"" + props.local}
+}
diff --git a/tsx-frontend/tsx/fixtures/composition.tsx b/tsx-frontend/tsx/fixtures/composition.tsx
new file mode 100644
index 0000000..5577133
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/composition.tsx
@@ -0,0 +1,17 @@
+import { createSignal, createMemo } from "solid-js";
+
+export function Counter() {
+ const [count, setCount] = createSignal(0);
+ const doubled = createMemo(() => count() * 2);
+ return setCount(count() + 1)} />;
+}
+
+function CounterButton(incoming: { direct: number; value: number; onIncrement: () => void }) {
+ const [count, setCount] = createSignal(10);
+ const doubled = createMemo(() => incoming.value * 2);
+ return ;
+}
+
+function Button(props: { direct: number; value: number; local: number; onIncrement: () => void }) {
+ return props.onIncrement()}>{props.direct}/{props.value}/{props.local} ;
+}
diff --git a/tsx-frontend/tsx/fixtures/counter.rx b/tsx-frontend/tsx/fixtures/counter.rx
new file mode 100644
index 0000000..e79f6cd
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/counter.rx
@@ -0,0 +1,5 @@
+export component Counter() {
+ source count := 0;
+ doubled = count * 2;
+ { count := count + 1 }}>{"" + doubled}
+}
diff --git a/tsx-frontend/tsx/fixtures/counter.tsx b/tsx-frontend/tsx/fixtures/counter.tsx
new file mode 100644
index 0000000..f39560f
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/counter.tsx
@@ -0,0 +1,7 @@
+import { createSignal, createMemo } from "solid-js";
+
+export function Counter() {
+ const [count, setCount] = createSignal(0);
+ const doubled = createMemo(() => count() * 2);
+ return setCount(count() + 1)}>{doubled()} ;
+}
diff --git a/tsx-frontend/tsx/fixtures/demo/tracker.rx b/tsx-frontend/tsx/fixtures/demo/tracker.rx
new file mode 100644
index 0000000..cd1994d
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/demo/tracker.rx
@@ -0,0 +1,41 @@
+import { assignIssue } from "./api";
+
+export default component Tracker() {
+ source issues := {
+ list: [
+ { id: 1, title: "Crash on save", assignee: "" },
+ { id: 2, title: "Slow search", assignee: "ana" },
+ { id: 3, title: "Typo in footer", assignee: "" },
+ ],
+ };
+ source mode := "all";
+ source claiming = 0;
+ source status := "";
+
+ visible = filter(issues.list, issue => mode == "all" || issue.assignee == "");
+ unassigned = filter(issues.list, issue => issue.assignee == "").length;
+
+ action claim(id: number) {
+ optimistic claiming := id;
+ try {
+ let owner = await assignIssue(id);
+ issues.list[id - 1].assignee := owner;
+ status := "";
+ } catch (error) {
+ status := "Could not assign #" + id;
+ }
+ }
+
+
+ { mode := mode == "all" ? "open" : "all" }}>Filter: {mode}
+ {"" + unassigned} open
+ {if visible.length > 0 {
+ for issue in visible { { claim(issue.id) }}/> }
+ } else { Nothing to triage }}
+ {status}
+
+}
+
+component IssueRow(props: { issue: { id: number; title: string; assignee: string }; claiming: boolean; onAssign: action catches(nothing) }) {
+ {props.issue.title}: {if props.claiming { {"me"} } else { {props.issue.assignee || "nobody"} }} { props.onAssign() }}>take
+}
diff --git a/tsx-frontend/tsx/fixtures/demo/tracker.tsx b/tsx-frontend/tsx/fixtures/demo/tracker.tsx
new file mode 100644
index 0000000..b6a71f7
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/demo/tracker.tsx
@@ -0,0 +1,62 @@
+import { createSignal, createOptimistic, createStore, createMemo, action, Show, For } from "solid-js";
+import { assignIssue } from "./api";
+
+export default function Tracker() {
+ const [issues, setIssues] = createStore({
+ list: [
+ { id: 1, title: "Crash on save", assignee: "" },
+ { id: 2, title: "Slow search", assignee: "ana" },
+ { id: 3, title: "Typo in footer", assignee: "" },
+ ],
+ });
+ const [mode, setMode] = createSignal("all");
+ const [claiming, setClaiming] = createOptimistic(() => 0);
+ const [status, setStatus] = createSignal("");
+
+ const visible = createMemo(() =>
+ issues.list.filter((issue) => mode() === "all" || issue.assignee === ""),
+ );
+ const unassigned = createMemo(() => issues.list.filter((issue) => issue.assignee === "").length);
+
+ // Show the claim at once; commit the server's answer, or roll back and explain.
+ const claim = action(function* (id: number) {
+ setClaiming(id);
+ try {
+ const owner: string = yield assignIssue(id);
+ setIssues((draft) => {
+ draft.list[id - 1].assignee = owner; // ids are 1-based list positions here
+ });
+ setStatus("");
+ } catch (error) {
+ setStatus("Could not assign #" + id);
+ }
+ });
+
+ return (
+
+ setMode(mode() === "all" ? "open" : "all")}>Filter: {mode()}
+ {unassigned()} open
+ 0} fallback={Nothing to triage }>
+
+ {(issue) => (
+ claim(issue.id)} />
+ )}
+
+
+ {status()}
+
+ );
+}
+
+function IssueRow(props: {
+ issue: { id: number; title: string; assignee: string };
+ claiming: boolean;
+ onAssign: () => void;
+}) {
+ return (
+
+ {props.issue.title}: {props.claiming ? "me" : props.issue.assignee || "nobody"}
+ props.onAssign()}>take
+
+ );
+}
diff --git a/tsx-frontend/tsx/fixtures/effect.rx b/tsx-frontend/tsx/fixtures/effect.rx
new file mode 100644
index 0000000..3219bfd
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/effect.rx
@@ -0,0 +1,6 @@
+export component Counter() {
+ source count := 0;
+ source seen := 0;
+ effect(count) { seen := $0 * 10; }
+ { count := count + 1 }}>{"" + count}/{"" + seen}
+}
diff --git a/tsx-frontend/tsx/fixtures/effect.tsx b/tsx-frontend/tsx/fixtures/effect.tsx
new file mode 100644
index 0000000..0f276e4
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/effect.tsx
@@ -0,0 +1,8 @@
+import { createSignal } from "solid-js";
+import { createRxEffect } from "reactivescript-tsx";
+export function Counter() {
+ const [count, setCount] = createSignal(0);
+ const [seen, setSeen] = createSignal(0);
+ createRxEffect([() => count()], value => { setSeen(value * 10); });
+ return setCount(count() + 1)}>{count()}/{seen()} ;
+}
diff --git a/tsx-frontend/tsx/fixtures/paired/counter-peek.rx b/tsx-frontend/tsx/fixtures/paired/counter-peek.rx
new file mode 100644
index 0000000..f301abb
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/paired/counter-peek.rx
@@ -0,0 +1,5 @@
+export default component Counter() {
+ source count := 0;
+ doubled = peek(count) * 2;
+ { count := count + 1 }}>{"" + doubled}
+}
diff --git a/tsx-frontend/tsx/fixtures/paired/counter.rx b/tsx-frontend/tsx/fixtures/paired/counter.rx
new file mode 100644
index 0000000..b61004f
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/paired/counter.rx
@@ -0,0 +1,5 @@
+export default component Counter() {
+ source count := 0;
+ doubled = count * 2;
+ { count := count + 1 }}>{"" + doubled}
+}
diff --git a/tsx-frontend/tsx/fixtures/paired/counter.tsx b/tsx-frontend/tsx/fixtures/paired/counter.tsx
new file mode 100644
index 0000000..89e6b74
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/paired/counter.tsx
@@ -0,0 +1,7 @@
+import { createSignal, createMemo } from "solid-js";
+
+export default function Counter() {
+ const [count, setCount] = createSignal(0);
+ const doubled = createMemo(() => count() * 2);
+ return setCount(count() + 1)}>{doubled()} ;
+}
diff --git a/tsx-frontend/tsx/fixtures/store.rx b/tsx-frontend/tsx/fixtures/store.rx
new file mode 100644
index 0000000..b148b40
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/store.rx
@@ -0,0 +1,5 @@
+export component Counter() {
+ source state := { count: 0, rows: [{ id: 1, label: "A" }, { id: 2, label: "B" }] };
+ total = state.count + state.rows.length;
+ { state.count := state.count + 1; state.rows[0].label := "X" }}>{"" + state.count}/{"" + total}{if state.count > 0 { for row in state.rows { {"" + row.label} } } else { empty }}
+}
diff --git a/tsx-frontend/tsx/fixtures/store.tsx b/tsx-frontend/tsx/fixtures/store.tsx
new file mode 100644
index 0000000..13ae417
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/store.tsx
@@ -0,0 +1,6 @@
+import { createStore, createMemo, Show, For } from "solid-js";
+export function Counter() {
+ const [state, setState] = createStore({ count: 0, rows: [{ id: 1, label: "A" }, { id: 2, label: "B" }] });
+ const total = createMemo(() => state.count + state.rows.length);
+ return setState(d => { d.count += 1; d.rows[0].label = "X"; })}>{state.count}/{total()} 0} fallback={empty }>{row => {row.label} } ;
+}
diff --git a/tsx-frontend/tsx/fixtures/values.rx b/tsx-frontend/tsx/fixtures/values.rx
new file mode 100644
index 0000000..f3971c5
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/values.rx
@@ -0,0 +1,9 @@
+factor: number = 2;
+fn format(n: number): string => "n=" + n;
+export component Counter() {
+ source count := 0;
+ source initial := peek count;
+ label = format(count * factor);
+ frozen = peek count;
+ { count := (previous) => previous + 1 }}>{"" + initial}/{"" + label}/{"" + frozen}/{if count > 1 { {"" + "many"} } else { {"" + "few"} }}/{false ? "" : ""}/{null ? "" : ""}
+}
diff --git a/tsx-frontend/tsx/fixtures/values.tsx b/tsx-frontend/tsx/fixtures/values.tsx
new file mode 100644
index 0000000..c6861c2
--- /dev/null
+++ b/tsx-frontend/tsx/fixtures/values.tsx
@@ -0,0 +1,10 @@
+import { createSignal, createMemo, untrack } from "solid-js";
+const factor: number = 2;
+function format(n: number): string { return "n=" + n; }
+export function Counter() {
+ const [count, setCount] = createSignal(0);
+ const initial = untrack(() => count());
+ const label = createMemo(() => format(count() * factor));
+ const frozen = createMemo(() => untrack(() => count()));
+ return setCount(previous => previous + 1)}>{initial}/{label()}/{frozen()}/{count() > 1 ? "many" : "few"}/{false}/{null} ;
+}
diff --git a/tsx-frontend/tsx/keyed-async.test.ts b/tsx-frontend/tsx/keyed-async.test.ts
new file mode 100644
index 0000000..40ca03c
--- /dev/null
+++ b/tsx-frontend/tsx/keyed-async.test.ts
@@ -0,0 +1,177 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+import { Runtime } from "./rx/runtime.ts";
+
+type Row = { id: number; name: string };
+type Step = { click: true } | { query: number; rows: Row[] } | { query: number; error: string };
+type Sample = { frame: string; effects: string[] };
+const sample = (frame: string, ...effects: string[]): Sample => ({ frame, effects });
+const record = (kind: string, id: number, name: string) => `log(${kind}, ${id}, ${name})`;
+const settle = async () => { for (let i = 0; i < 4; i++) { await new Promise(resolve => setImmediate(resolve)); flush(); } };
+const button = (node: any): any => node.tag === "button" ? node : node.children?.map(button).find(Boolean);
+
+function interpretedButton(items: any[]): any {
+ for (const item of items) {
+ if (item.k === "el" && item.tag === "button") return item;
+ const nested = item.children ?? item.inst?.items ?? item.committed?.inst?.items
+ ?? item.committedRows?.flatMap((row: any) => row.inst.items)
+ ?? ((item.k === "loading" || item.k === "errored") ? (item.committedFallback ? item.fallback : item.content)?.items : []);
+ const found = interpretedButton(nested ?? []); if (found) return found;
+ }
+}
+
+async function trace(source: string, steps: Step[], engine: "native" | "compiled" | "interpreter"): Promise {
+ const files: Record = {
+ "main.tsx": source,
+ "api.d.rx": "export pure fn fetchRows(query: number): { id: number; name: string }[]\nexport fn log(kind: string, id: number, name: string)\n",
+ };
+ if (engine === "interpreter") {
+ const built = buildTsx("main.tsx", path => files[path], { mount: "Counter" });
+ const runtime = new Runtime(built.set);
+ const out = [sample(runtime.step([]).at(-1)!.frame, ...runtime.log)];
+ for (const step of steps) {
+ const start = runtime.log.length;
+ let frame: string;
+ if ("click" in step) {
+ const node = interpretedButton(runtime.view.items)?.attrs.find((attr: any) => attr.name === "onClick")?.node;
+ assert.ok(node, "interpreter must execute the authored handler");
+ const handler = node.compute(runtime); assert.equal(typeof handler, "function"); handler(); frame = runtime.handler([]).at(-1)!.frame;
+ } else frame = runtime.land(JSON.stringify("fetchRows:" + step.query), "rows" in step ? step.rows : new Error(step.error), "error" in step).at(-1)!.frame;
+ out.push(sample(frame, ...runtime.log.slice(start)));
+ }
+ return out;
+ }
+ const root = fileURLToPath(new URL("../.out/", import.meta.url)); mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "keyed-async-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ const requests = new Map void; reject: (error: Error) => void }>();
+ const fetchRows = (query: number) => new Promise((resolve, reject) => { assert.ok(!requests.has(query), "one request per query"); requests.set(query, { resolve, reject }); });
+ const app = createApp({ api: { fetchRows } });
+ (globalThis as any).__keyedAsyncFetch = fetchRows;
+ (globalThis as any).__keyedAsyncLog = (...args: unknown[]) => app.log.push(`log(${args.map(String).join(", ")})`);
+ try {
+ writeFileSync(dir + "/api.mjs", "export const fetchRows = query => globalThis.__keyedAsyncFetch(query); export const log = (...args) => globalThis.__keyedAsyncLog(...args);");
+ let entry: string;
+ if (engine === "native") {
+ entry = "native.mts";
+ const code = transform(source, { filename: "main.tsx", generate: "universal", moduleName: STRING_RENDERER }).code.replaceAll('"./api"', '"./api.mjs"');
+ writeFileSync(dir + "/" + entry, code.replaceAll(STRING_RENDERER, renderer));
+ } else {
+ const built = buildTsx("main.tsx", path => files[path], { target: "universal", mount: "Counter" });
+ entry = built.entry;
+ for (const [name, code] of built.files) writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ }
+ const module = await import(pathToFileURL(dir + "/" + entry).href);
+ const dispose = app.mount(engine === "native" ? () => module.Counter() : module.default(app));
+ try {
+ await settle(); const out = [sample(app.frame(), ...app.log)];
+ for (const step of steps) {
+ const start = app.log.length;
+ if ("click" in step) { const handler = button(app.root)?.props.onClick; assert.equal(typeof handler, "function"); handler(); }
+ else { const request = requests.get(step.query); assert.ok(request, engine + " must ask query " + step.query); if ("rows" in step) request.resolve(step.rows); else request.reject(new Error(step.error)); }
+ await settle(); out.push(sample(app.frame(), ...app.log.slice(start)));
+ }
+ return out;
+ } finally { dispose(); }
+ } finally { delete (globalThis as any).__keyedAsyncFetch; delete (globalThis as any).__keyedAsyncLog; rmSync(dir, { recursive: true, force: true }); }
+}
+
+const imports = 'import { createSignal, createStore, createMemo, createProjection, For, Loading, Errored, isPending, untrack } from "solid-js"; import { createRxEffect } from "reactivescript-tsx"; import { fetchRows, log } from "./api";';
+const rowComponent = 'function Row(props: { row: { id: number; name: string } }) { const id: number = untrack(() => props.row.id); const initial: string = untrack(() => props.row.name); createRxEffect([() => props.row.name], name => { log("run", id, name); return () => { log("cleanup", id, name); }; }); return {id}:{props.row.name}/{initial}; ; }';
+const syncSource = imports + rowComponent + 'export function Counter() { const [rows, setRows] = createStore([{ id: 1, name: "a" }, { id: 2, name: "b" }]); const [step, setStep] = createSignal(0); const view = createProjection(() => rows.map(row => ({ id: row.id, name: row.name + "!" })), [], { key: "id" }); return { if (step() === 0) setRows(() => [{ id: 1, name: "a" }, { id: 2, name: "B" }]); else if (step() === 1) setRows(() => [{ id: 2, name: "B" }, { id: 1, name: "a" }]); else if (step() === 2) setRows(() => [{ id: 2, name: "B" }, { id: 3, name: "c" }]); else if (step() === 3) setRows(() => [{ id: 1, name: "aa" }, { id: 3, name: "c" }, { id: 2, name: "B" }]); else if (step() === 4) setRows(() => []); else if (step() === 5) setRows(() => [{ id: 2, name: "bb" }]); else setRows(() => []); setStep(previous => previous + 1); }}>{row => }
; }';
+
+test("S-store-3 structured keyed replacements preserve rows, quiet reorders, and dispose removed effects", async () => {
+ const steps: Step[] = Array.from({ length: 7 }, () => ({ click: true }));
+ const expected = [
+ sample("1:a!/a!;2:b!/b!;", record("run", 1, "a!"), record("run", 2, "b!")),
+ sample("1:a!/a!;2:B!/b!;", record("cleanup", 2, "b!"), record("run", 2, "B!")),
+ sample("2:B!/b!;1:a!/a!;"),
+ sample("2:B!/b!;3:c!/c!;", record("cleanup", 1, "a!"), record("run", 3, "c!")),
+ sample("1:aa!/aa!;3:c!/c!;2:B!/b!;", record("run", 1, "aa!")),
+ sample("", record("cleanup", 1, "aa!"), record("cleanup", 3, "c!"), record("cleanup", 2, "B!")),
+ sample("2:bb!/bb!;", record("run", 2, "bb!")), sample("", record("cleanup", 2, "bb!")),
+ ];
+ for (const engine of ["native", "compiled", "interpreter"] as const) assert.deepEqual(await trace(syncSource, steps, engine), expected, engine);
+});
+
+const asyncStart = imports + rowComponent + 'export function Counter() { const [query, setQuery] = createSignal(0); const raw = createMemo(async (): Promise<{ id: number; name: string }[]> => await fetchRows(query())); const view = createProjection(() => raw(), [], { key: "id" }); return setQuery(previous => previous + 1)}> error:{error().message}
}>loading}>';
+const asyncRows = '{row => }
';
+const asyncEnd = " ; }";
+const asyncSource = asyncStart + '{raw().length} {isPending(() => raw()) ? "pending" : "idle"}|' + asyncRows + " " + asyncEnd;
+
+test("async structured keyed replacements retain matching rows and suppress obsolete answers", async () => {
+ const steps: Step[] = [
+ { query: 0, rows: [{ id: 1, name: "a" }, { id: 2, name: "b" }] }, { click: true },
+ { query: 1, rows: [{ id: 2, name: "B" }, { id: 1, name: "a" }] }, { click: true },
+ { query: 2, rows: [{ id: 2, name: "B" }, { id: 3, name: "c" }] }, { click: true },
+ { query: 3, error: "offline" }, { click: true }, { click: true },
+ { query: 5, rows: [{ id: 3, name: "C" }, { id: 2, name: "BB" }, { id: 4, name: "d" }] },
+ { query: 4, rows: [{ id: 9, name: "stale" }] }, { click: true },
+ { query: 6, rows: [{ id: 4, name: "d" }, { id: 3, name: "C" }, { id: 2, name: "BB" }] },
+ { click: true }, { query: 7, rows: [] }, { click: true }, { click: true },
+ { query: 9, rows: [{ id: 2, name: "new" }] }, { query: 8, error: "obsolete" },
+ ];
+ const expected = [
+ sample("loading"), sample("2 idle|1:a/a;2:b/b;", record("run", 1, "a"), record("run", 2, "b")),
+ sample("2 pending|1:a/a;2:b/b;"),
+ sample("2 idle|2:B/b;1:a/a;", record("cleanup", 2, "b"), record("run", 2, "B")),
+ sample("2 pending|2:B/b;1:a/a;"),
+ sample("2 idle|2:B/b;3:c/c;", record("cleanup", 1, "a"), record("run", 3, "c")),
+ sample("2 pending|2:B/b;3:c/c;"), sample("error:offline"), sample("error:offline"), sample("error:offline"),
+ sample("3 idle|3:C/c;2:BB/b;4:d/d;", record("run", 4, "d"), record("cleanup", 3, "c"), record("run", 3, "C"), record("cleanup", 2, "B"), record("run", 2, "BB")),
+ sample("3 idle|3:C/c;2:BB/b;4:d/d;"), sample("3 pending|3:C/c;2:BB/b;4:d/d;"),
+ sample("3 idle|4:d/d;3:C/c;2:BB/b;"), sample("3 pending|4:d/d;3:C/c;2:BB/b;"),
+ sample("0 idle|", record("cleanup", 4, "d"), record("cleanup", 3, "C"), record("cleanup", 2, "BB")),
+ sample("0 pending|"), sample("0 pending|"),
+ sample("1 idle|2:new/new;", record("run", 2, "new")), sample("1 idle|2:new/new;"),
+ ];
+ for (const engine of ["native", "compiled", "interpreter"] as const) assert.deepEqual(await trace(asyncSource, steps, engine), expected, engine);
+});
+
+test("async keyed row retention is isolated per projection with overlapping keys", async () => {
+ const source = imports + rowComponent + `export function Counter() {
+ const [leftQuery, setLeftQuery] = createSignal(0);
+ const [rightQuery, setRightQuery] = createSignal(10);
+ const [step, setStep] = createSignal(0);
+ const leftRaw = createMemo(async (): Promise<{ id: number; name: string }[]> => await fetchRows(leftQuery()));
+ const rightRaw = createMemo(async (): Promise<{ id: number; name: string }[]> => await fetchRows(rightQuery()));
+ const left = createProjection(() => leftRaw(), [], { key: "id" });
+ const right = createProjection(() => rightRaw(), [], { key: "id" });
+ return { if (step() === 0) setLeftQuery(1); else setRightQuery(11); setStep(previous => previous + 1); }}>
+
error:{error().message}
}>
+ left-loading}>{leftRaw().length}|{row => }
+ right-loading}>{rightRaw().length}|{row => }
+
+
;
+ }`;
+ const steps: Step[] = [
+ { query: 0, rows: [{ id: 1, name: "left" }] }, { query: 10, rows: [{ id: 1, name: "right" }] },
+ { click: true }, { query: 1, rows: [{ id: 1, name: "LEFT" }] }, { click: true }, { query: 11, rows: [{ id: 1, name: "RIGHT" }] },
+ ];
+ const expected = [
+ sample("left-loadingright-loading"), sample("1|1:left/left;right-loading", record("run", 1, "left")),
+ sample("1|1:left/left;1|1:right/right;", record("run", 1, "right")), sample("1|1:left/left;1|1:right/right;"),
+ sample("1|1:LEFT/left;1|1:right/right;", record("cleanup", 1, "left"), record("run", 1, "LEFT")),
+ sample("1|1:LEFT/left;1|1:right/right;"),
+ sample("1|1:LEFT/left;1|1:RIGHT/right;", record("cleanup", 1, "right"), record("run", 1, "RIGHT")),
+ ];
+ for (const engine of ["native", "compiled", "interpreter"] as const) assert.deepEqual(await trace(source, steps, engine), expected, engine);
+});
+
+test("DV-8 baseline: a projection-only reader retains rows and swallows later pending and failure", async () => {
+ // This is the recorded limit, not a parity claim: spec/05-holds.md DV-8 and
+ // oracle/repros/dv8-projection.mjs. The visible raw summary in the test above
+ // legitimately reads the memo; this test separately pins the bare projection.
+ const source = asyncStart + '{view.length} {isPending(() => view.length) ? "pending" : "idle"}|' + asyncRows + " " + asyncEnd;
+ const steps: Step[] = [{ query: 0, rows: [] }, { click: true }, { query: 1, rows: [{ id: 1, name: "a" }, { id: 2, name: "b" }] }, { click: true }, { query: 2, error: "offline" }];
+ const rows = sample("2 idle|1:a/a;2:b/b;", record("run", 1, "a"), record("run", 2, "b"));
+ const nativeExpected = [sample("loading"), sample("0 idle|"), sample("0 idle|"), rows, sample(rows.frame), sample(rows.frame)];
+ for (const engine of ["native", "compiled"] as const) assert.deepEqual(await trace(source, steps, engine), nativeExpected, engine + " recorded DV-8");
+ assert.deepEqual(await trace(source, steps, "interpreter"), [sample("loading"), sample("0 idle|"), sample("0 pending|"), rows, sample("2 pending|1:a/a;2:b/b;"), sample("error:offline")], "interpreter rules-derived behavior");
+});
diff --git a/tsx-frontend/tsx/lower.test.ts b/tsx-frontend/tsx/lower.test.ts
new file mode 100644
index 0000000..3f26a33
--- /dev/null
+++ b/tsx-frontend/tsx/lower.test.ts
@@ -0,0 +1,778 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { lowerTsx, TsxError } from "./lower.ts";
+import { check } from "./rx/compiler.ts";
+import { traces } from "./test-utils.ts";
+import { compile } from "./rx/compiler.ts";
+
+const fixture = (name: string) =>
+ readFileSync(new URL("./fixtures/" + name, import.meta.url), "utf8");
+const counter = fixture("counter.tsx");
+const rxCounter = fixture("counter.rx");
+const composition = fixture("composition.tsx");
+
+test("counter: existing AST, checker, compiler and native Solid agree through four clicks", async () => {
+ await traces(counter, rxCounter, ["0", "2", "4", "6", "8"]);
+});
+
+test("declaration and JSX lines remain authored, and non-exported components stay non-exported", () => {
+ const component = lowerTsx(counter).decls[0];
+ assert.equal(component.k, "component");
+ if (component.k !== "component") return;
+ assert.equal(component.line, 3);
+ assert.deepEqual(
+ component.decls.map((d) => d.line),
+ [4, 5],
+ );
+ assert.equal(component.view[0].line, 6);
+ const local = lowerTsx("function Empty() { return
; }");
+ assert.ok(local.decls[0].k === "component");
+ assert.notEqual(local.decls[0].exported, true);
+ assert.deepEqual(check(local), []);
+ assert.deepEqual(local.view, []);
+});
+
+test("import aliases, chained memos, signed initialization, nested text, and block handler", async () => {
+ const tsx = counter
+ .replace(
+ "createSignal, createMemo",
+ "createSignal as signal, createMemo as memo",
+ )
+ .replace("= createSignal(0)", "= signal(-2)")
+ .replace("= createMemo", "= memo")
+ .replace(
+ " return",
+ " const total = memo(() => doubled() + count());\n return",
+ )
+ .replace("() => setCount(count() + 1)", "() => { setCount(count() + 1); }")
+ .replace("{doubled()}
", "value: {total()} ! ");
+ const rx = rxCounter
+ .replace("count := 0", "count := -2")
+ .replace(" ',
+ 'value: {"" + total} ! ',
+ );
+ await traces(tsx, rx, [
+ "value: -6!",
+ "value: -3!",
+ "value: 0!",
+ "value: 3!",
+ "value: 6!",
+ ]);
+});
+
+test("numeric JSX conversion preserves Infinity, NaN, negative Infinity, and signed zero", async () => {
+ for (const [expr, rxExpr, expected] of [
+ [
+ "1 / count()",
+ "1 / count",
+ ["Infinity", "1", "0.5", "0.3333333333333333"],
+ ],
+ ["0 / count()", "0 / count", ["NaN", "0", "0", "0"]],
+ [
+ "-1 / count()",
+ "-1 / count",
+ ["-Infinity", "-1", "-0.5", "-0.3333333333333333"],
+ ],
+ ["-0 * count()", "-0 * count", ["0", "0", "0", "0"]],
+ ] as const) {
+ await traces(
+ counter.replace("count() * 2", expr),
+ rxCounter.replace("count * 2", rxExpr),
+ [...expected],
+ );
+ }
+});
+
+test("typed function-form stores retain structured question-source semantics", () => {
+ const prefix =
+ 'import {createStore,createSignal} from "solid-js";import {fetchState} from "./api";export function Counter(){const [id,setId]=createSignal(1);const [state,setState]=createStore(';
+ const suffix =
+ ");return setState(d=>{d.a=5})}>{state.a}/{state.b} }";
+ for (const compute of [
+ "():{a:number;b:number}=>({a:id(),b:2})",
+ "():{a:number;b:number}=>{return {a:id(),b:2}}",
+ "async ():Promise<{a:number;b:number}>=>await fetchState(id())",
+ "async ():Promise<{a:number;b:number}>=>{return await fetchState(id())}",
+ ]) {
+ const program = lowerTsx(prefix + compute + suffix);
+ assert.deepEqual(
+ check(program).map((d) => d.code),
+ compute.startsWith("async") ? ["IO_IN_REACTIVE"] : [],
+ );
+ const component = program.decls.find((d) => d.k === "component")!;
+ if (component.k !== "component") return assert.fail("missing component");
+ const source = component.decls.find(
+ (d) => d.k === "source" && d.name === "state",
+ );
+ assert.ok(source && source.k === "source");
+ assert.equal(source.question, true);
+ // ST-1: structure is the type, which the frontend writes; the emitter lowers the question as a store from it.
+ assert.equal(source.type, "{ a: number; b: number }");
+ if (!compute.startsWith("async"))
+ assert.match(
+ compile({ ...program, view: [{ k: "el", tag: "Counter", attrs: [], children: [] }] }),
+ /__rt\.qsource\("state",[^\n]*structured: true/,
+ );
+ assert.equal(source.init.k, compute.startsWith("async") ? "await" : "rec");
+ }
+ for (const compute of [
+ "()=>({a:1,b:2})",
+ "(n:number):{a:number;b:number}=>({a:n,b:2})",
+ "async ()=>await fetchState(id())",
+ "async ():Promise<{a:number;b:number}>=>fetchState(id())",
+ "():{a:number;b:number}=>{const a=1;return {a,b:2}}",
+ "():number=>1",
+ ])
+ assert.throws(
+ () => lowerTsx(prefix + compute + suffix),
+ /Function-form stores|function-form stores/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(prefix + '():{a:number;b:number}=>({a:"wrong",b:2})' + suffix),
+ /Store computation does not match/,
+ );
+});
+
+test("only function-form stores retain native standing-question invalidation", () => {
+ for (const operation of ["refresh", "affects"]) {
+ const source =
+ "import {createStore," +
+ operation +
+ '} from "solid-js";export function Counter(){const [state,setState]=createStore(():{a:number}=>({a:1}));return ' +
+ operation +
+ "(state)}>{state.a} }";
+ const program = lowerTsx(source);
+ assert.deepEqual(check(program), []);
+ assert.throws(
+ () => lowerTsx(source.replace("():{a:number}=>({a:1})", "{a:1}")),
+ /not a static store/,
+ );
+ }
+});
+
+test("createPrevious lowers non-self history with a lazy matching fallback", () => {
+ const source =
+ 'import {createSignal,createMemo} from "solid-js";import {createPrevious} from "reactivescript-tsx";export function Counter(){const [n,setN]=createSignal(0);const double=createMemo(()=>n()*2);const previous=createPrevious(double,()=>n()+10);return setN(n()+1)}>{previous()} }';
+ const program = lowerTsx(source);
+ assert.deepEqual(check(program), []);
+ const component = program.decls.find((d) => d.k === "component")!;
+ if (component.k !== "component") return assert.fail("missing component");
+ const previous = component.decls.find(
+ (d) => d.k === "eq" && d.name === "previous",
+ );
+ assert.deepEqual(previous && previous.k === "eq" && previous.e, {
+ k: "pre",
+ name: "double",
+ init: {
+ k: "bin",
+ op: "+",
+ l: { k: "id", name: "n" },
+ r: { k: "lit", v: 10 },
+ },
+ });
+ assert.doesNotThrow(() => lowerTsx(source.replace("double,()=>n()+10", "n")));
+ assert.throws(
+ () =>
+ lowerTsx(
+ source
+ .replace("double,()=>n()+10", "double")
+ .replace(
+ "return
+ lowerTsx(
+ source
+ .replace("{previous()}", "{previous() === undefined ? 0 : previous()}")
+ .replace("double,()=>n()+10", "double"),
+ ),
+ );
+ for (const input of [
+ "previous",
+ "()=>double()",
+ "double()",
+ "42",
+ "missing",
+ ]) {
+ assert.throws(
+ () => lowerTsx(source.replace("double,()=>n()+10", input)),
+ /createPrevious requires an original known local source or memo accessor/,
+ );
+ }
+ assert.throws(
+ () => lowerTsx(source.replace("()=>n()+10", '()=>"wrong"')),
+ /fallback must match/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("()=>n()+10", "async ()=>0")),
+ /synchronous arrow/,
+ );
+ assert.throws(
+ () => lowerTsx(source.replace("()=>n()+10", "value=>0")),
+ /synchronous arrow/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ source.replace("const double=createMemo(()=>n()*2)", "const double=3"),
+ ),
+ /original known local source or memo/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ source
+ .replace(
+ "createSignal,createMemo",
+ "createSignal,createMemo,createStore",
+ )
+ .replace(
+ "const double=createMemo(()=>n()*2)",
+ "const [double,setDouble]=createStore({n:0})",
+ ),
+ ),
+ /original known local source or memo/,
+ );
+});
+
+test("createPrevious cannot construct inherited history in another owned setup", () => {
+ const imports =
+ 'import {createSignal,createMemo} from "solid-js";import {createPrevious} from "reactivescript-tsx";';
+ const moduleTarget =
+ "const [n,setN]=createSignal(0);const double=createMemo(()=>n()*2);";
+ for (const target of ["n", "double"])
+ assert.throws(
+ () =>
+ lowerTsx(
+ imports +
+ moduleTarget +
+ "function Panel(){const previous=createPrevious(" +
+ target +
+ ",()=>-1);return {previous()} }export function Counter(){return }",
+ ),
+ /same owned setup/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ imports +
+ "function usePrevious(current:()=>number):()=>number{const previous=createPrevious(current,()=>-1);return previous}export function Counter(){const [n,setN]=createSignal(0);const previous=usePrevious(n);return {previous()} }",
+ ),
+ /createPrevious requires/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ imports +
+ "function useCurrent():()=>number{const [n,setN]=createSignal(0);return n}export function Counter(){const current=useCurrent();const previous=createPrevious(current,()=>-1);return {previous()} }",
+ ),
+ /createPrevious requires/,
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ imports +
+ "function Panel(props:{current:()=>number}){const previous=createPrevious(props.current,()=>-1);return {previous()} }export function Counter(){const [n,setN]=createSignal(0);return }",
+ ),
+ /Accessor props require a separate contract/,
+ );
+ assert.doesNotThrow(() =>
+ lowerTsx(
+ imports +
+ moduleTarget +
+ "const previous=createPrevious(double,()=>0);const older=createPrevious(previous,()=>-1);function Panel(){return {previous()}/{older()} }export function Counter(){return }",
+ ),
+ );
+});
+
+test("createPrevious refuses imported getters without companion memo ownership", () => {
+ const dependency = lowerTsx(
+ 'import {createSignal} from "solid-js";export const [read,setRead]=createSignal(0)',
+ );
+ assert.throws(
+ () =>
+ lowerTsx(
+ 'import {read} from "./source.tsx";import {createPrevious} from "reactivescript-tsx";export function Counter(){const previous=createPrevious(read,()=>0);return {previous()} }',
+ "main.tsx",
+ {
+ modules: new Map([
+ ["./source.tsx", { path: "source.tsx", program: dependency }],
+ ]),
+ },
+ ),
+ /original known local source or memo/,
+ );
+});
+
+// Temporary TSX boundary (https://github.com/ryansolid/reactivescript/issues/20): an authored ordinary async callback
+// is refused at its async node with guidance to opt into an explicit generator action; it is never lowered or
+// silently converted to an action.
+const asyncCallbackRefused = (source: string, at: string) => (error: unknown) => {
+ assert.ok(error instanceof TsxError);
+ assert.equal(error.start, source.indexOf(at));
+ assert.match(error.message, /Ordinary async callbacks are refused for now \(https:\/\/github\.com\/ryansolid\/reactivescript\/issues\/20\)/);
+ assert.match(error.message, /wrap the work in action\(function\* \(\) \{ \.\.\. yield request\(\); \.\.\. \}\) and use yield instead of await/);
+ assert.match(error.message, /opts into action semantics, which hold the action's writes until it completes; it is not a semantics-preserving rewrite/);
+ return true;
+};
+
+test("ordinary async block handlers are refused at the async arrow with or without pre-await writes; explicit generator actions lower (issue #20)", () => {
+ const held =
+ 'import {createSignal} from "solid-js";import {request} from "./api";export function Counter(){const [n,setN]=createSignal(0);return =>{setN(1);const result:number=await request(event.target.value);setN(result);return result+1}}>{n()} }';
+ assert.throws(() => lowerTsx(held), asyncCallbackRefused(held, "async (event"));
+ // Without the pre-await write this body used to lower and was then inferred as an action upstream (AC-1b); it is
+ // refused the same way now instead of gaining held action semantics.
+ const source = held.replace("setN(1);", "");
+ for (const variant of [
+ source,
+ source.replace("Promise", "Promise"),
+ source.replace(
+ "const result:number=await request(event.target.value)",
+ "let result:number=(await request(event.target.value))",
+ ),
+ source.replace(
+ "const result:number=await request(event.target.value);setN(result);return result+1",
+ "await request(event.target.value);return 2",
+ ),
+ ])
+ assert.throws(() => lowerTsx(variant), asyncCallbackRefused(variant, "async (event"));
+ // The suggested opt-in: an explicit generator action, whose yield lowers to the action body's await.
+ const explicit =
+ 'import {createSignal, action} from "solid-js";import {request} from "./api";export function Counter(){const [n,setN]=createSignal(0);const save=action(function*(value:string){try{const result:number=yield request(value);setN(result)}catch(error){setN(-1)}});return save(event.target.value)}>{n()} }';
+ const program = lowerTsx(explicit);
+ assert.deepEqual(check(program), []);
+ const counter = program.decls.find((d) => d.k === "component")!;
+ assert.equal(counter.k, "component");
+ if (counter.k !== "component") return;
+ const save = counter.decls.find((d) => d.k === "action")!;
+ assert.equal(save.k, "action");
+ if (save.k !== "action") return;
+ assert.deepEqual(save.params, ["value"]);
+ const attempt = save.body[0];
+ assert.equal(attempt.k, "try");
+ if (attempt.k !== "try") return;
+ assert.deepEqual(attempt.body.map((s) => s.k), ["let", "write"]);
+ assert.equal(attempt.body[0].k === "let" && attempt.body[0].e.k, "await");
+});
+
+test("async implementations of contextual void callback props are refused at the async arrow (issue #20)", () => {
+ const source =
+ 'import {request} from "./api";function Child(props:{onChange:(value:number)=>void}){return props.onChange(2)}/>}export function Counter(){return {const result:number=await request(next);return result}}/>}';
+ assert.throws(() => lowerTsx(source), asyncCallbackRefused(source, "async next"));
+ const sync = source.replace(
+ "async next=>{const result:number=await request(next);return result}",
+ "(next:number)=>{}",
+ );
+ assert.doesNotThrow(() => lowerTsx(sync));
+ for (const forward of [
+ "onClick={async ()=>{await props.onChange(2)}}",
+ "onClick={async ()=>{const result:number=await props.onChange(2)}}",
+ "onClick={async ()=>{const result=await props.onChange(2);return result}}",
+ "onClick={async ()=>props.onChange(2)}",
+ ]) {
+ const variant = sync.replace("onClick={()=>props.onChange(2)}", forward);
+ assert.throws(() => lowerTsx(variant), asyncCallbackRefused(variant, "async ()"));
+ }
+});
+
+test("async handlers are refused before any await-position or body rule, for every body shape (issue #20)", () => {
+ const wrap = (handler: string) =>
+ 'import {createSignal} from "solid-js";import {request} from "./api";export function Counter(){const [n,setN]=createSignal(0);return {n()} }";
+ for (const handler of [
+ // Formerly lowered shapes, including await-then-write, awaits without writes, and early return/throw.
+ "async ()=>{const result:number=await request();setN(result)}",
+ "async ()=>{await request()}",
+ "async (mode:number)=>{if(mode===0)return 7;if(mode===1)throw 1;const answer:number=await 8;return answer}",
+ // Formerly refused by narrower rules, which no longer apply.
+ "async ()=>{setN(await request())}",
+ "async ()=>{if(await request())setN(1)}",
+ "async ()=>{return await request()}",
+ "async ()=>{const result:number=1+(await request())}",
+ "async ()=>{throw await request()}",
+ "async ()=>{setN(1);return 2}",
+ "async ()=>{var result:number=await request()}",
+ "async ()=>{const result=await request()}",
+ "async ()=>setN(1)",
+ "async ()=>await request()",
+ ]) {
+ const source = wrap(handler);
+ assert.throws(() => lowerTsx(source), asyncCallbackRefused(source, "async ("));
+ }
+ const optimistic = wrap("async ()=>{setN(1);await request()}").replaceAll("createSignal", "createOptimistic");
+ assert.throws(() => lowerTsx(optimistic), asyncCallbackRefused(optimistic, "async ("));
+});
+
+const rejected: [string, string, string][] = [
+ [
+ "destructured props parameter",
+ composition.replace("CounterButton(incoming:", "CounterButton({ value }:"),
+ "{ value }",
+ ],
+ [
+ "named props type",
+ composition.replace(
+ "incoming: { direct: number; value: number; onIncrement: () => void }",
+ "incoming: Props",
+ ),
+ "incoming: Props",
+ ],
+ [
+ "optional prop contract",
+ composition.replace("direct: number;", "direct?: number;"),
+ "direct?:",
+ ],
+ [
+ "readonly prop contract",
+ composition.replace("direct: number;", "readonly direct: number;"),
+ "readonly direct:",
+ ],
+ [
+ "optional callback parameter contract",
+ composition.replace(
+ "onIncrement: () => void",
+ "onIncrement: (delta?: number) => void",
+ ),
+ "(delta?:",
+ ],
+ [
+ "callback return contract",
+ composition.replace("onIncrement: () => void", "onIncrement: () => number"),
+ "() => number",
+ ],
+ [
+ "callback outside handler naming",
+ composition.replace("onIncrement: () => void", "increment: () => void"),
+ "increment:",
+ ],
+ [
+ "numeric handler-named prop",
+ composition.replace("direct: number;", "onValue: number;"),
+ "onValue:",
+ ],
+ [
+ "reserved children prop",
+ composition.replace("direct: number;", "children: number;"),
+ "children:",
+ ],
+ [
+ "reserved ref prop",
+ composition.replace("direct: number;", "ref: number;"),
+ "ref:",
+ ],
+ [
+ "reserved prototype prop",
+ composition.replace("direct: number;", "__proto__: number;"),
+ "__proto__:",
+ ],
+ [
+ "duplicate prop contract",
+ composition.replace("direct: number;", "direct: number; direct: number;"),
+ "direct: number; value:",
+ ],
+ [
+ "unknown component prop",
+ composition.replace("direct={count()}", "unknown={count()}"),
+ "unknown=",
+ ],
+ [
+ "missing component prop",
+ composition.replace("direct={count()} ", ""),
+ " setCount(123)}"),
+ "() => setCount(123)",
+ ],
+ [
+ "numeric prop called as accessor",
+ composition.replace(
+ "direct={incoming.direct}",
+ "direct={incoming.direct()}",
+ ),
+ "incoming.direct()",
+ ],
+ [
+ "unknown prop read",
+ composition.replace("incoming.value * 2", "incoming.unknown * 2"),
+ "incoming.unknown",
+ ],
+ [
+ "computed prop read",
+ composition.replace("incoming.value * 2", 'incoming["value"] * 2'),
+ 'incoming["value"]',
+ ],
+ [
+ "callback used as numeric value",
+ composition.replace("incoming.value * 2", "incoming.onIncrement * 2"),
+ "incoming.onIncrement *",
+ ],
+ [
+ "callback invoked in memo",
+ composition.replace("incoming.value * 2", "incoming.onIncrement()"),
+ "incoming.onIncrement()",
+ ],
+ [
+ "numeric prop invoked as callback",
+ composition.replace("props.onIncrement()", "props.value()"),
+ "props.value()",
+ ],
+ [
+ "callback called with arguments",
+ composition.replace("props.onIncrement()", "props.onIncrement(1)"),
+ "props.onIncrement(1)",
+ ],
+ [
+ "assignment to numeric props",
+ composition.replace("props.onIncrement()", "props.value = 3"),
+ "props.value =",
+ ],
+ [
+ "setter passed as callback",
+ composition.replace(
+ "onIncrement={() => setCount(count() + 1)}",
+ "onIncrement={setCount}",
+ ),
+ "setCount}",
+ ],
+ [
+ "eager callback invocation",
+ composition.replace(
+ "onIncrement={() => setCount(count() + 1)}",
+ "onIncrement={setCount(123)}",
+ ),
+ "setCount(123)",
+ ],
+ [
+ "local prop destructuring",
+ composition.replace(
+ "const [count, setCount] = createSignal(10);",
+ "const { value } = incoming;",
+ ),
+ "{ value }",
+ ],
+ [
+ "component spread",
+ composition.replace(
+ " ",
+ "onIncrement={incoming.onIncrement}>hello ",
+ ),
+ "hello",
+ ],
+ [
+ "unknown forwarded callback",
+ composition.replace(
+ "onIncrement={incoming.onIncrement}",
+ "onIncrement={incoming.onUnknown}",
+ ),
+ "incoming.onUnknown",
+ ],
+ [
+ "props import shadowing",
+ composition.replace(
+ "CounterButton(incoming:",
+ "CounterButton(createSignal:",
+ ),
+ "createSignal:",
+ ],
+ [
+ "duplicate component binding",
+ composition + "\nfunction Button() { return
; }",
+ "Button() { return",
+ ],
+ [
+ "direct recursive composition",
+ composition + "\nfunction Recursive() { return ; }",
+ " ",
+ ],
+ [
+ "indirect recursive composition",
+ composition +
+ "\nfunction A() { return ; } function B() { return ; }",
+ " ",
+ ],
+ [
+ "sibling local binding leakage",
+ composition.replace("{props.local} ", "{count()} "),
+ "count()} ",
+ ],
+ [
+ "accessor value is not a read",
+ counter.replace("{doubled()}", "{doubled}"),
+ "doubled}",
+ ],
+ [
+ "memo cannot use accessor value",
+ counter.replace("count() * 2", "count * 2"),
+ "count *",
+ ],
+ [
+ "unknown accessor",
+ counter.replace("count() * 2", "other() * 2"),
+ "other()",
+ ],
+ [
+ "accessor arguments",
+ counter.replace("count() * 2", "count(1) * 2"),
+ "count(1)",
+ ],
+ [
+ "async component",
+ counter.replace("export function", "export async function"),
+ "async function",
+ ],
+ [
+ "async memo",
+ counter.replace("createMemo(()", "createMemo(async ()"),
+ "async ()",
+ ],
+ [
+ "async expression handler",
+ counter.replace("onClick={()", "onClick={async ()"),
+ "async ()",
+ ],
+ [
+ "signal options",
+ counter.replace("createSignal(0)", "createSignal(0, { equals: false })"),
+ "[count",
+ ],
+ [
+ "memo options",
+ counter.replace("count() * 2)", "count() * 2, 0)"),
+ "doubled =",
+ ],
+ [
+ "external reactive primitive without a supported value contract",
+ counter.replace('from "solid-js"', 'from "other"'),
+ "[count",
+ ],
+ [
+ "extra import",
+ counter.replace("createSignal,", "createRoot, createSignal,"),
+ "createRoot",
+ ],
+ [
+ "namespace import",
+ counter.replace("{ createSignal, createMemo }", "* as Solid"),
+ "* as Solid",
+ ],
+ ["type import", counter.replace("import {", "import type {"), "import type"],
+ [
+ "arbitrary call",
+ counter.replace("setCount(count() + 1)", "console.log(count())"),
+ "console.log",
+ ],
+ [
+ "untyped handler parameter",
+ counter.replace("onClick={()", "onClick={(event)"),
+ "event)",
+ ],
+ [
+ "untyped previous-value memo parameter",
+ counter.replace("createMemo(()", "createMemo((previous)"),
+ "previous)",
+ ],
+ ["untyped props", counter.replace("Counter()", "Counter(props)"), "props)"],
+ [
+ "local import shadow",
+ counter.replace("[count, setCount]", "[createSignal, setCount]"),
+ "createSignal, setCount",
+ ],
+ ["mutable binding", counter.replace("const [", "let ["), "let ["],
+ [
+ "generic call",
+ counter.replace("createSignal(0)", "createSignal(0)"),
+ "[count",
+ ],
+ [
+ "overflowing numeric literal",
+ counter.replace("createSignal(0)", "createSignal(1e999)"),
+ "1e999",
+ ],
+ [
+ "typed binding",
+ counter.replace("const doubled =", "const doubled: () => number ="),
+ "doubled:",
+ ],
+ [
+ // A default component is the module's entry (upstream MO-1c, see paired.test.ts); other default exports are refused.
+ "non-component default export",
+ counter.replace("export function Counter", "export default function counter"),
+ "export default",
+ ],
+ ["object JSX", counter.replace("{doubled()}", "{{ value: 1 }}"), "{ value"],
+ [
+ "unknown component JSX",
+ counter.replace("{doubled()}", " "),
+ " {
+ assert.throws(
+ () => lowerTsx(source, "authored.tsx"),
+ (error: unknown) => {
+ assert.ok(error instanceof TsxError);
+ assert.equal(error.filename, "authored.tsx");
+ assert.equal(error.start, source.indexOf(authored));
+ assert.ok(error.end > error.start);
+ assert.match(error.message, /^authored.tsx:[0-9]+:[0-9]+: /);
+ const before = source.slice(0, error.start);
+ assert.equal(error.line, before.split("\n").length);
+ assert.equal(error.column, before.length - before.lastIndexOf("\n"));
+ return true;
+ },
+ );
+ });
+}
+
+test("parser failures have authored locations, including UTF-16 offsets after non-ASCII text", () => {
+ const source = "// \u{1f3b5}\n" + counter.replace("{doubled()}", "{doubled}");
+ assert.throws(
+ () => lowerTsx(source, "unicode.tsx"),
+ (error: unknown) => {
+ assert.ok(error instanceof TsxError);
+ assert.equal(error.start, source.indexOf("doubled}"));
+ return true;
+ },
+ );
+ assert.throws(
+ () => lowerTsx("export function Counter( {", "broken.tsx"),
+ TsxError,
+ );
+});
diff --git a/tsx-frontend/tsx/lower.ts b/tsx-frontend/tsx/lower.ts
new file mode 100644
index 0000000..3151dae
--- /dev/null
+++ b/tsx-frontend/tsx/lower.ts
@@ -0,0 +1,4619 @@
+// A deliberately bounded Solid TSX spelling for the existing ReactiveScript AST.
+// Explicit Node entry: Solid tests use the browser condition, which otherwise selects Oxc's WASM build.
+import {
+ parseSync,
+ type Node,
+ type Span,
+ type ArrowFunctionExpression,
+ type JSXElement,
+ type Function as OxcFunction,
+} from "oxc-parser/src-js/index";
+import { entryView, serverDecl, type Program, type Decl, type Expr, type Markup, type Stmt } from "./rx/ast.ts";
+import { actionInference } from "./rx/infer.ts";
+import type { JsDecl } from "./rx/compiler.ts";
+import { decodeHTML } from "entities";
+
+/** E-9: a fn with ref params or declarations is instantiating (an owned region). */
+const instantiating = (d: Extract) =>
+ d.params.some((p) => p.ref) || d.decls.length > 0;
+/** Frontend-only analysis, never part of the RX AST or signatures: the exports of a lowered TSX module that are immutable
+ * TSX values (setup consts). RX sees ordinary equations or `source x := …` exports; a TSX importer reads these bare,
+ * as values, rather than as accessors, and has no setter for them. */
+const immutableExports = new WeakMap>();
+
+export class TsxError extends Error {
+ filename: string;
+ line: number;
+ column: number;
+ start: number;
+ end: number;
+ constructor(filename: string, source: string, span: Span, message: string) {
+ const before = source.slice(0, span.start);
+ const line = before.split("\n").length;
+ const column = before.length - before.lastIndexOf("\n");
+ super(filename + ":" + line + ":" + column + ": " + message);
+ this.name = "TsxError";
+ this.filename = filename;
+ this.line = line;
+ this.column = column;
+ this.start = span.start;
+ this.end = span.end;
+ }
+}
+
+export type LowerOptions = {
+ modules?: Map;
+ declarations?: Map;
+};
+
+/** A bounded TSX spelling of executable RX. Kinds below only guard supported value/call/render forms. */
+export function lowerTsx(
+ source: string,
+ filename = "input.tsx",
+ options: LowerOptions = {},
+): Program {
+ const fail = (n: Span, message: string): never => {
+ throw new TsxError(filename, source, n, message);
+ };
+ const parsed = parseSync(filename, source, {
+ sourceType: "module",
+ lang: "tsx",
+ });
+ if (parsed.errors.length) {
+ const e = parsed.errors[0];
+ fail(e.labels[0] ?? { start: 0, end: 0 }, e.message);
+ }
+ const line = (n: Span) => source.slice(0, n.start).split("\n").length;
+ // Snapshot and inertness facts are carried on the resolved Binding (`snapshot`, `inert`), never by spelling: owners,
+ // branches and helpers each resolve names through their own scope.
+ /** Literals, allowed names, and operators, conditionals and templates over them: no call, member access, allocation,
+ * reactive read or await, so evaluating it again or later cannot change count, order, identity or failure timing. */
+ const inertWith = (x: unknown, ok: (name: string) => boolean): boolean => {
+ if (Array.isArray(x)) return x.every((y) => inertWith(y, ok));
+ if (!x || typeof x !== "object") return true;
+ const o = x as { k?: string; name?: unknown };
+ if (o.k === "lit") return true;
+ if (o.k === "id") return typeof o.name === "string" && ok(o.name);
+ if (o.k === "peek" || o.k === "un" || o.k === "bin" || o.k === "if" || o.k === "tpl")
+ return Object.entries(x).every(([key, y]) => key === "k" || key === "op" || inertWith(y, ok));
+ return false;
+ };
+ /** Authored spans of setup consts, for diagnostics raised after lowering. */
+ const constSpans = new WeakMap();
+ type Shape = {
+ kind:
+ | "number"
+ | "string"
+ | "boolean"
+ | "null"
+ | "undefined"
+ | "scalar"
+ | "record"
+ | "array"
+ | "view"
+ | "accessor"
+ | "callback";
+ fields?: Map;
+ item?: Shape;
+ params?: Shape[];
+ maybe?: boolean;
+ };
+ type Value = { e: Expr; shape: Shape; capturesProjection?: boolean };
+ type Binding = {
+ kind: "value" | "accessor" | "store";
+ shape: Shape;
+ name?: string;
+ e?: Expr;
+ derivedStore?: boolean;
+ storeQuestion?: boolean;
+ capturesProjection?: boolean;
+ serverQuestion?: boolean;
+ serverMemo?: boolean;
+ previousTarget?: boolean;
+ optimistic?: boolean;
+ ctx?: boolean;
+ props?: boolean; // a component's props: reads are tracked in Solid
+ snapshot?: boolean; // a setup const (or imported TSX const): a never-written eager snapshot, read untracked
+ inert?: boolean; // a snapshot whose initializer is inert (see inertWith)
+ failure?: { params: string[]; shapes: Shape[]; result: Shape };
+ actionParams?: Shape[];
+ server?: {
+ kind: "fn" | "action";
+ params: Shape[];
+ result: Shape;
+ unknownResult?: boolean;
+ };
+ };
+ type Scope = {
+ bindings: Map;
+ setters: Map;
+ names: Set;
+ helpers: Map;
+ contextReads?: boolean;
+ action?: boolean;
+ returnShape?: Shape;
+ effect?: boolean;
+ serverCall?: Node;
+ };
+ type Fn = {
+ fn?: OxcFunction;
+ exported?: boolean;
+ component: boolean;
+ props: string;
+ fields: Map;
+ params: { name: string; shape: Shape; type?: string; ref?: boolean }[];
+ returnsAccessor?: boolean;
+ ret?: Shape;
+ refs: { tag: string; node: Span }[];
+ propsType?: string;
+ decls?: Decl[];
+ };
+ const scalar = (s: Shape) =>
+ !["record", "array", "callback", "view", "accessor"].includes(s.kind);
+ const merge = (a: Shape, b: Shape): Shape =>
+ a.kind === b.kind && (scalar(a) || (matches(a, b) && matches(b, a)))
+ ? { ...a, ...(a.maybe || b.maybe ? { maybe: true } : {}) }
+ : scalar(a) && scalar(b)
+ ? { kind: "scalar" }
+ : fail(
+ parsed.program,
+ "Mixed structured value kinds need an explicit supported shape",
+ );
+ const matches = (actual: Shape, expected: Shape): boolean =>
+ expected.kind === "scalar"
+ ? scalar(actual)
+ : actual.kind === expected.kind &&
+ (expected.kind === "record"
+ ? [...expected.fields!].every(
+ ([key, type]) =>
+ !!actual.fields?.has(key) &&
+ matches(actual.fields.get(key)!, type),
+ )
+ : expected.kind === "callback"
+ ? (actual.params?.length ?? 0) <= (expected.params?.length ?? 0) &&
+ (actual.params ?? []).every((param, i) =>
+ matches(expected.params![i], param),
+ )
+ : expected.kind === "array" || expected.kind === "accessor"
+ ? matches(actual.item!, expected.item!)
+ : true);
+ const handlerName = (s: string) => /^on[A-Z]/.test(s);
+ const viewTypes = new Set();
+ const readType = (n: Node): Shape => {
+ if (
+ n.type === "TSTypeReference" &&
+ !n.typeArguments &&
+ n.typeName.type === "Identifier" &&
+ viewTypes.has(n.typeName.name)
+ )
+ return { kind: "view" };
+ if (
+ n.type === "TSTypeReference" &&
+ !n.typeArguments &&
+ n.typeName.type === "TSQualifiedName" &&
+ n.typeName.left.type === "Identifier" &&
+ n.typeName.left.name === "JSX" &&
+ n.typeName.right.name === "Element"
+ )
+ return { kind: "view" };
+ if (n.type === "TSNumberKeyword") return { kind: "number" };
+ if (n.type === "TSStringKeyword") return { kind: "string" };
+ if (n.type === "TSBooleanKeyword") return { kind: "boolean" };
+ if (n.type === "TSUndefinedKeyword" || n.type === "TSVoidKeyword")
+ return { kind: "undefined" };
+ if (n.type === "TSLiteralType") {
+ if (n.literal.type === "Literal")
+ return {
+ kind:
+ n.literal.value === null
+ ? "null"
+ : (typeof n.literal.value as "number" | "string" | "boolean"),
+ };
+ }
+ if (n.type === "TSUnionType") return n.types.map(readType).reduce(merge);
+ if (n.type === "TSArrayType")
+ return { kind: "array", item: readType(n.elementType) };
+ if (
+ n.type === "TSFunctionType" &&
+ !n.typeParameters &&
+ n.returnType.typeAnnotation.type === "TSVoidKeyword"
+ ) {
+ const params = n.params.map((param) => {
+ if (
+ param.type !== "Identifier" ||
+ param.optional ||
+ !param.typeAnnotation
+ )
+ return fail(
+ n,
+ "Callback parameters require explicit required value types",
+ );
+ return readType(param.typeAnnotation.typeAnnotation);
+ });
+ return { kind: "callback", ...(params.length ? { params } : {}) };
+ }
+ if (n.type === "TSFunctionType" && !n.params.length && !n.typeParameters)
+ return { kind: "accessor", item: readType(n.returnType.typeAnnotation) };
+ if (n.type === "TSTypeLiteral") {
+ const fields = new Map();
+ for (const field of n.members) {
+ if (
+ field.type !== "TSPropertySignature" ||
+ field.computed ||
+ field.optional ||
+ field.readonly ||
+ field.key.type !== "Identifier" ||
+ !field.typeAnnotation
+ )
+ return fail(
+ field,
+ "Object contracts require explicit, required identifier properties",
+ );
+ const key = field.key.name;
+ if (["__proto__", "ref"].includes(key) || fields.has(key))
+ return fail(field, "Reserved or duplicate prop: " + key);
+ const type = readType(field.typeAnnotation.typeAnnotation);
+ if (key === "children" && type.kind !== "view")
+ return fail(
+ field,
+ "Component children require an explicit JSX.Element slot contract",
+ );
+ fields.set(key, type);
+ }
+ return { kind: "record", fields };
+ }
+ return fail(
+ n,
+ "Unsupported type: use explicit primitive, scalar union, array, object, or () => void contracts",
+ );
+ };
+ const printType = (s: Shape): string =>
+ s.kind === "callback"
+ ? s.params?.length
+ ? "(" +
+ s.params
+ .map((param, i) => "arg" + i + ": " + printType(param))
+ .join(", ") +
+ ") => void"
+ : "action catches(nothing)"
+ : s.kind === "scalar"
+ ? "number | string | boolean | null | undefined"
+ : s.kind === "array"
+ ? "(" + printType(s.item!) + ")[]"
+ : s.kind === "record"
+ ? "{ " +
+ [...s.fields!]
+ .map(([key, type]) => key + ": " + printType(type))
+ .join("; ") +
+ " }"
+ : s.kind === "view"
+ ? "JSX.Element"
+ : s.kind === "accessor"
+ ? "() => " + printType(s.item!)
+ : s.kind;
+ const typeText = (text: string, at: Span): Shape => {
+ const p = parseSync(
+ "contract.tsx",
+ "function Contract(value: " +
+ text.replaceAll("action catches(nothing)", "() => void") +
+ ") {}",
+ { lang: "tsx", sourceType: "module" },
+ );
+ if (p.errors.length)
+ return fail(at, "Unsupported imported type contract: " + text);
+ const fn = p.program.body[0];
+ if (
+ fn.type !== "FunctionDeclaration" ||
+ fn.params[0]?.type !== "Identifier" ||
+ !fn.params[0].typeAnnotation
+ )
+ return fail(at, "Unsupported imported contract");
+ // Imported contracts have synthetic offsets; report failures at the authored import instead.
+ try {
+ return readType(fn.params[0].typeAnnotation.typeAnnotation);
+ } catch {
+ return fail(at, "Unsupported imported type contract: " + text);
+ }
+ };
+ const authoredBindings = new Set();
+ const arrayBuiltins = new Set();
+ const bind = (n: Node, names: Set, annotation = false): string => {
+ if (
+ n.type !== "Identifier" ||
+ (!annotation && n.typeAnnotation) ||
+ n.optional ||
+ n.decorators?.length
+ )
+ return fail(n, "Expected an identifier binding");
+ if (/^\$[0-9]+$/.test(n.name))
+ return fail(
+ n,
+ "Bindings named $ are reserved for RX effect samples",
+ );
+ if (arrayBuiltins.has(n.name))
+ return fail(
+ n,
+ "Array predicate builtin collides with an authored binding: " + n.name,
+ );
+ if (names.has(n.name))
+ return fail(n, "Duplicate or shadowed binding: " + n.name);
+ names.add(n.name);
+ authoredBindings.add(n.name);
+ return n.name;
+ };
+ const arrow = (
+ n: Node,
+ count = 0,
+ ): import("oxc-parser").ArrowFunctionExpression => {
+ if (
+ n.type !== "ArrowFunctionExpression" ||
+ n.async ||
+ n.params.length !== count ||
+ n.typeParameters ||
+ n.returnType
+ )
+ return fail(
+ n,
+ "Expected a synchronous arrow with " + count + " parameter(s)",
+ );
+ return n;
+ };
+ const hasLexicalAwait = (node: unknown): boolean => {
+ if (!node || typeof node !== "object") return false;
+ if (Array.isArray(node)) return node.some(hasLexicalAwait);
+ const n = node as Node;
+ if (
+ [
+ "ArrowFunctionExpression",
+ "FunctionExpression",
+ "FunctionDeclaration",
+ ].includes(n.type)
+ )
+ return false;
+ return (
+ n.type === "AwaitExpression" || Object.values(node).some(hasLexicalAwait)
+ );
+ };
+ // Temporary TSX boundary while https://github.com/ryansolid/reactivescript/issues/20 is open: an authored ordinary
+ // async callback is refused at its async node, never lowered or silently converted to an action. This covers
+ // callbacks only; async memo, function-form store and server computations keep their own routes.
+ const asyncIssue = "https://github.com/ryansolid/reactivescript/issues/20";
+ const asyncCallback = (n: Span): never =>
+ fail(
+ n,
+ "Ordinary async callbacks are refused for now (" +
+ asyncIssue +
+ "): wrap the work in action(function* () { ... yield request(); ... }) and use yield instead of await. This opts into action semantics, which hold the action's writes until it completes; it is not a semantics-preserving rewrite of the async callback",
+ );
+ const asyncAction = (n: Span): never =>
+ fail(
+ n,
+ "Actions take a synchronous generator, not an async function or async generator (" +
+ asyncIssue +
+ "): write action(function* () { ... yield request(); ... }) and use yield instead of await",
+ );
+ const functions = new Map();
+ const primitives = new Map();
+ const jsFunctions = new Map();
+ const contexts = new Map();
+ const sourceContexts = new Set();
+ const moduleNames = new Set();
+ const moduleScope: Scope = {
+ bindings: new Map(),
+ setters: new Map(),
+ names: moduleNames,
+ helpers: functions,
+ };
+ const result: Program = { decls: [], view: [] };
+ const constants: {
+ node: Extract;
+ exported: boolean;
+ }[] = [];
+ let declarationsStarted = false;
+ /** The module's `export default function Name` component: its entry (upstream MO-1c). */
+ let defaultComponent: string | undefined;
+ /** Untracked captures of structured store objects. RX has only a value snapshot (peek); Solid returns the live
+ * proxy. They agree only while nothing updates the captured object in place, which is checked after lowering. */
+ const structuredCaptures: { store: string; path: string[]; at: Span; derived: boolean }[] = [];
+ const collectFunction = (
+ fn: OxcFunction,
+ exported: boolean,
+ names: Set,
+ into = functions,
+ ) => {
+ // A named async function inside a component is a stored callback, refused with the same guidance.
+ if (fn.async && into !== functions && fn.id && !/^[A-Z]/.test(fn.id.name))
+ return asyncCallback(fn);
+ if (
+ !fn.id ||
+ !fn.body ||
+ fn.async ||
+ fn.generator ||
+ fn.declare ||
+ fn.typeParameters
+ )
+ return fail(fn, "Expected a named synchronous component or pure helper");
+ const name = bind(fn.id, names);
+ const component = /^[A-Z]/.test(name);
+ const meta: Fn = {
+ fn,
+ exported,
+ component,
+ props: "props",
+ fields: new Map(),
+ params: [],
+ refs: [],
+ };
+ if (component) {
+ if (fn.returnType || fn.params.length > 1)
+ return fail(
+ fn,
+ "Components take zero parameters or one inline-typed props identifier",
+ );
+ if (fn.params.length) {
+ const p = fn.params[0];
+ if (
+ p.type !== "Identifier" ||
+ p.optional ||
+ p.decorators?.length ||
+ p.typeAnnotation?.typeAnnotation.type !== "TSTypeLiteral"
+ )
+ return fail(
+ p,
+ "Props require an identifier with an inline object type; no destructuring or inferred props",
+ );
+ const type = readType(p.typeAnnotation.typeAnnotation);
+ meta.props = p.name;
+ meta.fields = type.fields!;
+ meta.propsType = printType(type);
+ for (const field of p.typeAnnotation.typeAnnotation.members)
+ if (
+ field.type === "TSPropertySignature" &&
+ field.key.type === "Identifier"
+ ) {
+ const key = field.key.name;
+ if (meta.fields.get(key)!.kind === "view" && key !== "children")
+ return fail(
+ field,
+ "JSX.Element contracts currently support only component children slots",
+ );
+ if (meta.fields.get(key)!.kind === "accessor")
+ return fail(
+ field.typeAnnotation!.typeAnnotation,
+ "Accessor props require a separate contract and are currently unsupported",
+ );
+ if (
+ (meta.fields.get(key)!.kind === "callback") !==
+ handlerName(key)
+ )
+ return fail(
+ field,
+ "Only on[A-Z] props may be callbacks, and handler-named props must be () => void",
+ );
+ }
+ }
+ } else {
+ for (const p of fn.params) {
+ if (
+ p.type !== "Identifier" ||
+ p.optional ||
+ p.decorators?.length ||
+ !p.typeAnnotation
+ )
+ return fail(p, "Pure helper parameters require explicit value types");
+ const annotation = p.typeAnnotation.typeAnnotation;
+ const ref =
+ annotation.type === "TSFunctionType" &&
+ !annotation.params.length &&
+ !annotation.typeParameters;
+ const shape = readType(
+ ref ? annotation.returnType.typeAnnotation : annotation,
+ );
+ meta.params.push({
+ name: p.name,
+ shape,
+ type: printType(shape),
+ ref,
+ });
+ }
+ if (!fn.returnType)
+ return fail(fn, "Pure helpers require an explicit return type");
+ const annotation = fn.returnType.typeAnnotation;
+ meta.returnsAccessor =
+ annotation.type === "TSFunctionType" &&
+ !annotation.params.length &&
+ !annotation.typeParameters;
+ meta.ret = readType(
+ meta.returnsAccessor && annotation.type === "TSFunctionType"
+ ? annotation.returnType.typeAnnotation
+ : annotation,
+ );
+ if (!meta.returnsAccessor && meta.params.some((p) => p.ref))
+ return fail(
+ fn,
+ "Accessor parameters currently require an accessor-returning setup helper",
+ );
+ if (meta.ret.kind === "callback")
+ return fail(fn.returnType, "Helpers cannot return callbacks");
+ }
+ into.set(name, meta);
+ };
+ for (const item of parsed.program.body) {
+ if (item.type === "ImportDeclaration") {
+ if (
+ item.importKind === "type" &&
+ item.source.value === "solid-js" &&
+ item.specifiers.length &&
+ item.specifiers.every(
+ (spec) =>
+ spec.type === "ImportSpecifier" &&
+ spec.imported.type === "Identifier" &&
+ ["Element", "JSX"].includes(spec.imported.name),
+ )
+ ) {
+ for (const spec of item.specifiers)
+ if (
+ spec.type === "ImportSpecifier" &&
+ spec.imported.type === "Identifier" &&
+ spec.imported.name === "Element"
+ )
+ viewTypes.add(spec.local.name);
+ continue;
+ }
+ if (
+ declarationsStarted ||
+ item.importKind === "type" ||
+ item.phase ||
+ item.attributes.length ||
+ !item.specifiers.length
+ )
+ return fail(item, "Use named value imports before declarations");
+ const dep = options.modules?.get(String(item.source.value));
+ const names: { name: string; alias: string }[] = [];
+ for (const spec of item.specifiers) {
+ if (
+ spec.type === "ImportSpecifier" &&
+ spec.importKind === "type" &&
+ item.source.value === "solid-js" &&
+ spec.imported.type === "Identifier" &&
+ ["Element", "JSX"].includes(spec.imported.name)
+ ) {
+ if (spec.imported.name === "Element") viewTypes.add(spec.local.name);
+ continue;
+ }
+ if (
+ spec.type !== "ImportSpecifier" ||
+ spec.importKind === "type" ||
+ spec.imported.type !== "Identifier"
+ )
+ return fail(spec, "Only named value imports are supported");
+ const local = bind(spec.local, moduleNames);
+ const imported = spec.imported.name;
+ if (item.source.value === "reactivescript-tsx") {
+ if (
+ ![
+ "defineError",
+ "raiseFailure",
+ "catchFailure",
+ "FailureBoundary",
+ "createSourceContext",
+ "useSourceContext",
+ "serverFunction",
+ "serverAction",
+ "createServerMemo",
+ "createPrevious",
+ "createRxEffect",
+ "refresh",
+ ].includes(imported)
+ )
+ return fail(spec, "Unsupported ReactiveScript helper: " + imported);
+ primitives.set(
+ local,
+ imported === "refresh" ? "extensionRefresh" : imported,
+ );
+ continue;
+ }
+ if (item.source.value === "solid-js") {
+ if (
+ ![
+ "createSignal",
+ "createOptimistic",
+ "createMemo",
+ "createDeferred",
+ "createStore",
+ "createProjection",
+ "createContext",
+ "useContext",
+ "refresh",
+ "affects",
+ "action",
+ "untrack",
+ "latest",
+ "isPending",
+ "Show",
+ "For",
+ "Loading",
+ "Errored",
+ "createEffect",
+ ].includes(imported)
+ )
+ return fail(spec, "Unsupported Solid import: " + imported);
+ primitives.set(local, imported);
+ continue;
+ }
+ if (!dep) {
+ const declarations = options.declarations?.get(
+ String(item.source.value),
+ );
+ const contract = declarations?.find((d) => d.name === imported);
+ if (declarations && !contract)
+ return fail(
+ spec,
+ "JS declaration file does not export " + imported,
+ );
+ if (contract && contract.kind !== "fn")
+ return fail(
+ spec,
+ "Only declared JS function imports are supported in this batch",
+ );
+ jsFunctions.set(local, contract?.params.length);
+ names.push({ name: imported, alias: local });
+ continue;
+ }
+ const d = dep.program.decls.find(
+ (d) =>
+ "exported" in d && d.exported && "name" in d && d.name === imported,
+ );
+ if (!d) return fail(spec, "Dependency does not export " + imported);
+ if (d.k === "component") {
+ const type = d.propsType
+ ? typeText(d.propsType, spec)
+ : { kind: "record" as const, fields: new Map() };
+ if (type.kind !== "record")
+ return fail(
+ spec,
+ "Imported component needs an object prop contract",
+ );
+ functions.set(local, {
+ component: true,
+ props: d.props,
+ fields: type.fields!,
+ params: [],
+ propsType: d.propsType,
+ refs: [],
+ });
+ } else if (d.k === "fn") {
+ if (!d.ret || d.params.some((p) => !p.type))
+ return fail(
+ spec,
+ "Imported helper needs explicit value parameters/return type and a pure expression body",
+ );
+ functions.set(local, {
+ component: false,
+ props: "props",
+ fields: new Map(),
+ params: d.params.map((p) => ({
+ name: p.name,
+ shape: typeText(p.type!, spec),
+ type: p.type,
+ ref: p.ref,
+ })),
+ ret: typeText(d.ret, spec),
+ returnsAccessor: d.params.some((p) => p.ref) || d.decls.length > 0,
+ refs: [],
+ });
+ } else if (d.k === "error") {
+ if (["Error", "Panic"].includes(local))
+ return fail(
+ spec,
+ "Imported failure aliases Error and Panic are reserved RX identities",
+ );
+ const shapes: Shape[] = d.params.map(() => ({ kind: "scalar" }));
+ const result: Shape = {
+ kind: "record",
+ fields: new Map([
+ ["name", { kind: "string" }],
+ ["message", { kind: "string" }],
+ ...d.params.map((param, i): [string, Shape] => [
+ param,
+ shapes[i],
+ ]),
+ ]),
+ };
+ moduleScope.bindings.set(local, {
+ kind: "value",
+ shape: { kind: "callback" },
+ failure: { params: d.params, shapes, result },
+ });
+ } else if (d.k === "action") {
+ moduleScope.bindings.set(local, {
+ kind: "value",
+ shape: { kind: "callback" },
+ actionParams: d.params.map(() => ({ kind: "scalar" })),
+ });
+ } else if (d.k === "server") {
+ // Upstream infers whether a server fn is io from its body; an imported RX server fn carries no authored
+ // loader/action intent, so it is callable as a loader and the shared checker refuses io in reactive scope.
+ moduleScope.bindings.set(local, {
+ kind: "value",
+ shape: { kind: "callback" },
+ server: {
+ kind: "fn",
+ params: d.params.map(() => ({ kind: "scalar" })),
+ result: { kind: "scalar" },
+ unknownResult: true,
+ },
+ });
+ } else if (d.k === "context") {
+ if (!d.type)
+ return fail(
+ spec,
+ "Imported contexts require an explicit read-only primitive contract; writable contexts are unsupported",
+ );
+ const shape = typeText(d.type, spec);
+ if (
+ ![
+ "number",
+ "string",
+ "boolean",
+ "null",
+ "scalar",
+ "accessor",
+ ].includes(shape.kind)
+ )
+ return fail(
+ spec,
+ "Imported contexts currently require a primitive value shape",
+ );
+ contexts.set(local, shape);
+ if (d.source) sourceContexts.add(local);
+ } else if ((d.k === "source" || d.k === "eq") && d.type) {
+ const shape = typeText(d.type, spec);
+ // A TSX setup const is exported as a never-written snapshot source; its readers see a plain value.
+ const plain =
+ dep.path.endsWith(".tsx") &&
+ !!immutableExports.get(dep.program)?.has(d.name);
+ const store =
+ !plain &&
+ dep.path.endsWith(".tsx") &&
+ ((d.k === "source" && ["record", "array"].includes(shape.kind)) ||
+ (d.k === "eq" && (d.key !== undefined || d.e.k === "draft")));
+ moduleScope.bindings.set(local, {
+ kind: store
+ ? "store"
+ : dep.path.endsWith(".tsx") && !plain
+ ? "accessor"
+ : "value",
+ shape,
+ ...(plain ? { snapshot: true } : {}),
+ ...(d.k === "eq" && ["await", "catch"].includes(d.e.k)
+ ? { serverQuestion: true }
+ : {}),
+ ...(store && d.k === "eq" ? { derivedStore: true } : {}),
+ });
+ } else
+ return fail(
+ spec,
+ "Imported value needs an explicit supported type contract",
+ );
+ names.push({ name: imported, alias: local });
+ }
+ if (names.length)
+ result.decls.push({
+ k: "import",
+ names,
+ from: String(item.source.value),
+ pure: false,
+ ...(dep ? { resolved: dep.path } : {}),
+ line: line(item),
+ });
+ continue;
+ }
+ declarationsStarted = true;
+ if (item.type === "ExportDefaultDeclaration") {
+ const dd = item.declaration;
+ if (dd.type !== "FunctionDeclaration" || !dd.id || !/^[A-Z]/.test(dd.id.name))
+ return fail(
+ item,
+ "export default: only a named uppercase component function is a module's default export (the app's entry, MO-1c)",
+ );
+ if (defaultComponent) return fail(item, "A module has one default component");
+ defaultComponent = dd.id.name;
+ collectFunction(dd, true, moduleNames);
+ continue;
+ }
+ const exported = item.type === "ExportNamedDeclaration";
+ const d = exported ? item.declaration : item;
+ if (
+ exported &&
+ (item.source || item.specifiers.length || item.exportKind === "type")
+ )
+ return fail(item, "Only inline named exports are supported");
+ if (d?.type === "FunctionDeclaration")
+ collectFunction(d, exported, moduleNames);
+ else if (d?.type === "VariableDeclaration")
+ constants.push({ node: d, exported });
+ else
+ return fail(
+ item,
+ "Expected component/helper functions or eager const declarations",
+ );
+ }
+ let generated = 0;
+ const fresh = () => {
+ let n: string;
+ do {
+ n = "__tsx" + generated++;
+ } while (moduleNames.has(n) || source.includes(n));
+ return n;
+ };
+ const copy = (s: Scope): Scope => ({
+ bindings: new Map(s.bindings),
+ setters: new Map(s.setters),
+ names: new Set(s.names),
+ helpers: new Map(s.helpers),
+ contextReads: s.contextReads,
+ action: s.action,
+ returnShape: s.returnShape,
+ effect: s.effect,
+ serverCall: s.serverCall,
+ });
+ const serverQuestion = (expression: unknown, scope: Scope): boolean => {
+ if (!expression || typeof expression !== "object") return false;
+ if (Array.isArray(expression))
+ return expression.some((item) => serverQuestion(item, scope));
+ const e = expression as Expr;
+ if (e.k === "id" && scope.bindings.get(e.name)?.serverQuestion) return true;
+ if (
+ e.k === "call" &&
+ e.f.k === "id" &&
+ scope.bindings.get(e.f.name)?.server?.kind === "fn"
+ )
+ return true;
+ return Object.values(expression).some((item) =>
+ serverQuestion(item, scope),
+ );
+ };
+ const failureClasses = (node: Node, scope: Scope): string[] | undefined => {
+ if (node.type === "Identifier" && node.name === "undefined") return;
+ if (node.type !== "ArrayExpression")
+ return fail(
+ node,
+ "Failure classes require a literal array of declared constructors or undefined",
+ );
+ const classes = node.elements.map((item) => {
+ if (
+ item?.type !== "Identifier" ||
+ (!(item.name === "Error" && !scope.names.has("Error")) &&
+ !scope.bindings.get(item.name)?.failure)
+ )
+ return fail(
+ item ?? node,
+ "Failure classes must name declared failure constructors or the built-in Error",
+ );
+ return scope.bindings.get(item.name)?.name ?? item.name;
+ });
+ return node.elements.some(
+ (item) =>
+ item?.type === "Identifier" &&
+ item.name === "Error" &&
+ !scope.names.has("Error"),
+ )
+ ? undefined
+ : classes;
+ };
+ const failureShape = (classes: string[] | undefined, scope: Scope): Shape => {
+ if (!classes)
+ return {
+ kind: "scalar",
+ fields: new Map([
+ ["name", { kind: "scalar" }],
+ ["message", { kind: "scalar" }],
+ ]),
+ };
+ const shapes = classes.map(
+ (name) =>
+ [...scope.bindings.entries()].find(
+ ([local, b]) => (b.name ?? local) === name && b.failure,
+ )?.[1].failure!.result,
+ );
+ const fields = new Map(shapes?.[0]?.fields ?? []);
+ for (const shape of shapes?.slice(1) ?? [])
+ for (const [key, type] of fields) {
+ const other = shape?.fields?.get(key);
+ if (!other || !matches(type, other) || !matches(other, type))
+ fields.delete(key);
+ }
+ return { kind: "record", fields };
+ };
+ const value = (n: Node, scope: Scope, expected?: Shape): Value => {
+ if (n.type === "YieldExpression") {
+ if (
+ !scope.action ||
+ n.delegate ||
+ !n.argument ||
+ n.argument.type !== "CallExpression" ||
+ n.argument.callee.type !== "Identifier" ||
+ !(
+ jsFunctions.has(n.argument.callee.name) ||
+ scope.bindings.get(n.argument.callee.name)?.server
+ )
+ )
+ return fail(
+ n,
+ "Actions currently support only non-delegating yields of imported JS calls",
+ );
+ const inner = copy(scope);
+ inner.serverCall = n.argument;
+ const v = value(n.argument, inner, expected);
+ return { e: { k: "await", e: v.e }, shape: v.shape };
+ }
+ if (n.type === "ParenthesizedExpression") {
+ const inner = copy(scope);
+ if (scope.serverCall === n) inner.serverCall = n.expression;
+ return value(n.expression, inner, expected);
+ }
+ if (n.type === "AwaitExpression") {
+ const inner = copy(scope);
+ inner.serverCall = n.argument;
+ const v = value(n.argument, inner, expected);
+ return { e: { k: "await", e: v.e }, shape: v.shape };
+ }
+ if (
+ n.type === "Literal" &&
+ (n.value === null ||
+ ["number", "string", "boolean"].includes(typeof n.value))
+ ) {
+ if (typeof n.value === "number" && !Number.isFinite(n.value))
+ return fail(
+ n,
+ "Overflowed numeric literals cannot be represented by RX's literal emitter",
+ );
+ return {
+ e: { k: "lit", v: n.value },
+ shape: {
+ kind:
+ n.value === null
+ ? "null"
+ : (typeof n.value as "number" | "string" | "boolean"),
+ },
+ };
+ }
+ if (n.type === "Identifier") {
+ if (n.name === "undefined" && !scope.bindings.has(n.name))
+ return { e: { k: "lit", v: undefined }, shape: { kind: "undefined" } };
+ const b = scope.bindings.get(n.name);
+ if (
+ b?.kind === "accessor" &&
+ expected?.kind === "accessor" &&
+ matches(b.shape, expected.item!)
+ )
+ return {
+ e: {
+ k: "fn",
+ params: [],
+ body: b.e ?? { k: "id", name: b.name ?? n.name },
+ },
+ shape: expected,
+ };
+ if (!b || b.kind === "accessor")
+ return fail(
+ n,
+ b
+ ? "Accessor values must be called, not implicitly read"
+ : "Unknown value: " + n.name,
+ );
+ return {
+ e: b.e ?? { k: "id", name: b.name ?? n.name },
+ shape: b.shape,
+ capturesProjection: b.derivedStore || b.capturesProjection,
+ };
+ }
+ if (n.type === "UnaryExpression" && ["!", "+", "-"].includes(n.operator)) {
+ const v = value(n.argument, scope);
+ if (n.operator !== "!" && v.shape.kind !== "number")
+ return fail(n, "Unary numeric operators require known numeric values");
+ return {
+ e: n.operator === "+" ? v.e : { k: "un", op: n.operator, e: v.e },
+ shape: { kind: n.operator === "!" ? "boolean" : "number" },
+ };
+ }
+ if (n.type === "BinaryExpression" || n.type === "LogicalExpression") {
+ if (
+ ![
+ "+",
+ "-",
+ "*",
+ "/",
+ "%",
+ "===",
+ "!==",
+ "<",
+ ">",
+ "<=",
+ ">=",
+ "&&",
+ "||",
+ "??",
+ ].includes(n.operator)
+ )
+ return fail(
+ n,
+ "Operator is not equivalent across RX execution paths: " + n.operator,
+ );
+ const l = value(n.left, scope),
+ r = value(n.right, scope);
+ if (
+ !["&&", "||", "??"].includes(n.operator) &&
+ (!scalar(l.shape) || !scalar(r.shape))
+ )
+ return fail(
+ n,
+ "Structured values cannot be coerced or compared implicitly",
+ );
+ const op =
+ n.operator === "===" ? "==" : n.operator === "!==" ? "!=" : n.operator;
+ const shape = ["&&", "||", "??"].includes(op)
+ ? merge(l.shape, r.shape)
+ : ["==", "!=", "<", ">", "<=", ">="].includes(op)
+ ? { kind: "boolean" as const }
+ : {
+ kind:
+ n.operator === "+" &&
+ (l.shape.kind === "string" || r.shape.kind === "string")
+ ? ("string" as const)
+ : ("number" as const),
+ };
+ return {
+ e: { k: "bin", op, l: l.e, r: r.e },
+ shape,
+ capturesProjection: l.capturesProjection || r.capturesProjection,
+ };
+ }
+ if (n.type === "ConditionalExpression") {
+ const c = value(n.test, scope),
+ t = value(n.consequent, scope, expected),
+ f = value(n.alternate, scope, expected);
+ return {
+ e: { k: "if", c: c.e, t: t.e, f: f.e },
+ shape:
+ t.e.k === "throw"
+ ? f.shape
+ : f.e.k === "throw"
+ ? t.shape
+ : merge(t.shape, f.shape),
+ capturesProjection: t.capturesProjection || f.capturesProjection,
+ };
+ }
+ if (n.type === "ArrayExpression") {
+ const items = n.elements.map((i): Value => {
+ if (!i) return fail(n, "Array holes are unsupported");
+ if (i.type === "SpreadElement") {
+ const v = value(i.argument, scope);
+ if (v.shape.kind !== "array")
+ return fail(i, "Array spreads require a known array value");
+ return {
+ e: { k: "spread", e: v.e },
+ shape: v.shape.item!,
+ capturesProjection: v.capturesProjection,
+ };
+ }
+ return value(i, scope);
+ });
+ return {
+ e: { k: "arr", items: items.map((v) => v.e) },
+ capturesProjection: items.some((v) => v.capturesProjection),
+ shape: {
+ kind: "array",
+ item: items.length
+ ? items.map((v) => v.shape).reduce(merge)
+ : expected?.kind === "array"
+ ? expected.item
+ : { kind: "scalar" },
+ },
+ };
+ }
+ if (n.type === "ObjectExpression") {
+ const fields = new Map();
+ const items: { key: string; v: Expr }[] = [];
+ let capturesProjection = false;
+ for (const p of n.properties) {
+ if (
+ p.type !== "Property" ||
+ p.computed ||
+ p.method ||
+ p.kind !== "init" ||
+ p.key.type !== "Identifier" ||
+ ["__proto__", "constructor", "prototype"].includes(p.key.name)
+ )
+ return fail(
+ p,
+ "Only plain identifier data properties are supported; no spreads/getters/methods/prototype keys",
+ );
+ if (fields.has(p.key.name)) return fail(p, "Duplicate object property");
+ const v = value(p.value, scope);
+ capturesProjection ||= !!v.capturesProjection;
+ fields.set(p.key.name, v.shape);
+ items.push({ key: p.key.name, v: v.e });
+ }
+ return {
+ e: { k: "rec", fields: items },
+ shape: { kind: "record", fields },
+ capturesProjection,
+ };
+ }
+ if (n.type === "MemberExpression") {
+ const o = value(n.object, scope);
+ if (o.shape.maybe && !n.optional)
+ return fail(n, "Possibly absent values require optional member access");
+ if (!n.computed && n.property.type === "Identifier") {
+ const key = n.property.name;
+ const shape = o.shape.fields
+ ? o.shape.fields?.get(key)
+ : ["array", "string"].includes(o.shape.kind) && key === "length"
+ ? { kind: "number" as const }
+ : undefined;
+ if (!shape) return fail(n, "Unknown or unsupported member: " + key);
+ return {
+ e: {
+ k: "member",
+ o: o.e,
+ p: key,
+ ...(n.optional ? { opt: true } : {}),
+ },
+ shape: { ...shape, maybe: o.shape.maybe },
+ capturesProjection:
+ o.capturesProjection &&
+ !(o.e.k === "id" && scope.bindings.get(o.e.name)?.derivedStore),
+ };
+ }
+ if (n.computed && o.shape.kind === "array") {
+ const i = value(n.property, scope);
+ if (i.shape.kind !== "number")
+ return fail(n.property, "Array indices must be numeric");
+ return {
+ e: {
+ k: "index",
+ o: o.e,
+ i: i.e,
+ ...(n.optional ? { opt: true } : {}),
+ },
+ shape: { ...o.shape.item!, maybe: true },
+ capturesProjection:
+ o.capturesProjection &&
+ !(o.e.k === "id" && scope.bindings.get(o.e.name)?.derivedStore),
+ };
+ }
+ return fail(n, "Computed record access is unsupported");
+ }
+ if (n.type === "ChainExpression") return value(n.expression, scope);
+ if (n.type === "CallExpression" && !n.optional && !n.typeArguments) {
+ if (
+ n.callee.type === "MemberExpression" &&
+ !n.callee.computed &&
+ !n.callee.optional &&
+ n.callee.property.type === "Identifier" &&
+ ["filter", "every", "map"].includes(n.callee.property.name)
+ ) {
+ const method = n.callee.property.name;
+ const array = value(n.callee.object, scope);
+ if (array.shape.kind !== "array" || n.arguments.length !== 1)
+ return fail(
+ n,
+ "Array predicates require one callback and a known array value",
+ );
+ if (authoredBindings.has(method))
+ return fail(n, "Array predicate builtin is shadowed: " + method);
+ arrayBuiltins.add(method);
+ const fn = arrow(n.arguments[0], 1),
+ inner = copy(scope);
+ const item = bind(fn.params[0], inner.names);
+ inner.bindings.set(item, {
+ kind: "value",
+ shape: array.shape.item!,
+ capturesProjection:
+ array.capturesProjection &&
+ !(
+ array.e.k === "id" &&
+ scope.bindings.get(array.e.name)?.derivedStore
+ ),
+ });
+ const body = fn.body;
+ let predicate: Value;
+ const compared =
+ body.type === "BinaryExpression" &&
+ ["===", "!=="].includes(body.operator) &&
+ body.left.type === "Identifier" &&
+ body.left.name === item &&
+ body.right.type === "Identifier"
+ ? scope.bindings.get(body.right.name)
+ : undefined;
+ if (
+ body.type === "BinaryExpression" &&
+ compared?.kind === "store" &&
+ !compared.derivedStore &&
+ !scalar(array.shape.item!) &&
+ matches(compared.shape, array.shape.item!)
+ ) {
+ predicate = {
+ e: {
+ k: "bin",
+ op: body.operator === "===" ? "==" : "!=",
+ l: { k: "id", name: item },
+ r: value(body.right, scope).e,
+ },
+ shape: { kind: "boolean" },
+ };
+ } else predicate = value(body, inner);
+ if (method !== "map" && !scalar(predicate.shape))
+ return fail(
+ body,
+ "Array predicates currently require a supported scalar result",
+ );
+ return {
+ e: {
+ k: "call",
+ f: { k: "id", name: method },
+ args: [array.e, { k: "fn", params: [item], body: predicate.e }],
+ },
+ shape:
+ method === "every"
+ ? { kind: "boolean" }
+ : method === "map"
+ ? { kind: "array", item: predicate.shape }
+ : array.shape,
+ capturesProjection:
+ method === "map"
+ ? !scalar(predicate.shape) && predicate.capturesProjection
+ : method === "filter" && array.capturesProjection,
+ };
+ }
+ if (n.callee.type === "Identifier") {
+ const name = n.callee.name;
+ const b = scope.bindings.get(name);
+ if (b?.server) {
+ if (scope.serverCall !== n)
+ return fail(
+ n,
+ "Server results require await, yield, or a directly returned memo call; a Promise is not a scalar value",
+ );
+ if (n.arguments.length !== b.server.params.length)
+ return fail(
+ n,
+ "Server call arguments must match the declared parameters",
+ );
+ const args = n.arguments.map((arg, i) =>
+ value(arg, scope, b.server!.params[i]),
+ );
+ if (args.some((arg, i) => !matches(arg.shape, b.server!.params[i])))
+ return fail(
+ n,
+ "Server argument does not match its declared value type",
+ );
+ return {
+ e: {
+ k: "call",
+ f: { k: "id", name },
+ args: args.map((arg) => arg.e),
+ },
+ shape: b.server.unknownResult
+ ? (expected ?? b.server.result)
+ : b.server.result,
+ };
+ }
+ if (b?.failure) {
+ if (n.arguments.length !== b.failure.params.length)
+ return fail(
+ n,
+ "Failure constructor arguments must match the declared fields",
+ );
+ const args = n.arguments.map((arg, i) =>
+ value(arg, scope, b.failure!.shapes[i]),
+ );
+ if (args.some((arg, i) => !matches(arg.shape, b.failure!.shapes[i])))
+ return fail(
+ n,
+ "Failure constructor argument does not match its field type",
+ );
+ return {
+ e: {
+ k: "call",
+ f: { k: "id", name: b.name ?? name },
+ args: args.map((arg) => arg.e),
+ },
+ shape: b.failure.result,
+ };
+ }
+ if (b?.kind === "accessor" && !n.arguments.length)
+ return {
+ e: b.e ?? { k: "id", name: b.name ?? name },
+ shape: b.shape,
+ capturesProjection: b.capturesProjection,
+ };
+ if (b?.shape.kind === "accessor" && !n.arguments.length)
+ return {
+ e: {
+ k: "call",
+ f: b.e ?? { k: "id", name: b.name ?? name },
+ args: [],
+ },
+ shape: b.shape.item!,
+ };
+ const primitive = primitives.get(name);
+ if (primitive === "raiseFailure" && n.arguments.length === 1) {
+ const error = value(n.arguments[0], scope);
+ return {
+ e: { k: "throw", e: error.e },
+ shape: expected ?? { kind: "scalar" },
+ };
+ }
+ if (primitive === "catchFailure" && n.arguments.length === 3) {
+ const compute = arrow(n.arguments[0]);
+ const classes = failureClasses(n.arguments[1], scope);
+ const fallback = arrow(n.arguments[2], 1);
+ const inner = copy(scope);
+ const error = bind(fallback.params[0], inner.names);
+ inner.bindings.set(error, {
+ kind: "value",
+ shape: failureShape(classes, scope),
+ });
+ const result = value(compute.body, scope, expected);
+ const alt = value(
+ fallback.body,
+ inner,
+ expected ?? (result.e.k === "throw" ? undefined : result.shape),
+ );
+ return {
+ e: {
+ k: "catch",
+ e: result.e,
+ name: error,
+ classes,
+ alt: alt.e,
+ },
+ shape:
+ result.e.k === "throw"
+ ? alt.shape
+ : merge(result.shape, alt.shape),
+ };
+ }
+ if (
+ primitive === "useContext" &&
+ n.arguments.length === 1 &&
+ n.arguments[0].type === "Identifier"
+ ) {
+ if (scope.contextReads === false)
+ return fail(
+ n,
+ "Context reads inside pure helpers require dynamic caller ownership and are unsupported",
+ );
+ const context = n.arguments[0].name,
+ shape = contexts.get(context);
+ if (sourceContexts.has(context))
+ return fail(
+ n,
+ "Source contexts must be read with useSourceContext, not useContext",
+ );
+ if (!shape)
+ return fail(
+ n.arguments[0],
+ "useContext requires a known module context",
+ );
+ return { e: { k: "use", name: context }, shape };
+ }
+ if (
+ ["untrack", "latest", "isPending"].includes(primitive ?? "") &&
+ n.arguments.length === 1
+ ) {
+ const fn = arrow(n.arguments[0]);
+ const v = value(fn.body, scope);
+ const readsStore = (x: unknown): boolean =>
+ Array.isArray(x)
+ ? x.some(readsStore)
+ : !!x &&
+ typeof x === "object" &&
+ (((x as { k?: string }).k === "id" &&
+ scope.bindings.get((x as { name: string }).name)?.kind === "store") ||
+ Object.values(x).some(readsStore));
+ if (primitive === "untrack" && !scalar(v.shape) && readsStore(v.e)) {
+ const path: string[] = [];
+ let root: Expr = v.e;
+ while (root.k === "member" && !root.opt) {
+ path.unshift(root.p);
+ root = root.o;
+ }
+ const store = root.k === "id" ? scope.bindings.get(root.name) : undefined;
+ if (root.k !== "id" || store?.kind !== "store")
+ return fail(
+ n,
+ "untrack of a structured value of a derived or computed store is unsupported: Solid returns the live store proxy, which shows answers reconciled into it in place, but the RX spelling, peek, snapshots the value at this instant; untrack a primitive field, or read the store path where it is used",
+ );
+ structuredCaptures.push({ store: root.name, path, at: n, derived: !!store.derivedStore });
+ }
+ return {
+ e: {
+ k:
+ primitive === "untrack"
+ ? "peek"
+ : primitive === "latest"
+ ? "latest"
+ : "pending",
+ e: v.e,
+ },
+ shape: primitive === "isPending" ? { kind: "boolean" } : v.shape,
+ capturesProjection: v.capturesProjection,
+ };
+ }
+ const f = scope.helpers.get(name);
+ if (jsFunctions.has(name)) {
+ if (!expected)
+ return fail(
+ n,
+ "JS function results require an explicit supported TS return annotation; declarations do not retain result shapes",
+ );
+ const arity = jsFunctions.get(name);
+ if (arity !== undefined && arity !== n.arguments.length)
+ return fail(
+ n,
+ "JS call argument count does not match its declaration",
+ );
+ return {
+ e: {
+ k: "call",
+ f: { k: "id", name },
+ args: n.arguments.map((a) => value(a, scope).e),
+ },
+ shape: expected,
+ };
+ }
+ if (
+ f &&
+ !f.returnsAccessor &&
+ !f.component &&
+ f.ret &&
+ f.params.length === n.arguments.length
+ ) {
+ const args = n.arguments.map((a) => value(a, scope));
+ args.forEach((a, i) => {
+ if (!matches(a.shape, f.params[i].shape))
+ fail(
+ n.arguments[i],
+ "Helper argument does not match its explicit type",
+ );
+ });
+ return {
+ e: { k: "call", f: { k: "id", name }, args: args.map((v) => v.e) },
+ shape: f.ret,
+ capturesProjection: args.some((arg) => arg.capturesProjection),
+ };
+ }
+ }
+ return fail(
+ n,
+ "Only known zero-argument accessors, supported Solid probes, and explicitly typed pure helpers may be called here",
+ );
+ }
+ if (
+ (n.type === "ArrowFunctionExpression" ||
+ n.type === "FunctionExpression") &&
+ n.async
+ )
+ return asyncCallback(n);
+ return fail(n, "Unsupported value expression: " + n.type);
+ };
+ /** A read Solid tracks (an accessor, a store, props) outside untrack; closures read nothing now. */
+ const tracked = (x: unknown, scope: Scope): boolean => {
+ if (Array.isArray(x)) return x.some((y) => tracked(y, scope));
+ if (!x || typeof x !== "object") return false;
+ const o = x as { k?: string; name?: string };
+ if (o.k === "peek" || o.k === "fn") return false;
+ if (o.k === "id") {
+ const b = scope.bindings.get(o.name!);
+ return b?.kind === "accessor" || b?.kind === "store" || !!b?.props;
+ }
+ return Object.values(x).some((y) => tracked(y, scope));
+ };
+ const refers = (x: unknown): boolean =>
+ Array.isArray(x)
+ ? x.some(refers)
+ : !!x &&
+ typeof x === "object" &&
+ (["id", "use", "pre"].includes((x as { k?: string }).k ?? "") ||
+ Object.values(x).some(refers));
+ /** Reads of earlier setup snapshots (and context lookups, which Solid does not track) inside another eager initializer
+ * are untracked, as they are in TSX. Every other read is left as written: a read Solid tracks is never peeked here. */
+ const peekSnapshots = (e: Expr, scope: Scope): Expr => {
+ const go = (x: unknown): unknown => {
+ if (Array.isArray(x)) return x.map(go);
+ if (!x || typeof x !== "object") return x;
+ const o = x as { k?: string; name?: string };
+ if (o.k === "peek" || o.k === "fn") return x;
+ if (o.k === "id" && scope.bindings.get(o.name!)?.snapshot)
+ return { k: "peek", e: x };
+ if (o.k === "use") return { k: "peek", e: x };
+ return Object.fromEntries(
+ Object.entries(x).map(([key, v]) => [key, go(v)]),
+ );
+ };
+ return go(e) as Expr;
+ };
+ const constant = (
+ node: Extract,
+ scope: Scope,
+ exported = false,
+ ): Decl | undefined => {
+ if (node.kind !== "const" || node.declarations.length !== 1 || node.declare)
+ return fail(node, "Use one const identifier binding per declaration");
+ const d = node.declarations[0];
+ if (!d.init || d.definite || d.id.type !== "Identifier")
+ return fail(
+ d,
+ "Eager constants require one initialized identifier; no destructuring",
+ );
+ const annotated = d.id.typeAnnotation
+ ? readType(d.id.typeAnnotation.typeAnnotation)
+ : undefined;
+ const v = value(d.init, scope, annotated);
+ if (v.shape.kind === "callback" || v.shape.kind === "view")
+ return fail(
+ d.init,
+ "Callback snapshots are unsupported; forward callbacks explicitly",
+ );
+ const type = annotated ?? v.shape;
+ if (!matches(v.shape, type))
+ return fail(d.id, "Const initializer does not match its type");
+ const name = bind(d.id, scope.names, true);
+ let initializer = d.init;
+ while (initializer.type === "ParenthesizedExpression")
+ initializer = initializer.expression;
+ const root =
+ initializer.type === "Identifier"
+ ? scope.bindings.get(initializer.name)
+ : undefined;
+ if (initializer.type === "Identifier" && root?.kind === "store") {
+ // Naming a store reads nothing: native keeps the same live proxy, so the alias is the store binding itself
+ // (a snapshot source would freeze its current object).
+ scope.bindings.set(name, {
+ ...root,
+ shape: type,
+ name: root.name ?? initializer.name,
+ });
+ return;
+ }
+ if (v.capturesProjection && !scalar(type))
+ return fail(
+ d.init,
+ "Structured snapshots containing projection-root proxies require live alias semantics; alias the projection root directly",
+ );
+ // A setup const is evaluated once, eagerly. RX's eager form is a source snapshot (E-4): `untrack(() => e)` is already
+ // `peek(e)`, and reads of earlier setup snapshots are peeked. Any other read stays exactly as written, so a read Solid
+ // tracks (accessor, store, props, an RX import) reaches the shared checker outside peek and is reported as
+ // SNAPSHOT_INITIALIZER at this line (DV-6) — write it inside untrack. No setter is bound, so the source is never written.
+ const init = peekSnapshots(v.e, scope);
+ // An async value read at mount (including one imported from another module, which the block-level check below cannot
+ // see) is refused by its resolved binding.
+ const asyncRead = (x: unknown): boolean => {
+ if (Array.isArray(x)) return x.some(asyncRead);
+ if (!x || typeof x !== "object") return false;
+ const o = x as { k?: string; name?: string };
+ if (o.k === "fn") return false;
+ if (o.k === "await") return true;
+ if (o.k === "id") {
+ const b = scope.bindings.get(o.name!);
+ return !!(b?.serverQuestion || b?.serverMemo);
+ }
+ return Object.values(x).some(asyncRead);
+ };
+ if (asyncRead(v.e))
+ return fail(
+ d.init,
+ "Setup const " +
+ name +
+ " reads an async value at mount; Solid refuses untracked async reads there (PENDING_ASYNC_UNTRACKED_READ). Read it in JSX or a memo",
+ );
+ const inert = inertWith(init, (n) => !!scope.bindings.get(n)?.inert);
+ scope.bindings.set(name, {
+ kind: "value",
+ shape: type,
+ snapshot: true,
+ ...(inert ? { inert: true } : {}),
+ });
+ if (type.kind === "accessor" && !tracked(v.e, scope))
+ // A function value (an accessor-typed context read, a closure) is an equation over the snapshot: it reads nothing
+ // tracked and never changes. A source would hold it as a signal value, where JS cannot tell a stored function from
+ // a compute or updater.
+ return {
+ k: "eq",
+ name,
+ e: init,
+ line: line(node),
+ ...(d.id.typeAnnotation ? { type: printType(type) } : {}),
+ ...(exported ? { exported: true } : {}),
+ };
+ const snapshot: Decl = {
+ k: "source",
+ name,
+ init,
+ line: line(node),
+ ...(d.id.typeAnnotation ? { type: printType(type) } : {}),
+ ...(exported ? { exported: true } : {}),
+ };
+ constSpans.set(snapshot, d);
+ return snapshot;
+ };
+ const target = (
+ n: Node,
+ scope: Scope,
+ drafts: Map,
+ ): { name: string; path: (string | Expr)[]; ctx: boolean } => {
+ const path: (string | Expr)[] = [];
+ let root = n;
+ while (root.type === "MemberExpression" && !root.optional) {
+ if (root.computed) {
+ const index = value(root.property, scope);
+ if (index.shape.kind !== "number")
+ return fail(root.property, "Draft array indices must be numeric");
+ path.unshift(index.e);
+ } else if (root.property.type === "Identifier")
+ path.unshift(root.property.name);
+ else return fail(root, "Unsupported write path");
+ root = root.object;
+ }
+ if (root.type !== "Identifier" || !drafts.has(root.name) || !path.length)
+ return fail(
+ n,
+ "Assignments are only supported on the draft parameter of createStore/createProjection",
+ );
+ const name = drafts.get(root.name)!,
+ binding = scope.bindings.get(name);
+ return {
+ name: binding?.ctx ? binding.name! : name,
+ path,
+ ctx: !!binding?.ctx,
+ };
+ };
+ const statements = (
+ nodes: Node[],
+ scope: Scope,
+ drafts = new Map(),
+ ): Stmt[] =>
+ nodes.flatMap((n) => {
+ if (n.type === "ExpressionStatement")
+ return actionExpr(n.expression, scope, drafts);
+ if (n.type === "VariableDeclaration") {
+ if (
+ n.declarations.length !== 1 ||
+ n.declare
+ )
+ return fail(
+ n,
+ "Use one imperative local binding per declaration",
+ );
+ const d = n.declarations[0];
+ if (!d.init || d.id.type !== "Identifier")
+ return fail(d, "Imperative locals require initialized identifiers");
+ const expected = d.id.typeAnnotation
+ ? readType(d.id.typeAnnotation.typeAnnotation)
+ : undefined;
+ const local = copy(scope);
+ const v = value(d.init, local, expected);
+ if (expected && !matches(v.shape, expected))
+ return fail(
+ d,
+ "Imperative local initializer does not match its annotation",
+ );
+ const name = bind(d.id, scope.names, true);
+ scope.bindings.set(name, { kind: "value", shape: v.shape });
+ return [{ k: "let", name, e: v.e }];
+ }
+ if (n.type === "IfStatement")
+ return [
+ {
+ k: "sif",
+ c: value(n.test, scope).e,
+ t: statements(
+ n.consequent.type === "BlockStatement"
+ ? n.consequent.body
+ : [n.consequent],
+ copy(scope),
+ drafts,
+ ),
+ f: n.alternate
+ ? statements(
+ n.alternate.type === "BlockStatement"
+ ? n.alternate.body
+ : [n.alternate],
+ copy(scope),
+ drafts,
+ )
+ : [],
+ },
+ ];
+ if (n.type === "ReturnStatement") {
+ if (n.argument?.type === "ArrowFunctionExpression") {
+ if (n.argument.async) return asyncCallback(n.argument);
+ arrow(n.argument);
+ if (scope.returnShape && scope.returnShape.kind !== "callback")
+ return fail(
+ n,
+ "Returned cleanup does not match the declared result contract",
+ );
+ const cleanup = copy(scope);
+ cleanup.effect = false;
+ cleanup.returnShape = undefined;
+ return [{ k: "return", e: handler(n.argument, cleanup) }];
+ }
+ const v = n.argument
+ ? value(n.argument, scope, scope.returnShape)
+ : undefined;
+ if (scope.effect && v && v.shape.kind !== "undefined")
+ return fail(
+ n,
+ "Effect apply callbacks may return only a synchronous zero-argument cleanup or undefined",
+ );
+ if (
+ scope.returnShape &&
+ !matches(v?.shape ?? { kind: "undefined" }, scope.returnShape)
+ )
+ return fail(
+ n,
+ "Return value does not match its explicit result contract",
+ );
+ return [{ k: "return", ...(v ? { e: v.e } : {}) }];
+ }
+ if (n.type === "ThrowStatement")
+ return [{ k: "throw", e: value(n.argument, scope).e }];
+ if (n.type === "TryStatement") {
+ const inner = copy(scope);
+ let catchName: string | undefined;
+ let catchClasses: string[] | undefined;
+ let caught = n.handler?.body.body;
+ if (n.handler?.param) {
+ catchName = bind(n.handler.param, inner.names);
+ const guard =
+ caught?.length === 1 && caught[0].type === "IfStatement"
+ ? caught[0]
+ : undefined;
+ const test = guard?.test;
+ const alternate =
+ guard?.alternate?.type === "BlockStatement"
+ ? guard.alternate.body
+ : guard?.alternate
+ ? [guard.alternate]
+ : [];
+ const rethrow =
+ alternate.length === 1 && alternate[0].type === "ThrowStatement"
+ ? alternate[0].argument
+ : undefined;
+ const filters = (test: Node): string[] | undefined => {
+ if (test.type === "ParenthesizedExpression")
+ return filters(test.expression);
+ if (test.type === "LogicalExpression" && test.operator === "||") {
+ const left = filters(test.left),
+ right = filters(test.right);
+ return left && right ? [...left, ...right] : undefined;
+ }
+ if (
+ test.type !== "BinaryExpression" ||
+ test.operator !== "instanceof" ||
+ test.left.type !== "Identifier" ||
+ test.left.name !== catchName
+ )
+ return;
+ const ctor = test.right;
+ if (
+ ctor.type === "MemberExpression" &&
+ !ctor.computed &&
+ !ctor.optional &&
+ ctor.object.type === "Identifier" &&
+ ctor.property.type === "Identifier" &&
+ ctor.property.name === "cls"
+ ) {
+ const binding = scope.bindings.get(ctor.object.name);
+ if (binding?.failure) return [binding.name ?? ctor.object.name];
+ }
+ };
+ if (
+ test &&
+ rethrow?.type === "Identifier" &&
+ rethrow.name === catchName
+ )
+ catchClasses = filters(test);
+ if (catchClasses) {
+ caught =
+ guard!.consequent.type === "BlockStatement"
+ ? guard!.consequent.body
+ : [guard!.consequent];
+ }
+ inner.bindings.set(catchName, {
+ kind: "value",
+ // Untyped JS failures may be primitives; these fields can be undefined, not guaranteed strings.
+ shape: catchClasses
+ ? failureShape(catchClasses, scope)
+ : {
+ kind: "scalar",
+ fields: new Map([
+ ["name", { kind: "scalar" }],
+ ["message", { kind: "scalar" }],
+ ]),
+ },
+ });
+ }
+ return [
+ {
+ k: "try",
+ body: statements(n.block.body, copy(scope), drafts),
+ ...(n.handler
+ ? {
+ catchName,
+ ...(catchClasses ? { catchClasses } : {}),
+ handler: statements(caught!, inner, drafts),
+ }
+ : {}),
+ ...(n.finalizer
+ ? { fin: statements(n.finalizer.body, copy(scope), drafts) }
+ : {}),
+ },
+ ];
+ }
+ return fail(n, "Unsupported imperative statement: " + n.type);
+ });
+ const actionExpr = (
+ n: Node,
+ scope: Scope,
+ drafts = new Map(),
+ ): Stmt[] => {
+ if (n.type === "YieldExpression")
+ return [{ k: "expr", e: value(n, scope, { kind: "undefined" }).e }];
+ if (
+ n.type === "AssignmentExpression" &&
+ ["=", "+=", "-=", "*=", "/=", "%="].includes(n.operator)
+ ) {
+ const t = target(n.left, scope, drafts);
+ const rhs = value(n.right, scope);
+ let shape: Shape | undefined = t.ctx
+ ? contexts.get(t.name)
+ : scope.bindings.get(t.name)?.shape;
+ for (const part of t.path)
+ shape =
+ typeof part === "string" && shape?.kind === "record"
+ ? shape.fields?.get(part)
+ : typeof part !== "string" && shape?.kind === "array"
+ ? shape.item
+ : undefined;
+ if (!shape || !matches(rhs.shape, shape))
+ return fail(
+ n,
+ "Draft assignment must preserve the declared property shape",
+ );
+ const e: Expr =
+ n.operator === "="
+ ? rhs.e
+ : {
+ k: "bin",
+ op: n.operator[0],
+ l: value(n.left, scope).e,
+ r: rhs.e,
+ };
+ return [{ k: "write", target: t, e }];
+ }
+ if (n.type === "CallExpression" && !n.optional && !n.typeArguments) {
+ const factory = n.callee;
+ if (
+ factory.type === "CallExpression" &&
+ factory.callee.type === "Identifier" &&
+ primitives.get(factory.callee.name) === "action"
+ ) {
+ const fn = factory.arguments[0];
+ if (
+ (fn?.type === "FunctionExpression" ||
+ fn?.type === "ArrowFunctionExpression") &&
+ fn.async
+ )
+ return asyncAction(fn);
+ if (
+ factory.optional ||
+ factory.typeArguments ||
+ factory.arguments.length !== 1 ||
+ n.arguments.length ||
+ fn?.type !== "FunctionExpression" ||
+ !fn.generator ||
+ fn.async ||
+ fn.params.length ||
+ fn.typeParameters ||
+ fn.returnType
+ )
+ return fail(
+ n,
+ "Inline actions require an immediately invoked zero-argument synchronous generator",
+ );
+ const inner = copy(scope);
+ inner.action = true;
+ inner.effect = false;
+ inner.returnShape = undefined;
+ return [{ k: "action", body: statements(fn.body!.body, inner) }];
+ }
+ if (
+ n.callee.type === "Identifier" &&
+ ["refresh", "extensionRefresh", "affects"].includes(
+ primitives.get(n.callee.name) ?? "",
+ )
+ ) {
+ if (n.arguments.length !== 1 || n.arguments[0].type !== "Identifier")
+ return fail(
+ n,
+ "Invalidation calls currently require one original accessor or store binding, without property paths",
+ );
+ const name = n.arguments[0].name,
+ binding = scope.bindings.get(name);
+ if (!binding || !["accessor", "store"].includes(binding.kind))
+ return fail(
+ n.arguments[0],
+ "Invalidation targets must be known original reactive bindings, not read values or wrappers",
+ );
+ if (binding.ctx)
+ return fail(
+ n.arguments[0],
+ "Invalidation through a context reader requires source-capability targeting and is unsupported",
+ );
+ if (
+ binding.kind === "store" &&
+ !binding.storeQuestion &&
+ !binding.derivedStore
+ )
+ return fail(
+ n.arguments[0],
+ "Store invalidation requires an original function-form createStore standing question, not a static store",
+ );
+ if (
+ primitives.get(n.callee.name) === "refresh" &&
+ binding.serverQuestion
+ )
+ return fail(
+ n,
+ "Solid refresh cannot invalidate a serverFunction factory cache; use createServerMemo and refresh from reactivescript-ref/tsx",
+ );
+ if (
+ primitives.get(n.callee.name) === "extensionRefresh" &&
+ binding.serverQuestion &&
+ !binding.serverMemo
+ )
+ return fail(
+ n,
+ "Server cache refresh requires an original createServerMemo getter; ordinary or imported async memo cache ownership is not established",
+ );
+ return [
+ {
+ k:
+ primitives.get(n.callee.name) === "affects"
+ ? "affects"
+ : "refresh",
+ name: binding.name ?? name,
+ },
+ ];
+ }
+ if (n.callee.type === "Identifier" && jsFunctions.has(n.callee.name)) {
+ const arity = jsFunctions.get(n.callee.name);
+ if (arity !== undefined && arity !== n.arguments.length)
+ return fail(
+ n,
+ "JS call argument count does not match its declaration",
+ );
+ return [
+ {
+ k: "expr",
+ e: {
+ k: "call",
+ f: { k: "id", name: n.callee.name },
+ args: n.arguments.map((a) => value(a, scope).e),
+ },
+ },
+ ];
+ }
+ if (n.callee.type === "Identifier" && scope.setters.has(n.callee.name)) {
+ const name = scope.setters.get(n.callee.name)!;
+ const b = scope.bindings.get(name)!;
+ if (n.arguments.length !== 1)
+ return fail(n, "Setters take exactly one value or supported updater");
+ const argument = n.arguments[0];
+ if (b.kind === "store") {
+ const fn = arrow(
+ argument,
+ argument.type === "ArrowFunctionExpression" &&
+ argument.params.length === 0 &&
+ argument.body.type !== "BlockStatement"
+ ? 0
+ : 1,
+ );
+ const inner = copy(scope);
+ const param = fn.params.length
+ ? bind(fn.params[0], inner.names)
+ : undefined;
+ if (fn.body.type !== "BlockStatement") {
+ if (b.storeQuestion)
+ return fail(
+ n,
+ "Root updates of a function-form createStore are unsupported: ReactiveScript lowers a structured standing question with path writes only; update fields in a draft block",
+ );
+ if (param)
+ inner.bindings.set(param, { kind: "value", shape: b.shape });
+ const v = value(fn.body, inner, b.shape);
+ if (!matches(v.shape, b.shape))
+ return fail(
+ fn.body,
+ "Store updater must preserve its supported root shape",
+ );
+ if (v.capturesProjection)
+ return fail(
+ fn.body,
+ "Store root updaters containing projection proxies require unsupported live identity semantics",
+ );
+ return [
+ {
+ k: "write",
+ target: {
+ name: b.ctx ? b.name! : name,
+ path: [],
+ ctx: !!b.ctx,
+ },
+ e: { k: "fn", params: param ? [param] : [], body: v.e },
+ },
+ ];
+ }
+ if (!param)
+ return fail(
+ fn,
+ "Store draft blocks require one explicit draft parameter",
+ );
+ inner.bindings.set(param, {
+ kind: "value",
+ name,
+ shape: b.shape,
+ ...(b.ctx ? { e: b.e } : {}),
+ });
+ const written = new Set();
+ const checkReads = (node: unknown): void => {
+ if (!node || typeof node !== "object") return;
+ if (Array.isArray(node)) {
+ node.forEach(checkReads);
+ return;
+ }
+ const expr = node as Node;
+ if (expr.type === "MemberExpression") {
+ let root: Node = expr;
+ let key: string | undefined;
+ while (root.type === "MemberExpression") {
+ key =
+ !root.computed && root.property.type === "Identifier"
+ ? root.property.name
+ : undefined;
+ if (root.computed) checkReads(root.property);
+ root = root.object;
+ }
+ if (
+ root.type === "Identifier" &&
+ [param, name].includes(root.name)
+ ) {
+ if (written.has("*") || (key ? written.has(key) : written.size))
+ fail(
+ expr,
+ "Draft reads after a write to the same field need sequential draft semantics; split this update",
+ );
+ return;
+ }
+ }
+ if (
+ expr.type === "Identifier" &&
+ [param, name].includes(expr.name) &&
+ written.size
+ )
+ fail(expr, "Reading a whole draft after writes is unsupported");
+ if (expr.type === "CallExpression" && written.size)
+ fail(
+ expr,
+ "Calls after draft writes may observe staged values and are unsupported",
+ );
+ Object.values(node).forEach(checkReads);
+ };
+ for (const node of fn.body.body) {
+ if (
+ node.type !== "ExpressionStatement" ||
+ node.expression.type !== "AssignmentExpression"
+ )
+ return fail(
+ node,
+ "Store draft blocks currently support straight-line assignments only",
+ );
+ const assignment = node.expression;
+ checkReads(assignment.right);
+ if (assignment.operator !== "=") checkReads(assignment.left);
+ const path = target(
+ assignment.left,
+ inner,
+ new Map([[param, name]]),
+ ).path;
+ written.add(typeof path[0] === "string" ? path[0] : "*");
+ }
+ return statements(fn.body.body, inner, new Map([[param, name]]));
+ }
+ let e: Expr;
+ if (argument.type === "ArrowFunctionExpression") {
+ const fn = arrow(argument, 1);
+ const inner = copy(scope);
+ const param = bind(fn.params[0], inner.names);
+ inner.bindings.set(param, { kind: "value", shape: b.shape });
+ const v = value(
+ fn.body,
+ inner,
+ fn.body.type === "ArrayExpression" ? b.shape : undefined,
+ );
+ if (b.optimistic && v.capturesProjection && !scalar(v.shape))
+ return fail(
+ fn.body,
+ "Optimistic roots containing projection proxies require unsupported live identity semantics",
+ );
+ if (!matches(v.shape, b.shape))
+ return fail(
+ fn.body,
+ "Updater must preserve the signal's supported value type",
+ );
+ e = { k: "fn", params: [param], body: v.e };
+ } else {
+ const v = value(
+ argument,
+ scope,
+ argument.type === "ArrayExpression" ? b.shape : undefined,
+ );
+ if (b.optimistic && v.capturesProjection && !scalar(v.shape))
+ return fail(
+ argument,
+ "Optimistic roots containing projection proxies require unsupported live identity semantics",
+ );
+ if (!matches(v.shape, b.shape))
+ return fail(
+ argument,
+ "Setter must preserve the signal's supported value type",
+ );
+ e = v.e;
+ }
+ return [
+ {
+ k: b.optimistic ? "optimistic" : "write",
+ target: { name: b.name ?? name, path: [], ctx: b.ctx ?? false },
+ e,
+ },
+ ];
+ }
+ if (n.callee.type === "Identifier") {
+ const binding = scope.bindings.get(n.callee.name);
+ const params =
+ binding?.actionParams ??
+ (binding?.server?.kind === "action"
+ ? binding.server.params
+ : undefined);
+ if (params) {
+ if (params.length !== n.arguments.length)
+ return fail(
+ n,
+ "Action arguments must match its declared parameters",
+ );
+ const args = n.arguments.map((arg, i) =>
+ value(arg, scope, params[i]),
+ );
+ if (args.some((arg, i) => !matches(arg.shape, params[i])))
+ return fail(n, "Action argument does not match its declared type");
+ return [
+ {
+ k: "expr",
+ e: {
+ k: "call",
+ f: { k: "id", name: n.callee.name },
+ args: args.map((arg) => arg.e),
+ },
+ },
+ ];
+ }
+ }
+ if (
+ n.callee.type === "MemberExpression" ||
+ (n.callee.type === "Identifier" &&
+ scope.bindings.get(n.callee.name)?.shape.kind === "callback")
+ ) {
+ const f = value(n.callee, scope);
+ if (f.shape.kind === "callback") {
+ const params = f.shape.params ?? [];
+ if (n.arguments.length !== params.length)
+ return fail(
+ n,
+ "Callback arguments must match the declared parameter count",
+ );
+ const args = n.arguments.map((arg, i) =>
+ value(arg, scope, params[i]),
+ );
+ if (args.some((arg, i) => !matches(arg.shape, params[i])))
+ return fail(
+ n,
+ "Callback argument does not match its declared type",
+ );
+ return [
+ {
+ k: "expr",
+ e: { k: "call", f: f.e, args: args.map((arg) => arg.e) },
+ },
+ ];
+ }
+ }
+ }
+ return fail(
+ n,
+ "Expected a setter, declared callback, or draft assignment; arbitrary imperative calls are unsupported",
+ );
+ };
+ const handler = (n: Node, scope: Scope, expected?: Shape): Expr => {
+ if (
+ n.type === "Identifier" &&
+ scope.bindings.get(n.name)?.actionParams?.length
+ )
+ return fail(
+ n,
+ "Parameterized actions require a zero-argument forwarding handler with explicit arguments",
+ );
+ if (n.type === "MemberExpression" || n.type === "Identifier") {
+ const v = value(n, scope);
+ if (v.shape.kind === "callback") {
+ if (expected && !matches(v.shape, expected))
+ return fail(
+ n,
+ "Forwarded callback does not match its declared parameter contract",
+ );
+ return v.e;
+ }
+ return fail(n, "Only callback members can be forwarded as handlers");
+ }
+ if (
+ (n.type === "ArrowFunctionExpression" ||
+ n.type === "FunctionExpression") &&
+ n.async
+ )
+ return asyncCallback(n);
+ if (
+ n.type !== "ArrowFunctionExpression" ||
+ n.typeParameters ||
+ (n.returnType && n.returnType.typeAnnotation.type !== "TSVoidKeyword")
+ )
+ return fail(
+ n,
+ "Handlers require supported typed arrow parameters; synchronous result annotations must be void",
+ );
+ const fn = n;
+ if (expected && fn.params.length > (expected.params?.length ?? 0))
+ return fail(fn, "Callback parameters exceed its declared contract");
+ if (!expected && fn.params.length > 1)
+ return fail(
+ fn,
+ "Intrinsic handlers currently support one typed event parameter",
+ );
+ const inner = copy(scope);
+ inner.action = false;
+ inner.effect = false;
+ inner.returnShape = undefined;
+ const params = fn.params.map((param, i) => {
+ if (param.type !== "Identifier")
+ return fail(param, "Handler parameters require identifier bindings");
+ const type = param.typeAnnotation
+ ? readType(param.typeAnnotation.typeAnnotation)
+ : expected?.params?.[i];
+ if (!type)
+ return fail(
+ param,
+ "Event parameters require an explicit inline supported value type",
+ );
+ if (expected?.params?.[i] && !matches(expected.params[i], type))
+ return fail(
+ param,
+ "Handler parameter does not match its callback contract",
+ );
+ const name = bind(param, inner.names, true);
+ inner.bindings.set(name, { kind: "value", shape: type });
+ return name;
+ });
+ const stmts =
+ fn.body.type === "BlockStatement"
+ ? statements(fn.body.body, inner)
+ : actionExpr(fn.body, inner);
+ // Inferred actions (pinned upstream AC-1b): a plain Solid callback is never an action, but upstream infers one
+ // from the lowered body. Ordinary async callbacks are refused above (issue #20); this guard still refuses any
+ // lowered body whose writes upstream would hold (AC-1c) while Solid applies them at once. Other inferred actions
+ // (refresh/affects) are left to the shared checker and are covered by the differential suites.
+ const inferred = actionInference(stmts);
+ if (inferred.held.length)
+ return fail(
+ fn,
+ "Solid applies " + inferred.held.join(", ") + " immediately, before the await, but ReactiveScript infers this callback as an action (AC-1b: " + inferred.why + ") and holds that write until the body ends (AC-1c); write after the await, or wrap the callback in action(...) to hold it deliberately",
+ );
+ return {
+ k: "fn",
+ params,
+ body: { k: "lit", v: undefined },
+ stmts,
+ };
+ };
+ const display = (v: Value, at: Span): Markup[] => {
+ if (v.shape.kind === "view") return [{ k: "read", e: v.e, line: line(at) }];
+ if (
+ v.shape.kind === "record" ||
+ v.shape.kind === "callback" ||
+ v.shape.kind === "accessor"
+ )
+ return fail(at, "Plain objects/functions are not Solid JSX children");
+ if (v.shape.kind === "array") {
+ const name = fresh();
+ return [
+ {
+ k: "mfor",
+ name,
+ xs: v.e,
+ decls: [],
+ body: display({ e: { k: "id", name }, shape: v.shape.item! }, at),
+ line: line(at),
+ },
+ ];
+ }
+ if (
+ ["boolean", "null", "undefined"].includes(v.shape.kind) &&
+ !v.shape.maybe
+ )
+ return [
+ {
+ k: "read",
+ e: {
+ k: "if",
+ c: v.e,
+ t: { k: "lit", v: "" },
+ f: { k: "lit", v: "" },
+ },
+ line: line(at),
+ },
+ ];
+ let e: Expr = { k: "bin", op: "+", l: { k: "lit", v: "" }, r: v.e };
+ if (v.shape.kind === "scalar" || v.shape.maybe) {
+ const omitted = [true, false, null, undefined]
+ .map(
+ (x) =>
+ ({ k: "bin", op: "==", l: v.e, r: { k: "lit", v: x } }) as Expr,
+ )
+ .reduce((l, r) => ({ k: "bin", op: "||", l, r }));
+ e = { k: "if", c: omitted, t: { k: "lit", v: "" }, f: e };
+ }
+ return [{ k: "read", e, line: line(at) }];
+ };
+ const text = (raw: string): string => {
+ // Rust char::is_whitespace, used by Solid's trim_jsx_text, is not JS \s.
+ const whitespace =
+ /[\u0009-\u000d \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
+ let s = raw.replace(/\r/g, "");
+ if (s.includes("\n"))
+ s = s
+ .split("\n")
+ .map((line, i) =>
+ i
+ ? line.replace(new RegExp("^" + whitespace.source + "+"), "")
+ : line,
+ )
+ .filter((line) => [...line].some((c) => !whitespace.test(c)))
+ .join(" ");
+ return s.replace(new RegExp(whitespace.source + "+", "g"), " ");
+ };
+ const markup = (n: Node, scope: Scope, owner: Fn): Markup[] => {
+ if (n.type === "ParenthesizedExpression")
+ return markup(n.expression, scope, owner);
+ if (n.type === "JSXFragment")
+ return n.children.flatMap((c) => markup(c, scope, owner));
+ if (n.type === "JSXText") {
+ const s = decodeHTML(text(n.value));
+ return s ? [{ k: "text", s, line: line(n) }] : [];
+ }
+ if (n.type === "JSXExpressionContainer")
+ return n.expression.type === "JSXEmptyExpression"
+ ? []
+ : markup(n.expression, scope, owner);
+ if (n.type === "ArrayExpression")
+ return n.elements.flatMap((e) =>
+ e
+ ? markup(e, scope, owner)
+ : fail(n, "JSX arrays cannot contain holes"),
+ );
+ if (n.type === "ConditionalExpression")
+ return [
+ {
+ k: "mif",
+ c: value(n.test, scope).e,
+ t: markup(n.consequent, scope, owner),
+ tDecls: [],
+ f: markup(n.alternate, scope, owner),
+ fDecls: [],
+ line: line(n),
+ },
+ ];
+ if (n.type === "LogicalExpression" && n.operator === "&&") {
+ const c = value(n.left, scope);
+ return [
+ {
+ k: "mif",
+ c: c.e,
+ t: markup(n.right, scope, owner),
+ tDecls: [],
+ f: display(c, n.left),
+ fDecls: [],
+ line: line(n),
+ },
+ ];
+ }
+ if (n.type !== "JSXElement") return display(value(n, scope), n);
+ const opening = n.openingElement;
+ if (opening.name.type !== "JSXIdentifier" || opening.typeArguments)
+ return fail(
+ opening,
+ "Only intrinsic or named component JSX is supported",
+ );
+ const tag = opening.name.name;
+ const primitive = primitives.get(tag);
+ const attrs = new Map();
+ for (const a of opening.attributes) {
+ if (a.type !== "JSXAttribute" || a.name.type !== "JSXIdentifier")
+ return fail(a, "JSX spreads/namespaces are unsupported");
+ const key = a.name.name;
+ if (attrs.has(key)) return fail(a, "Duplicate JSX attribute: " + key);
+ const quoted =
+ a.value?.type === "Literal" && typeof a.value.value === "string"
+ ? { ...a.value, value: decodeHTML(a.value.value) }
+ : a.value;
+ attrs.set(key, {
+ node:
+ a.value?.type === "JSXExpressionContainer"
+ ? a.value.expression
+ : (quoted ?? {
+ type: "Literal",
+ value: true,
+ start: a.start,
+ end: a.end,
+ raw: "true",
+ }),
+ at: a,
+ });
+ }
+ if (contexts.has(tag)) {
+ const payload = attrs.get("value"),
+ shape = contexts.get(tag)!;
+ if (!payload || attrs.size !== 1)
+ return fail(
+ opening,
+ "Context providers require only a value attribute",
+ );
+ if (sourceContexts.has(tag)) {
+ const tuple = payload.node;
+ if (
+ tuple.type !== "ArrayExpression" ||
+ tuple.elements.length !== 2 ||
+ tuple.elements[0]?.type !== "Identifier" ||
+ tuple.elements[1]?.type !== "Identifier"
+ )
+ return fail(
+ payload.at,
+ "Source context providers require the canonical [read, set] tuple",
+ );
+ const [read, write] = tuple.elements;
+ const binding = scope.bindings.get(read.name);
+ if (
+ scope.setters.get(write.name) !== read.name ||
+ binding?.kind !== (scalar(shape) ? "accessor" : "store") ||
+ binding.optimistic ||
+ binding.ctx ||
+ !matches(binding.shape, shape)
+ )
+ return fail(
+ payload.at,
+ "Source context read and setter must refer to the same source with a compatible authoritative value",
+ );
+ if (!owner.decls)
+ return fail(
+ n,
+ "Source context providers require an owned component region",
+ );
+ const name = "Tsx" + fresh();
+ owner.decls.push({
+ k: "component",
+ name,
+ props: fresh(),
+ decls: [
+ {
+ k: "provide",
+ name: tag,
+ e: binding.e ?? { k: "id", name: binding.name ?? read.name },
+ line: line(n),
+ },
+ ],
+ view: n.children.flatMap((c) => markup(c, scope, owner)),
+ line: line(n),
+ });
+ return [{ k: "el", tag: name, attrs: [], children: [], line: line(n) }];
+ }
+ const v = value(payload.node, scope, shape);
+ if (!matches(v.shape, shape))
+ return fail(
+ payload.at,
+ "Context provider value must match its primitive context shape",
+ );
+ const name = "Tsx" + fresh(),
+ props = fresh(),
+ seed = fresh();
+ if (!owner.decls)
+ return fail(n, "Context providers require an owned component region");
+ owner.decls.push({
+ k: "component",
+ name,
+ props,
+ propsType: "{ value: " + printType(shape) + " }",
+ decls: [
+ // The provider snapshots props.value once at setup (E-4), as the native provider does. An accessor-typed value
+ // is a function, captured by an equation over `peek` (a source cannot hold a function as a value).
+ shape.kind === "accessor"
+ ? {
+ k: "eq",
+ name: seed,
+ e: {
+ k: "peek",
+ e: { k: "member", o: { k: "id", name: props }, p: "value" },
+ },
+ line: line(n),
+ }
+ : {
+ k: "source",
+ name: seed,
+ init: {
+ k: "peek",
+ e: { k: "member", o: { k: "id", name: props }, p: "value" },
+ },
+ line: line(n),
+ },
+ {
+ k: "provide",
+ name: tag,
+ e: { k: "id", name: seed },
+ line: line(n),
+ },
+ ],
+ view: n.children.flatMap((c) => markup(c, scope, owner)),
+ line: line(n),
+ });
+ return [
+ {
+ k: "el",
+ tag: name,
+ attrs: [{ name: "value", e: v.e }],
+ children: [],
+ line: line(n),
+ },
+ ];
+ }
+ if (
+ primitive === "Loading" ||
+ primitive === "Errored" ||
+ primitive === "FailureBoundary"
+ ) {
+ if (
+ [...attrs.keys()].some(
+ (k) =>
+ k !== "fallback" &&
+ !(primitive === "FailureBoundary" && k === "classes") &&
+ !(primitive === "Loading" && k === "on"),
+ )
+ )
+ return fail(opening, "Boundaries currently support only fallback");
+ const classes =
+ primitive === "FailureBoundary" && attrs.has("classes")
+ ? failureClasses(attrs.get("classes")!.node, scope)
+ : undefined;
+ const fallback = attrs.get("fallback");
+ const inner = copy(scope);
+ let fallbackNode = fallback?.node;
+ let errName: string | undefined;
+ if (
+ primitive !== "Loading" &&
+ fallbackNode?.type === "ArrowFunctionExpression"
+ ) {
+ const fn = arrow(fallbackNode, 1);
+ errName = bind(fn.params[0], inner.names);
+ // Solid passes an error accessor. Its unknown payload is intentionally not renderable.
+ inner.bindings.set(errName, {
+ kind: "accessor",
+ shape: failureShape(classes, scope),
+ });
+ fallbackNode = fn.body;
+ } else if (
+ primitive === "FailureBoundary" ||
+ (primitive === "Errored" && !fallbackNode)
+ )
+ return fail(opening, "Errored requires fallback");
+ const body = n.children.flatMap((c) => markup(c, scope, owner));
+ const renderedFallback = fallbackNode
+ ? markup(fallbackNode, inner, owner)
+ : [];
+ return primitive === "Loading"
+ ? [
+ {
+ k: "loading",
+ ...(attrs.has("on")
+ ? { on: value(attrs.get("on")!.node, scope).e }
+ : {}),
+ fallback: renderedFallback,
+ decls: [],
+ body,
+ line: line(n),
+ },
+ ]
+ : [
+ {
+ k: "errored",
+ fallback: renderedFallback,
+ decls: [],
+ body,
+ ...(errName ? { errName } : {}),
+ ...(classes ? { classes } : {}),
+ line: line(n),
+ },
+ ];
+ }
+ if (primitive === "Show") {
+ const when = attrs.get("when");
+ if (
+ !when ||
+ [...attrs.keys()].some((k) => !["when", "fallback"].includes(k))
+ )
+ return fail(
+ opening,
+ "Show supports when and optional fallback, without keyed mode",
+ );
+ const condition = value(when.node, scope);
+ const children = n.children.filter(
+ (c) =>
+ (c.type !== "JSXText" || text(c.value)) &&
+ (c.type !== "JSXExpressionContainer" ||
+ c.expression.type !== "JSXEmptyExpression"),
+ );
+ const callback =
+ children.length === 1 &&
+ children[0].type === "JSXExpressionContainer" &&
+ children[0].expression.type === "ArrowFunctionExpression"
+ ? children[0].expression
+ : undefined;
+ const inner = copy(scope);
+ const tDecls: Decl[] = [];
+ if (callback) {
+ const fn = arrow(callback, 1),
+ name = bind(fn.params[0], inner.names);
+ inner.bindings.set(name, { kind: "accessor", shape: condition.shape });
+ tDecls.push({ k: "eq", name, e: condition.e, line: line(callback) });
+ }
+ return [
+ {
+ k: "mif",
+ c: condition.e,
+ t: callback
+ ? markup(callback.body, inner, owner)
+ : n.children.flatMap((c) => markup(c, scope, owner)),
+ tDecls,
+ f: attrs.has("fallback")
+ ? markup(attrs.get("fallback")!.node, scope, owner)
+ : [],
+ fDecls: [],
+ line: line(n),
+ },
+ ];
+ }
+ if (primitive === "For") {
+ const each = attrs.get("each");
+ if (
+ !each ||
+ [...attrs.keys()].some((k) => !["each", "fallback"].includes(k))
+ )
+ return fail(
+ opening,
+ "For supports each and optional fallback with default identity keying",
+ );
+ const xs = value(each.node, scope);
+ if (xs.shape.kind !== "array")
+ return fail(each.node, "For each must have a known array shape");
+ const children = n.children.filter(
+ (c) => c.type !== "JSXText" || text(c.value),
+ );
+ if (
+ children.length !== 1 ||
+ children[0].type !== "JSXExpressionContainer"
+ )
+ return fail(n, "For requires one item => JSX child callback");
+ const fn = arrow(children[0].expression, 1);
+ const inner = copy(scope);
+ const name = bind(fn.params[0], inner.names);
+ inner.bindings.set(name, { kind: "value", shape: xs.shape.item! });
+ if (fn.body.type === "BlockStatement")
+ return fail(
+ fn.body,
+ "For callbacks currently require expression bodies",
+ );
+ const loop: Markup = {
+ k: "mfor",
+ name,
+ xs: xs.e,
+ decls: [],
+ body: markup(fn.body, inner, owner),
+ line: line(n),
+ };
+ return attrs.has("fallback")
+ ? [
+ {
+ k: "mif",
+ c: {
+ k: "bin",
+ op: ">",
+ l: { k: "member", o: xs.e, p: "length" },
+ r: { k: "lit", v: 0 },
+ },
+ t: [loop],
+ tDecls: [],
+ f: markup(attrs.get("fallback")!.node, scope, owner),
+ fDecls: [],
+ line: line(n),
+ },
+ ]
+ : [loop];
+ }
+ if (primitive)
+ return fail(
+ opening,
+ "This imported Solid control is not lowered yet: " + primitive,
+ );
+ const child = scope.helpers.get(tag);
+ if (!child && !/^[a-z][a-z0-9-]*$/.test(tag))
+ return fail(opening, "Unknown component: " + tag);
+ if (child && !child.component)
+ return fail(opening, "Pure helpers are not components");
+ if (child) {
+ if (
+ child.fields.get("children")?.kind !== "view" &&
+ n.children.some((c) => c.type !== "JSXText" || text(c.value))
+ )
+ return fail(
+ n.children[0],
+ "Component children need a view contract, which is not supported yet",
+ );
+ owner.refs.push({ tag, node: opening });
+ }
+ const out: { name: string; e: Expr }[] = [];
+ for (const [key, a] of attrs) {
+ if (["children", "ref", "__proto__"].includes(key))
+ return fail(a.at, "Reserved JSX attribute: " + key);
+ const expected = child?.fields.get(key);
+ if (child && !expected)
+ return fail(a.at, "Unknown component prop: " + key);
+ if (handlerName(key))
+ out.push({ name: key, e: handler(a.node, scope, expected) });
+ else {
+ const v = value(a.node, scope);
+ if (expected && !matches(v.shape, expected))
+ return fail(
+ a.node,
+ "Component prop does not match its declared type",
+ );
+ if (!child && !scalar(v.shape))
+ return fail(
+ a.node,
+ "Intrinsic attributes currently require primitive values",
+ );
+ out.push({ name: key, e: v.e });
+ }
+ }
+ if (child)
+ for (const key of child.fields.keys())
+ if (key !== "children" && !attrs.has(key))
+ return fail(opening, "Missing required prop: " + key);
+ return [
+ {
+ k: "el",
+ tag,
+ attrs: out,
+ children:
+ child && child.fields.get("children")?.kind !== "view"
+ ? []
+ : n.children.flatMap((c) => markup(c, scope, owner)),
+ line: line(n),
+ },
+ ];
+ };
+ const helper = (name: string, meta: Fn, outer: Scope): Decl => {
+ const fn = meta.fn!,
+ scope = copy(outer),
+ decls: Decl[] = [];
+ scope.contextReads = false;
+ for (const p of meta.params) {
+ if (scope.names.has(p.name))
+ return fail(
+ fn,
+ "Helper parameters cannot shadow outer bindings or each other",
+ );
+ scope.names.add(p.name);
+ scope.bindings.set(p.name, {
+ kind: p.ref ? "accessor" : "value",
+ shape: p.shape,
+ });
+ }
+ const nodes = fn.body!.body,
+ ret = nodes.at(-1);
+ if (!ret || ret.type !== "ReturnStatement" || !ret.argument)
+ return fail(fn, "Pure helpers must end with a return expression");
+ // A pure RX fn is one expression (E-9): a local can only be substituted into the tail. That is sound only when
+ // evaluating its initializer at each use (or not at all, in an untaken branch) is unobservable, so a local must be
+ // inert (literals, parameters, earlier locals, inert module consts, and operators over them). Any other initializer
+ // — a call, member access, record/array, reactive read — is refused; capture and unused locals are refused below.
+ const locals: { name: string; at: Node; e: Expr }[] = [];
+ const mark = (name: string) => "\u0000local:" + name;
+ const helperParams = new Set(meta.params.map((p) => p.name));
+ if (meta.returnsAccessor) decls.push(...setup(nodes.slice(0, -1), scope));
+ else
+ for (const node of nodes.slice(0, -1)) {
+ if (
+ node.type !== "VariableDeclaration" ||
+ node.kind !== "const" ||
+ node.declarations.length !== 1 ||
+ node.declare
+ )
+ return fail(
+ node,
+ "Pure helper setup supports one const identifier binding per declaration only",
+ );
+ const d = node.declarations[0];
+ if (!d.init || d.definite || d.id.type !== "Identifier")
+ return fail(
+ d,
+ "Pure helper locals require one initialized identifier; no destructuring",
+ );
+ let initializer = d.init;
+ while (initializer.type === "ParenthesizedExpression")
+ initializer = initializer.expression;
+ const root =
+ initializer.type === "Identifier"
+ ? scope.bindings.get(initializer.name)
+ : undefined;
+ const annotated = d.id.typeAnnotation
+ ? readType(d.id.typeAnnotation.typeAnnotation)
+ : undefined;
+ const v = value(d.init, scope, annotated);
+ if (v.shape.kind === "callback" || v.shape.kind === "view")
+ return fail(
+ d.init,
+ "Callback snapshots are unsupported; forward callbacks explicitly",
+ );
+ const type = annotated ?? v.shape;
+ if (!matches(v.shape, type))
+ return fail(d.id, "Const initializer does not match its type");
+ if (
+ !(initializer.type === "Identifier" && root?.kind === "store") &&
+ !inertWith(
+ v.e,
+ (n) =>
+ helperParams.has(n) ||
+ !!scope.bindings.get(n)?.inert ||
+ locals.some((l) => mark(l.name) === n),
+ )
+ )
+ return fail(
+ d.init,
+ "Pure helper local " +
+ d.id.name +
+ " cannot be lowered: an RX fn body is one expression, so a local is substituted at its uses, which is only sound for inert initializers (literals, parameters, earlier locals, inert module consts, and operators over them). This initializer could fail, suspend, allocate or read reactive state; write it in the return expression or compute it in the caller",
+ );
+ const local = bind(d.id, scope.names, true);
+ if (initializer.type === "Identifier" && root?.kind === "store") {
+ // Naming a store reads nothing: native keeps the same live proxy, so alias the store binding itself.
+ scope.bindings.set(local, {
+ ...root,
+ shape: type,
+ name: root.name ?? initializer.name,
+ });
+ continue;
+ }
+ scope.bindings.set(local, {
+ kind: "value",
+ shape: type,
+ e: { k: "id", name: mark(local) },
+ ...(v.capturesProjection ? { capturesProjection: true } : {}),
+ });
+ locals.push({ name: local, at: d, e: v.e });
+ }
+ const returned =
+ ret.argument.type === "Identifier"
+ ? scope.bindings.get(ret.argument.name)
+ : undefined;
+ if (meta.returnsAccessor && returned?.kind !== "accessor")
+ return fail(
+ ret,
+ "Setup helpers must return an original accessor binding, not its current value",
+ );
+ const v: Value =
+ meta.returnsAccessor && returned
+ ? {
+ e: returned.e ?? {
+ k: "id",
+ name:
+ returned.name ??
+ (ret.argument as Extract).name,
+ },
+ shape: returned.shape,
+ }
+ : value(ret.argument, scope, meta.ret);
+ if (!matches(v.shape, meta.ret!))
+ return fail(
+ ret.argument,
+ "Helper return does not match its explicit type",
+ );
+ const used = new Set();
+ const names = (x: unknown, out = new Set()): Set => {
+ if (Array.isArray(x)) x.forEach((y) => names(y, out));
+ else if (x && typeof x === "object") {
+ const o = x as { k?: string; name?: unknown };
+ const local =
+ o.k === "id" && typeof o.name === "string"
+ ? locals.find((l) => mark(l.name) === o.name)
+ : undefined;
+ if (local) names(local.e, out);
+ else {
+ if ((o.k === "id" || o.k === "pre") && typeof o.name === "string")
+ out.add(o.name);
+ Object.values(x).forEach((y) => names(y, out));
+ }
+ }
+ return out;
+ };
+ const binders = (x: unknown, out = new Set()): Set => {
+ if (Array.isArray(x)) x.forEach((y) => binders(y, out));
+ else if (x && typeof x === "object") {
+ const o = x as Record;
+ if (o.k === "fn") (o.params as string[]).forEach((p) => out.add(p));
+ if ((o.k === "catch" || o.k === "let") && typeof o.name === "string")
+ out.add(o.name);
+ if (o.k === "try" && typeof o.catchName === "string")
+ out.add(o.catchName);
+ if (o.k === "draft") out.add("d");
+ Object.values(o).forEach((y) => binders(y, out));
+ }
+ return out;
+ };
+ const substitute = (x: Expr): Expr => {
+ const go = (y: unknown, bound: Set): unknown => {
+ if (Array.isArray(y)) return y.map((z) => go(z, bound));
+ if (!y || typeof y !== "object") return y;
+ const o = y as Record;
+ const local =
+ o.k === "id" && typeof o.name === "string"
+ ? locals.find((l) => mark(l.name) === o.name)
+ : undefined;
+ if (local) {
+ used.add(local.name);
+ const captured = [...names(local.e)].find((n) => bound.has(n));
+ if (captured)
+ return fail(
+ local.at,
+ "Pure helper local " +
+ local.name +
+ " cannot be substituted under the inner binding " +
+ captured +
+ "; rename the inner binding",
+ );
+ return go(local.e, bound);
+ }
+ const inner =
+ o.k === "fn" || o.k === "catch" || o.k === "draft" || o.k === "try"
+ ? new Set([...bound, ...binders([y])])
+ : bound;
+ return Object.fromEntries(
+ Object.entries(o).map(([key, z]) => [key, go(z, inner)]),
+ );
+ };
+ const out = go(x, new Set()) as Expr;
+ for (const l of [...locals].reverse())
+ if (!used.has(l.name))
+ return fail(
+ l.at,
+ "Unused pure helper local " +
+ l.name +
+ ": TSX evaluates it eagerly (it could throw or suspend), and a pure RX fn has only its tail; use it or remove it",
+ );
+ return out;
+ };
+ return {
+ k: "fn",
+ name,
+ params: meta.params.map((p) => ({
+ name: p.name,
+ ref: p.ref ?? false,
+ type: p.type,
+ })),
+ decls,
+ tail: substitute(v.e),
+ ret: printType(meta.ret!),
+ line: line(fn),
+ ...(meta.exported ? { exported: true } : {}),
+ };
+ };
+ const setup = (nodes: Node[], scope: Scope, props?: string): Decl[] => {
+ const decls: Decl[] = [];
+ for (const node of nodes) {
+ if (node.type === "FunctionDeclaration") continue;
+ if (
+ node.type === "ExpressionStatement" &&
+ node.expression.type === "CallExpression" &&
+ node.expression.callee.type === "Identifier" &&
+ ["createEffect", "createRxEffect"].includes(
+ primitives.get(node.expression.callee.name) ?? "",
+ )
+ ) {
+ const call = node.expression;
+ if (primitives.get(node.expression.callee.name) === "createEffect")
+ return fail(
+ call,
+ "createEffect is not lowered: ReactiveScript has one effect form with per-input value comparison (EF-2). Use createRxEffect from reactivescript-ref/tsx",
+ );
+ if (call.arguments.length !== 2 || call.optional || call.typeArguments)
+ return fail(
+ call,
+ "Effects require input computations and an apply callback, without options",
+ );
+ const input = call.arguments[0];
+ if (
+ input.type !== "ArrayExpression" ||
+ input.elements.some((item) => !item || item.type === "SpreadElement")
+ )
+ return fail(
+ input,
+ "createRxEffect requires a literal array of zero-argument input callbacks",
+ );
+ const values = input.elements.map((input) =>
+ value(arrow(input!).body, scope),
+ );
+ const applyNode = call.arguments[1];
+ if (applyNode.type === "ArrowFunctionExpression" && applyNode.async)
+ return asyncCallback(applyNode);
+ const apply = arrow(applyNode, values.length);
+ if (apply.body.type !== "BlockStatement")
+ return fail(
+ apply.body,
+ "Effect apply callbacks require an explicit block returning undefined or a cleanup; setter results are not valid cleanup values",
+ );
+ const inner = copy(scope);
+ inner.effect = true;
+ apply.params.forEach((param, i) => {
+ const p = bind(param, inner.names, true);
+ if (
+ param.type === "Identifier" &&
+ param.typeAnnotation &&
+ !matches(
+ values[i].shape,
+ readType(param.typeAnnotation.typeAnnotation),
+ )
+ )
+ return fail(
+ param,
+ "Effect sample parameter does not match its input",
+ );
+ inner.bindings.set(p, {
+ kind: "value",
+ shape: values[i].shape,
+ name: "$" + i,
+ });
+ });
+ decls.push({
+ k: "effect",
+ inputs: values.map((v) => v.e),
+ body:
+ apply.body.type === "BlockStatement"
+ ? statements(apply.body.body, inner)
+ : actionExpr(apply.body, inner),
+ line: line(node),
+ });
+ continue;
+ }
+ if (
+ node.type !== "VariableDeclaration" ||
+ node.kind !== "const" ||
+ node.declarations.length !== 1 ||
+ node.declare
+ )
+ return fail(
+ node,
+ "Component setup supports const bindings and two-callback createRxEffect calls",
+ );
+ const d = node.declarations[0],
+ call = d.init;
+ const primitive =
+ call?.type === "CallExpression" && call.callee.type === "Identifier"
+ ? primitives.get(call.callee.name)
+ : undefined;
+ const factory =
+ call?.type === "CallExpression" && call.callee.type === "Identifier"
+ ? scope.helpers.get(call.callee.name)
+ : undefined;
+ if (
+ factory?.returnsAccessor &&
+ call?.type === "CallExpression" &&
+ call.callee.type === "Identifier"
+ ) {
+ if (
+ call.optional ||
+ call.typeArguments ||
+ call.arguments.length !== factory.params.length
+ )
+ return fail(
+ d,
+ "Setup helper calls require their declared arguments without options or type arguments",
+ );
+ const args = call.arguments.map((arg, i) => {
+ const param = factory.params[i];
+ const binding =
+ arg.type === "Identifier"
+ ? scope.bindings.get(arg.name)
+ : undefined;
+ if (param.ref) {
+ if (
+ arg.type !== "Identifier" ||
+ binding?.kind !== "accessor" ||
+ !matches(binding.shape, param.shape)
+ )
+ return fail(
+ arg,
+ "Ref helper arguments must be compatible original accessor bindings",
+ );
+ return (
+ binding.e ?? { k: "id" as const, name: binding.name ?? arg.name }
+ );
+ }
+ const v = value(arg, scope, param.shape);
+ if (!matches(v.shape, param.shape))
+ return fail(
+ arg,
+ "Helper argument does not match its declared type",
+ );
+ // Only owned helper calls snapshot value arguments; ordinary pure calls keep caller tracking.
+ return { k: "peek" as const, e: v.e };
+ });
+ const name = bind(d.id, scope.names);
+ scope.bindings.set(name, { kind: "accessor", shape: factory.ret! });
+ decls.push({
+ k: "eq",
+ name,
+ e: { k: "call", f: { k: "id", name: call.callee.name }, args },
+ line: line(node),
+ });
+ continue;
+ }
+ if (
+ (primitive === "serverFunction" || primitive === "serverAction") &&
+ call?.type === "CallExpression"
+ ) {
+ const [label, fn] = call.arguments;
+ if (
+ d.id.type !== "Identifier" ||
+ call.optional ||
+ call.typeArguments ||
+ call.arguments.length !== 2 ||
+ label.type !== "Literal" ||
+ label.value !== d.id.name ||
+ fn.type !== "FunctionExpression" ||
+ !fn.generator ||
+ fn.async ||
+ fn.typeParameters ||
+ !fn.body
+ )
+ return fail(
+ d,
+ "Server helpers require a matching literal name and synchronous typed generator function",
+ );
+ const annotation = fn.returnType?.typeAnnotation;
+ if (
+ annotation?.type !== "TSTypeReference" ||
+ annotation.typeName.type !== "Identifier" ||
+ annotation.typeName.name !== "Generator" ||
+ annotation.typeArguments?.params.length !== 3
+ )
+ return fail(
+ fn,
+ "Server generators require an explicit Generator return contract",
+ );
+ const result = readType(annotation.typeArguments.params[1]);
+ const name = bind(d.id, scope.names);
+ const inner = copy(scope);
+ inner.action = true;
+ inner.contextReads = false;
+ inner.returnShape = result;
+ const params = fn.params.map((param) => {
+ if (param.type !== "Identifier" || !param.typeAnnotation)
+ return fail(
+ param,
+ "Server parameters require explicit supported value types",
+ );
+ const shape = readType(param.typeAnnotation.typeAnnotation);
+ const name = bind(param, inner.names, true);
+ inner.bindings.set(name, { kind: "value", shape });
+ return { name, shape };
+ });
+ if (fn.body.body.at(-1)?.type !== "ReturnStatement")
+ return fail(
+ fn,
+ "Server generators must end with an explicit result return",
+ );
+ const kind = primitive === "serverFunction" ? "fn" : "action";
+ scope.bindings.set(name, {
+ kind: "value",
+ shape: { kind: "callback" },
+ server: { kind, params: params.map((param) => param.shape), result },
+ });
+ decls.push(
+ serverDecl(
+ name,
+ params.map((param) => param.name),
+ statements(fn.body.body, inner),
+ line(node),
+ ),
+ );
+ continue;
+ }
+ if (primitive === "useSourceContext" && call?.type === "CallExpression") {
+ if (
+ scope.contextReads === false ||
+ call.optional ||
+ call.typeArguments ||
+ call.arguments.length !== 1 ||
+ call.arguments[0].type !== "Identifier" ||
+ !sourceContexts.has(call.arguments[0].name) ||
+ d.id.type !== "ArrayPattern" ||
+ d.id.elements.length !== 2
+ )
+ return fail(
+ d,
+ "useSourceContext requires a declared source context and [read, set] bindings in owned setup",
+ );
+ const context = call.arguments[0].name;
+ const read = bind(d.id.elements[0] ?? d.id, scope.names),
+ write = bind(d.id.elements[1] ?? d.id, scope.names);
+ // Capture the reader without reading its value, but retain the required-provider contract: an equation whose
+ // value is a closure is constant, so no snapshot is involved.
+ decls.push({
+ k: "eq",
+ name: read,
+ e: { k: "fn", params: [], body: { k: "use", name: context } },
+ line: line(node),
+ });
+ scope.bindings.set(read, {
+ kind: scalar(contexts.get(context)!) ? "accessor" : "store",
+ shape: contexts.get(context)!,
+ name: context,
+ e: { k: "call", f: { k: "id", name: read }, args: [] },
+ ctx: true,
+ });
+ scope.setters.set(write, read);
+ continue;
+ }
+ if (primitive === "defineError" && call?.type === "CallExpression") {
+ if (
+ d.id.type !== "Identifier" ||
+ call.optional ||
+ call.typeArguments ||
+ call.arguments.length !== 3
+ )
+ return fail(
+ d,
+ "defineError requires an identifier and name, fields, typed message callback",
+ );
+ const [label, fields, message] = call.arguments;
+ if (["Error", "Panic"].includes(d.id.name))
+ return fail(
+ d.id,
+ "Failure constructor names Error and Panic are reserved RX identities",
+ );
+ if (
+ label.type !== "Literal" ||
+ label.value !== d.id.name ||
+ fields.type !== "ArrayExpression"
+ )
+ return fail(
+ d,
+ "Failure name must equal its binding name and fields must be a literal string array",
+ );
+ const params = fields.elements.map((field) => {
+ if (
+ field?.type !== "Literal" ||
+ typeof field.value !== "string" ||
+ !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field.value) ||
+ [
+ "name",
+ "message",
+ "stack",
+ "__proto__",
+ "constructor",
+ "prototype",
+ ].includes(field.value)
+ )
+ return fail(
+ field ?? fields,
+ "Failure fields require distinct safe identifier strings",
+ );
+ return field.value;
+ });
+ if (new Set(params).size !== params.length)
+ return fail(fields, "Duplicate failure field");
+ const fn = arrow(message, params.length);
+ const inner = copy(scope);
+ const shapes = fn.params.map((param, i) => {
+ if (param.type !== "Identifier" || !param.typeAnnotation)
+ return fail(
+ param,
+ "Failure message parameters require explicit supported types",
+ );
+ const shape = readType(param.typeAnnotation.typeAnnotation);
+ const name = bind(param, inner.names, true);
+ inner.bindings.set(name, { kind: "value", shape, name: params[i] });
+ return shape;
+ });
+ const msg = value(fn.body, inner);
+ if (msg.shape.kind !== "string")
+ return fail(message, "Failure messages must return a string");
+ const name = bind(d.id, scope.names);
+ const result: Shape = {
+ kind: "record",
+ fields: new Map([
+ ["name", { kind: "string" }],
+ ["message", { kind: "string" }],
+ ...params.map((param, i): [string, Shape] => [param, shapes[i]]),
+ ]),
+ };
+ scope.bindings.set(name, {
+ kind: "value",
+ shape: { kind: "callback" },
+ failure: { params, shapes, result },
+ });
+ decls.push({ k: "error", name, params, msg: msg.e, line: line(node) });
+ continue;
+ }
+ if (
+ ![
+ "createSignal",
+ "createOptimistic",
+ "createMemo",
+ "createServerMemo",
+ "createPrevious",
+ "createDeferred",
+ "createStore",
+ "createProjection",
+ "action",
+ ].includes(primitive ?? "")
+ ) {
+ const lowered = constant(node, scope);
+ if (lowered) decls.push(lowered);
+ continue;
+ }
+ if (
+ !call ||
+ call.type !== "CallExpression" ||
+ call.optional ||
+ (call.arguments.length !== (primitive === "createProjection" ? 2 : 1) &&
+ !(
+ ["createMemo", "createServerMemo", "createPrevious"].includes(
+ primitive ?? "",
+ ) && call.arguments.length === 2
+ ) &&
+ !(primitive === "createProjection" && call.arguments.length === 3)) ||
+ (call.typeArguments &&
+ !(
+ primitive === "createStore" &&
+ call.typeArguments.params.length === 1
+ )) ||
+ d.definite
+ )
+ return fail(
+ d,
+ "Reactive primitives take one argument, without options/type arguments",
+ );
+ if (primitive === "createPrevious") {
+ const input = call.arguments[0];
+ const binding =
+ input.type === "Identifier"
+ ? scope.bindings.get(input.name)
+ : undefined;
+ if (
+ input.type !== "Identifier" ||
+ !binding?.previousTarget ||
+ binding.kind !== "accessor" ||
+ binding.name ||
+ binding.e ||
+ (d.id.type === "Identifier" && input.name === d.id.name)
+ )
+ return fail(
+ input,
+ "createPrevious requires an original known local source or memo accessor; self-cycles, store roots, imported getters, values, and arbitrary callbacks are unsupported",
+ );
+ // Module setup is lowered one declaration at a time.
+ if (
+ !(scope === moduleScope ? result.decls : decls).some(
+ (d) => (d.k === "source" || d.k === "eq") && d.name === input.name,
+ )
+ )
+ return fail(
+ input,
+ "createPrevious requires an earlier source or memo in the same owned setup; inherited targets cannot recover earlier history",
+ );
+ const fallback = call.arguments[1];
+ const init = fallback
+ ? value(arrow(fallback).body, scope, binding.shape)
+ : undefined;
+ if (init && !matches(init.shape, binding.shape))
+ return fail(
+ fallback,
+ "createPrevious fallback must match the input value shape",
+ );
+ const name = bind(d.id, scope.names);
+ scope.bindings.set(name, {
+ kind: "accessor",
+ shape: init
+ ? binding.shape
+ : merge(binding.shape, { kind: "undefined" }),
+ previousTarget: true,
+ capturesProjection:
+ binding.capturesProjection || init?.capturesProjection,
+ });
+ decls.push({
+ k: "eq",
+ name,
+ e: { k: "pre", name: input.name, ...(init ? { init: init.e } : {}) },
+ line: line(node),
+ });
+ continue;
+ }
+ if (primitive === "action") {
+ const fn = call.arguments[0];
+ if (
+ (fn.type === "FunctionExpression" ||
+ fn.type === "ArrowFunctionExpression") &&
+ fn.async
+ )
+ return asyncAction(fn);
+ if (
+ fn.type !== "FunctionExpression" ||
+ !fn.generator ||
+ fn.async ||
+ fn.typeParameters ||
+ fn.returnType
+ )
+ return fail(
+ fn,
+ "Actions require a synchronous generator function without type parameters or a return annotation",
+ );
+ const name = bind(d.id, scope.names),
+ inner = copy(scope);
+ inner.action = true;
+ const params = fn.params.map((param) => {
+ if (param.type !== "Identifier" || !param.typeAnnotation)
+ return fail(
+ param,
+ "Action parameters require explicit supported value types",
+ );
+ const shape = readType(param.typeAnnotation.typeAnnotation);
+ const name = bind(param, inner.names, true);
+ inner.bindings.set(name, { kind: "value", shape });
+ return { name, shape };
+ });
+ scope.bindings.set(name, {
+ kind: "value",
+ shape: { kind: "callback" },
+ actionParams: params.map((param) => param.shape),
+ });
+ decls.push({
+ k: "action",
+ name,
+ params: params.map((param) => param.name),
+ body: statements(fn.body!.body, inner),
+ line: line(node),
+ });
+ } else if (primitive === "createProjection") {
+ if (call.arguments.length === 3) {
+ const fn = arrow(call.arguments[0]);
+ const seed = call.arguments[1],
+ options = call.arguments[2];
+ if (
+ seed.type !== "ArrayExpression" ||
+ seed.elements.length ||
+ options.type !== "ObjectExpression" ||
+ options.properties.length !== 1
+ )
+ return fail(
+ d,
+ "Keyed projections require an empty array seed and one literal key option",
+ );
+ const option = options.properties[0];
+ if (
+ option.type !== "Property" ||
+ option.computed ||
+ option.method ||
+ option.kind !== "init" ||
+ option.key.type !== "Identifier" ||
+ option.key.name !== "key" ||
+ option.value.type !== "Literal" ||
+ typeof option.value.value !== "string"
+ )
+ return fail(
+ options,
+ "Keyed projections require {key: fieldNameLiteral}",
+ );
+ const v = value(fn.body, scope);
+ const key = option.value.value;
+ const field = v.shape.item?.fields?.get(key);
+ if (
+ v.shape.kind !== "array" ||
+ !field ||
+ !["number", "string"].includes(field.kind) ||
+ field.maybe
+ )
+ return fail(
+ fn.body,
+ "Keyed projections require record rows with a known number/string key field",
+ );
+ const name = bind(d.id, scope.names);
+ scope.bindings.set(name, {
+ kind: "store",
+ shape: v.shape,
+ derivedStore: true,
+ });
+ decls.push({ k: "eq", name, e: v.e, key, line: line(node) });
+ continue;
+ }
+ const fn = arrow(call.arguments[0], 1);
+ if (fn.body.type !== "BlockStatement")
+ return fail(
+ fn.body,
+ "Projections require a synchronous draft-assignment block and explicit seed",
+ );
+ if (scope.names.has("d"))
+ return fail(
+ fn,
+ "Projection drafts reserve the existing RX draft binding d",
+ );
+ const seed = value(call.arguments[1], scope);
+ if (!["record", "array"].includes(seed.shape.kind))
+ return fail(
+ call.arguments[1],
+ "Projection seeds need a supported record or array shape",
+ );
+ const name = bind(d.id, scope.names),
+ seedName = fresh();
+ // Solid reads the seed once, untracked, at setup: an RX snapshot source (E-4).
+ decls.push({
+ k: "source",
+ name: seedName,
+ init:
+ seed.e.k === "peek" || !refers(seed.e)
+ ? seed.e
+ : { k: "peek", e: seed.e },
+ line: line(node),
+ });
+ const inner = copy(scope),
+ param = bind(fn.params[0], inner.names);
+ inner.bindings.set(param, {
+ kind: "value",
+ name: "d",
+ shape: seed.shape,
+ });
+ inner.bindings.set("d", { kind: "value", shape: seed.shape });
+ const drafts = new Map([[param, "d"]]);
+ if (
+ fn.body.body.some(
+ (node) =>
+ node.type !== "ExpressionStatement" ||
+ node.expression.type !== "AssignmentExpression",
+ )
+ )
+ return fail(
+ fn.body,
+ "Projection bodies currently support straight-line assignments only",
+ );
+ const body = statements(fn.body.body, inner, drafts);
+ decls.push({
+ k: "eq",
+ name,
+ e: {
+ k: "draft",
+ seed: { k: "pre", name, init: { k: "id", name: seedName } },
+ body,
+ },
+ line: line(node),
+ });
+ scope.bindings.set(name, {
+ kind: "store",
+ shape: seed.shape,
+ derivedStore: true,
+ });
+ } else if (
+ primitive === "createMemo" ||
+ primitive === "createDeferred" ||
+ primitive === "createServerMemo"
+ ) {
+ const memo = call.arguments[0];
+ if (
+ memo.type !== "ArrowFunctionExpression" ||
+ memo.params.length > 1 ||
+ (primitive === "createDeferred" && memo.params.length) ||
+ memo.typeParameters
+ )
+ return fail(
+ memo,
+ "Memos require at most one previous-value parameter and no type parameters",
+ );
+ let body =
+ memo.body.type === "BlockStatement" &&
+ memo.body.body.length === 1 &&
+ memo.body.body[0].type === "ReturnStatement"
+ ? memo.body.body[0].argument
+ : memo.body;
+ if (!body) return fail(memo.body, "Memo must return a value");
+ while (body.type === "ParenthesizedExpression") body = body.expression;
+ if (memo.async && body.type !== "AwaitExpression")
+ return fail(
+ memo,
+ "Async memos currently require a single returned await expression",
+ );
+ if (!memo.async && body.type === "AwaitExpression")
+ return fail(body, "await requires an async memo");
+ let expected: Shape | undefined;
+ let stream = false;
+ let implicitAsync =
+ body.type === "CallExpression" &&
+ body.callee.type === "Identifier" &&
+ !!scope.bindings.get(body.callee.name)?.server;
+ if (memo.returnType) {
+ const annotation = memo.returnType.typeAnnotation;
+ if (
+ memo.async ||
+ (annotation.type === "TSTypeReference" &&
+ annotation.typeName.type === "Identifier" &&
+ annotation.typeName.name === "Promise")
+ ) {
+ if (
+ annotation.type !== "TSTypeReference" ||
+ annotation.typeName.type !== "Identifier" ||
+ annotation.typeName.name !== "Promise" ||
+ annotation.typeArguments?.params.length !== 1
+ )
+ return fail(
+ annotation,
+ "Async memo result annotations must be Promise",
+ );
+ expected = readType(annotation.typeArguments.params[0]);
+ implicitAsync = !memo.async;
+ } else if (
+ annotation.type === "TSTypeReference" &&
+ annotation.typeName.type === "Identifier" &&
+ annotation.typeName.name === "AsyncIterable" &&
+ annotation.typeArguments?.params.length === 1
+ ) {
+ expected = readType(annotation.typeArguments.params[0]);
+ stream = true;
+ } else expected = readType(annotation);
+ }
+ const name = bind(d.id, scope.names);
+ const inner = copy(scope);
+ if (implicitAsync) inner.serverCall = body;
+ if (memo.params.length) {
+ if (memo.async || stream || implicitAsync)
+ return fail(
+ memo,
+ "Previous-value async memos require additional lifetime semantics and are unsupported",
+ );
+ const p = memo.params[0];
+ if (p.type !== "Identifier" || (!p.typeAnnotation && !expected))
+ return fail(
+ p,
+ "Previous-value parameters need a primitive parameter or memo return annotation",
+ );
+ const shape = p.typeAnnotation
+ ? readType(p.typeAnnotation.typeAnnotation)
+ : expected!;
+ if (!scalar(shape))
+ return fail(
+ p,
+ "Previous-value parameters currently support primitive values only",
+ );
+ const previous = bind(p, inner.names, true);
+ inner.bindings.set(previous, {
+ kind: "value",
+ shape,
+ e: { k: "pre", name },
+ });
+ }
+ const v = value(body, inner, expected);
+ if (expected && !matches(v.shape, expected))
+ return fail(body, "Memo result does not match its annotation");
+ const request = v.e.k === "await" ? v.e.e : v.e;
+ scope.bindings.set(name, {
+ kind: "accessor",
+ shape: v.shape,
+ capturesProjection: v.capturesProjection,
+ previousTarget: true,
+ serverQuestion: serverQuestion(v.e, scope),
+ serverMemo:
+ primitive === "createServerMemo" &&
+ request.k === "call" &&
+ request.f.k === "id" &&
+ scope.bindings.get(request.f.name)?.server?.kind === "fn",
+ });
+ let expression: Expr =
+ stream || implicitAsync
+ ? {
+ k: "await",
+ e: v.e,
+ ...(stream ? { stream: true as const } : {}),
+ }
+ : v.e;
+ if (call.arguments.length === 2) {
+ const options = call.arguments[1];
+ if (
+ options.type !== "ObjectExpression" ||
+ options.properties.length !== 1
+ )
+ return fail(
+ d,
+ "Memo options currently support only an explicit loadingValue literal",
+ );
+ const option = options.properties[0];
+ if (
+ option.type !== "Property" ||
+ option.computed ||
+ option.method ||
+ option.kind !== "init" ||
+ option.key.type !== "Identifier" ||
+ option.key.name !== "loadingValue" ||
+ expression.k !== "await"
+ )
+ return fail(
+ d,
+ "Memo options currently support only loadingValue on an async or stream memo",
+ );
+ const seed = value(option.value, scope);
+ if (
+ !matches(seed.shape, v.shape) ||
+ !(seed.e.k === "lit" || (seed.e.k === "un" && seed.e.e.k === "lit"))
+ )
+ return fail(
+ option.value,
+ "Memo loadingValue currently requires a matching primitive literal, optionally signed",
+ );
+ expression = { ...expression, init: seed.e };
+ }
+ decls.push({
+ k: "eq",
+ name,
+ e: expression,
+ ...(primitive === "createDeferred" ? { deferred: true } : {}),
+ line: line(node),
+ });
+ } else {
+ if (
+ d.id.type !== "ArrayPattern" ||
+ d.id.elements.length !== 2 ||
+ !d.id.elements[0] ||
+ !d.id.elements[1] ||
+ d.id.typeAnnotation ||
+ d.id.optional
+ )
+ return fail(
+ d.id,
+ "Signals/stores require exactly [read, write] bindings",
+ );
+ const initializer = call.arguments[0];
+ const seedType =
+ primitive === "createStore" && call.typeArguments
+ ? readType(call.typeArguments.params[0])
+ : undefined;
+ const question =
+ ["createSignal", "createOptimistic", "createStore"].includes(
+ primitive ?? "",
+ ) && initializer.type === "ArrowFunctionExpression";
+ let body: Node = initializer;
+ let resultType = seedType;
+ if (question && primitive === "createStore") {
+ if (initializer.params.length || initializer.typeParameters)
+ return fail(
+ initializer,
+ "Function-form stores require zero-argument computations without type parameters",
+ );
+ body = initializer.body;
+ if (
+ body.type === "BlockStatement" &&
+ body.body.length === 1 &&
+ body.body[0].type === "ReturnStatement" &&
+ body.body[0].argument
+ )
+ body = body.body[0].argument;
+ while (body.type === "ParenthesizedExpression")
+ body = body.expression;
+ if (
+ body.type === "BlockStatement" ||
+ (initializer.async && body.type !== "AwaitExpression") ||
+ (!initializer.async && hasLexicalAwait(body)) ||
+ (body.type === "AwaitExpression" && hasLexicalAwait(body.argument))
+ )
+ return fail(
+ initializer,
+ "Function-form stores require a single returned value or async tail await",
+ );
+ const annotation = initializer.returnType?.typeAnnotation;
+ if (initializer.async) {
+ if (
+ annotation?.type !== "TSTypeReference" ||
+ annotation.typeName.type !== "Identifier" ||
+ annotation.typeName.name !== "Promise" ||
+ annotation.typeArguments?.params.length !== 1
+ )
+ return fail(
+ initializer,
+ "Async function-form stores require an explicit Promise result annotation",
+ );
+ resultType = readType(annotation.typeArguments.params[0]);
+ } else if (annotation) resultType = readType(annotation);
+ if (!resultType || !["record", "array"].includes(resultType.kind))
+ return fail(
+ initializer,
+ "Function-form stores require an explicit supported record or array result contract",
+ );
+ } else if (question) body = arrow(initializer).body;
+ const v = value(body, scope, resultType);
+ if (
+ question &&
+ primitive === "createStore" &&
+ resultType &&
+ !matches(v.shape, resultType)
+ )
+ return fail(
+ initializer,
+ "Store computation does not match its explicit value contract",
+ );
+ if (seedType && !matches(v.shape, seedType))
+ return fail(
+ initializer,
+ "Store seed does not match its explicit value contract",
+ );
+ if (primitive === "createSignal" && !scalar(v.shape))
+ return fail(
+ call.arguments[0],
+ "Structured createSignal identity cannot be represented by RX's auto-store sources; use createStore",
+ );
+ if (
+ primitive === "createOptimistic" &&
+ !scalar(v.shape) &&
+ !["record", "array"].includes(v.shape.kind)
+ )
+ return fail(
+ initializer,
+ "Optimistic roots require supported primitive, record, or array values",
+ );
+ if (
+ primitive === "createOptimistic" &&
+ v.capturesProjection &&
+ !scalar(v.shape)
+ )
+ return fail(
+ initializer,
+ "Optimistic roots containing projection proxies require unsupported live identity semantics",
+ );
+ if (
+ primitive === "createStore" &&
+ !["record", "array"].includes(v.shape.kind)
+ )
+ return fail(
+ call.arguments[0],
+ "createStore requires a known object/array seed",
+ );
+ const read = bind(d.id.elements[0], scope.names),
+ write = bind(d.id.elements[1], scope.names);
+ scope.bindings.set(read, {
+ kind: primitive === "createStore" ? "store" : "accessor",
+ shape: seedType ?? v.shape,
+ previousTarget: primitive !== "createStore",
+ ...(question && primitive === "createStore"
+ ? {
+ storeQuestion: true,
+ serverQuestion: serverQuestion(v.e, scope),
+ }
+ : {}),
+ ...(primitive === "createOptimistic" ? { optimistic: true } : {}),
+ });
+ scope.setters.set(write, read);
+ const reactive = (e: unknown): boolean => {
+ if (!e || typeof e !== "object") return false;
+ if ("k" in e && e.k === "lit") return false;
+ if (
+ "k" in e &&
+ e.k === "id" &&
+ "name" in e &&
+ typeof e.name === "string"
+ )
+ return (
+ e.name === props ||
+ ["accessor", "store"].includes(
+ scope.bindings.get(e.name)?.kind ?? "",
+ )
+ );
+ return Object.values(e).some((x) =>
+ Array.isArray(x) ? x.some(reactive) : reactive(x),
+ );
+ };
+ decls.push({
+ k: "source",
+ name: read,
+ init:
+ !question && (primitive === "createOptimistic" || reactive(v.e))
+ ? { k: "peek", e: v.e }
+ : question
+ ? v.e
+ : peekSnapshots(v.e, scope),
+ ...(question || primitive === "createOptimistic"
+ ? { question: true }
+ : {}),
+ // ST-1: structure is the type. A function-form store's type text is an object/array type, which the emitter
+ // lowers as a structured question even before (or without) a path write.
+ ...(question && primitive === "createStore"
+ ? { type: printType(resultType ?? seedType ?? v.shape) }
+ : {}),
+ line: line(node),
+ });
+ }
+ }
+ return decls;
+ };
+ for (const d of constants) {
+ const declarator = d.node.declarations[0],
+ call = declarator?.init;
+ if (
+ call?.type === "CallExpression" &&
+ call.callee.type === "Identifier" &&
+ primitives.get(call.callee.name) === "createSourceContext"
+ ) {
+ if (
+ d.node.kind !== "const" ||
+ d.node.declarations.length !== 1 ||
+ call.optional ||
+ call.arguments.length ||
+ call.typeArguments?.params.length !== 1
+ )
+ return fail(
+ d.node,
+ "Source contexts require createSourceContext() with one supported value contract",
+ );
+ const shape = readType(call.typeArguments.params[0]);
+ const supported = (s: Shape): boolean =>
+ scalar(s) ||
+ (s.kind === "record" && [...s.fields!.values()].every(supported)) ||
+ (s.kind === "array" && supported(s.item!));
+ if (!supported(shape))
+ return fail(
+ call,
+ "Source contexts require primitive, record, or array value contracts without callbacks",
+ );
+ const name = bind(declarator.id, moduleNames);
+ contexts.set(name, shape);
+ sourceContexts.add(name);
+ result.decls.push({
+ k: "context",
+ name,
+ type: printType(shape),
+ source: true,
+ line: line(d.node),
+ ...(d.exported ? { exported: true } : {}),
+ });
+ continue;
+ }
+ if (
+ call?.type === "CallExpression" &&
+ call.callee.type === "Identifier" &&
+ primitives.get(call.callee.name) === "createContext"
+ ) {
+ if (
+ d.node.kind !== "const" ||
+ d.node.declarations.length !== 1 ||
+ call.optional ||
+ (call.typeArguments && call.typeArguments.params.length !== 1) ||
+ call.arguments.length > 1 ||
+ (!call.arguments.length && !call.typeArguments)
+ )
+ return fail(
+ d.node,
+ "Contexts require a primitive literal/default accessor or an explicit primitive type without a default",
+ );
+ const contract = call.typeArguments
+ ? readType(call.typeArguments.params[0])
+ : undefined;
+ let initial: Value;
+ const argument = call.arguments[0];
+ if (!argument) {
+ if (
+ !contract ||
+ !(
+ scalar(contract) ||
+ (contract.kind === "accessor" && scalar(contract.item!))
+ )
+ )
+ return fail(
+ call,
+ "Default-less contexts currently require a primitive or primitive accessor contract",
+ );
+ initial = { e: { k: "lit", v: undefined }, shape: contract };
+ } else if (argument.type === "ArrowFunctionExpression") {
+ const fn = arrow(argument);
+ const v = value(fn.body, moduleScope);
+ if (v.e.k !== "lit" || !scalar(v.shape))
+ return fail(
+ fn.body,
+ "Context default accessors currently require a primitive literal body",
+ );
+ initial = {
+ e: { k: "fn", params: [], body: v.e },
+ shape: { kind: "accessor", item: v.shape },
+ };
+ } else initial = value(argument, moduleScope);
+ if (
+ !["lit", "fn"].includes(initial.e.k) ||
+ !["number", "string", "boolean", "null", "scalar", "accessor"].includes(
+ initial.shape.kind,
+ ) ||
+ (argument && contract && !matches(initial.shape, contract))
+ )
+ return fail(
+ argument ?? call,
+ "Context defaults currently require a non-undefined primitive literal",
+ );
+ const name = bind(declarator.id, moduleNames);
+ const shape = argument ? (contract ?? initial.shape) : initial.shape;
+ contexts.set(name, shape);
+ result.decls.push({
+ k: "context",
+ name,
+ type: printType(shape),
+ source: false,
+ ...(argument ? { dflt: initial.e } : {}),
+ line: line(d.node),
+ ...(d.exported ? { exported: true } : {}),
+ });
+ } else {
+ const ids =
+ declarator.id.type === "ArrayPattern"
+ ? declarator.id.elements
+ : [declarator.id];
+ const names = new Set(
+ ids.flatMap((id) => (id?.type === "Identifier" ? [id.name] : [])),
+ );
+ result.decls.push(
+ ...setup([d.node], moduleScope).map((decl) => {
+ if (!d.exported || !("name" in decl) || !names.has(decl.name))
+ return decl;
+ const binding = moduleScope.bindings.get(decl.name);
+ const exportedDecl: Decl = {
+ ...decl,
+ exported: true,
+ ...(binding && ["source", "eq"].includes(decl.k)
+ ? { type: printType(binding.shape) }
+ : {}),
+ } as Decl;
+ const span = constSpans.get(decl);
+ if (span) constSpans.set(exportedDecl, span); // the setup-const fact follows the Decl, not its spelling
+ return exportedDecl;
+ }),
+ );
+ if (d.exported && declarator.id.type === "ArrayPattern") {
+ const [read, write] = declarator.id.elements;
+ if (
+ read?.type === "Identifier" &&
+ write?.type === "Identifier" &&
+ moduleScope.setters.get(write.name) === read.name
+ ) {
+ const binding = moduleScope.bindings.get(read.name)!;
+ const param = fresh();
+ result.decls.push({
+ k: "action",
+ name: write.name,
+ params: [param],
+ body: [
+ {
+ k: binding.optimistic ? "optimistic" : "write",
+ target: { name: read.name, path: [], ctx: false },
+ e: { k: "id", name: param },
+ },
+ ],
+ line: line(d.node),
+ exported: true,
+ });
+ }
+ }
+ }
+ }
+ for (const [name, meta] of functions) {
+ if (!meta.fn) continue;
+ if (!meta.component) {
+ result.decls.push(helper(name, meta, moduleScope));
+ continue;
+ }
+ const fn = meta.fn;
+ const scope = copy(moduleScope);
+ const decls: Decl[] = [];
+ if (meta.component) {
+ if (scope.names.has(meta.props))
+ return fail(fn.params[0] ?? fn, "Props cannot shadow a module binding");
+ scope.names.add(meta.props);
+ scope.bindings.set(meta.props, {
+ kind: "value",
+ shape: { kind: "record", fields: meta.fields },
+ props: true,
+ });
+ }
+ const nodes = fn.body!.body;
+ const ret = nodes.at(-1);
+ if (!ret || ret.type !== "ReturnStatement" || !ret.argument)
+ return fail(fn, "Functions must end with one value/JSX return");
+ for (const node of nodes.slice(0, -1))
+ if (node.type === "FunctionDeclaration") {
+ if (!node.id || /^[A-Z]/.test(node.id.name))
+ return fail(
+ node,
+ "Nested component declarations are unsupported; use module components",
+ );
+ collectFunction(node, false, scope.names, scope.helpers);
+ }
+ decls.push(...setup(nodes.slice(0, -1), scope, meta.props));
+ for (const node of nodes.slice(0, -1))
+ if (node.type === "FunctionDeclaration" && node.id)
+ decls.push(
+ helper(node.id.name, scope.helpers.get(node.id.name)!, scope),
+ );
+ meta.decls = decls;
+ result.decls.push({
+ k: "component",
+ name,
+ props: meta.props,
+ propsType: meta.propsType,
+ decls,
+ view: markup(ret.argument, scope, meta),
+ line: line(fn),
+ ...(meta.exported ? { exported: true } : {}),
+ ...(name === defaultComponent ? { isDefault: true } : {}),
+ });
+ }
+ const visiting = new Set(),
+ done = new Set();
+ const visit = (name: string): void => {
+ if (done.has(name)) return;
+ visiting.add(name);
+ for (const ref of functions.get(name)!.refs) {
+ if (visiting.has(ref.tag))
+ fail(ref.node, "Recursive component composition is unsupported");
+ visit(ref.tag);
+ }
+ visiting.delete(name);
+ done.add(name);
+ };
+ for (const name of functions.keys()) visit(name);
+ immutableExports.set(
+ result,
+ new Set(
+ result.decls.flatMap((d) =>
+ (d.k === "eq" || d.k === "source") &&
+ d.exported &&
+ !!moduleScope.bindings.get(d.name)?.snapshot
+ ? [d.name]
+ : [],
+ ),
+ ),
+ );
+ // JavaScript's temporal dead zone: an eager initializer (a setup const, a signal or store seed) runs at its declaration,
+ // so it cannot read a binding initialized at or after it — directly, through a helper it calls or an equation it reads,
+ // or in a closure it captures. RX sources have no such ordering, so the frontend refuses it instead of accepting it.
+ const temporalDeadZone = (decls: Decl[]) => {
+ const order = new Map();
+ decls.forEach((d, i) => {
+ if (d.k === "source" && !d.question) order.set(d.name, i);
+ });
+ const bodies = new Map();
+ for (const d of decls)
+ if (d.k === "fn") bodies.set(d.name, [d.tail, d.decls]);
+ else if (d.k === "eq") bodies.set(d.name, [d.e]);
+ decls.forEach((d, i) => {
+ if (d.k !== "source" || d.question) return;
+ const seen = new Set();
+ const visit = (x: unknown): string | undefined => {
+ if (Array.isArray(x)) {
+ for (const y of x) {
+ const hit = visit(y);
+ if (hit) return hit;
+ }
+ return;
+ }
+ if (!x || typeof x !== "object") return;
+ const o = x as { k?: string; name?: unknown };
+ if ((o.k === "id" || o.k === "pre") && typeof o.name === "string") {
+ const at = order.get(o.name);
+ if (at !== undefined && at >= i) return o.name;
+ if (bodies.has(o.name) && !seen.has(o.name)) {
+ seen.add(o.name);
+ return visit(bodies.get(o.name));
+ }
+ return;
+ }
+ return visit(Object.values(x));
+ };
+ const hit = visit(d.init);
+ if (hit) {
+ const offset = source
+ .split("\n")
+ .slice(0, d.line - 1)
+ .reduce((n, l) => n + l.length + 1, 0);
+ fail(
+ constSpans.get(d) ?? { start: offset, end: offset },
+ d.name +
+ " reads " +
+ hit +
+ " before its initialization (JavaScript's temporal dead zone); declare " +
+ hit +
+ " first",
+ );
+ }
+ });
+ for (const d of decls)
+ if (d.k === "component" || d.k === "fn") temporalDeadZone(d.decls);
+ };
+ temporalDeadZone(result.decls);
+ // Eager setup consts that RX could not observe the way Solid does. Solid evaluates them at mount: an untracked async
+ // read is an error there (PENDING_ASYNC_UNTRACKED_READ), and a failing initializer throws to the boundary above the
+ // component even if nothing reads it. An RX snapshot source holds a pending value or failure and surfaces it only at its
+ // readers, so these are refused rather than lowered with different timing.
+ const bodiesByName = new Map();
+ const collect = (x: unknown): void => {
+ if (Array.isArray(x)) return x.forEach(collect);
+ if (!x || typeof x !== "object") return;
+ const d = x as Decl;
+ if (d.k === "eq") bodiesByName.set(d.name, [...(bodiesByName.get(d.name) ?? []), d.e]);
+ if (d.k === "fn") bodiesByName.set(d.name, [...(bodiesByName.get(d.name) ?? []), d.tail]);
+ if (d.k === "source" && d.question) bodiesByName.set(d.name, [...(bodiesByName.get(d.name) ?? []), d.init]);
+ Object.values(x).forEach(collect);
+ };
+ collect(result.decls);
+ const awaits = (x: unknown, seen = new Set()): boolean => {
+ if (Array.isArray(x)) return x.some((y) => awaits(y, seen));
+ if (!x || typeof x !== "object") return false;
+ const o = x as { k?: string; name?: unknown };
+ if (o.k === "await") return true;
+ if (o.k === "fn") return false;
+ if (o.k === "id" && typeof o.name === "string") {
+ if (seen.has(o.name) || !bodiesByName.has(o.name)) return false;
+ seen.add(o.name);
+ return awaits(bodiesByName.get(o.name), seen);
+ }
+ return Object.values(x).some((y) => awaits(y, seen));
+ };
+ const inert = (x: unknown): boolean => {
+ if (Array.isArray(x)) return x.every(inert);
+ if (!x || typeof x !== "object") return true;
+ const { k, name } = x as { k?: string; name?: string };
+ if (k === "id") return !bodiesByName.has(name!); // an equation or question could fail when read
+ if (k && !["lit", "peek", "arr", "rec", "tpl", "bin", "un", "if", "spread", "fn"].includes(k)) return false;
+ return k === "fn" || Object.values(x).every(inert);
+ };
+ const mentions = (x: unknown, name: string, self: unknown): boolean => {
+ if (x === self) return false;
+ if (Array.isArray(x)) return x.some((y) => mentions(y, name, self));
+ if (!x || typeof x !== "object") return false;
+ const o = x as { k?: string; name?: unknown; target?: { name: string } };
+ if ((o.k === "id" || o.k === "pre") && o.name === name) return true;
+ if (o.target?.name === name) return true;
+ return Object.values(x).some((y) => mentions(y, name, self));
+ };
+ const snapshot = (owner: unknown, d: Decl): void => {
+ if (d.k === "source" && !d.question && constSpans.has(d)) {
+ const at = constSpans.get(d)!;
+ if (awaits(d.init))
+ fail(
+ at,
+ "Setup const " +
+ d.name +
+ " reads an async value at mount; Solid refuses untracked async reads there (PENDING_ASYNC_UNTRACKED_READ). Read it in JSX or a memo",
+ );
+ if (!d.exported && !inert(d.init) && !mentions(owner, d.name, d))
+ fail(
+ at,
+ "Unused setup const " +
+ d.name +
+ ": TSX evaluates it eagerly at mount (it could throw), but an RX snapshot that nothing reads is never observed; use it or remove it",
+ );
+ }
+ };
+ const blocks = (x: unknown): void => {
+ if (Array.isArray(x)) return x.forEach(blocks);
+ if (!x || typeof x !== "object") return;
+ for (const v of Object.values(x))
+ if (Array.isArray(v) && v.some((y) => y && typeof y === "object" && (y as Decl).k === "source"))
+ v.forEach((y) => {
+ const d = y as Decl;
+ if (d && d.k === "source") snapshot(x, d);
+ });
+ Object.values(x).forEach(blocks);
+ };
+ blocks(result);
+ // Eager initializers (setup consts; signal, store, projection and provider seeds) run at their declaration in Solid, so a
+ // failure throws during setup, to the boundary above the owner, before any boundary inside it exists. An RX
+ // `source x := e` holds the failure and raises it at its readers (R-f-2): perhaps inside an inner boundary, in an untaken
+ // branch or in a closure, so the AST cannot carry Solid's routing. Such initializers are refused unless a catch inside
+ // the initializer discharges every failure: only a catch-all (`catch` without classes) counts, since a selective catch
+ // cannot be proved to cover what it guards without class inference. "Could fail" is decided conservatively from this
+ // module's lowered
+ // declarations, resolved lexically (never by spelling across owners): an explicit throw; a call of a helper whose tail
+ // could fail, of a JS import not declared to reject nothing, of a server function, action or callback value; a read of
+ // an equation or question whose expression could fail; a read of a non-snapshot import; and a props field unless every
+ // application site in this module passes a value that cannot fail (an exported component's callers are unknown).
+ type FEnv = { vars: Map; parent?: FEnv };
+ type FEntry =
+ | { k: "decl"; d: Decl; env: FEnv; imported?: string }
+ | { k: "value" }
+ | { k: "props"; c: Extract };
+ type Fails = { f: boolean; why?: string };
+ const noFails = (): Fails => ({ f: false });
+ const anyFails = (why: string): Fails => ({ f: true, why });
+ const join = (a: Fails, b: Fails): Fails => (a.f ? a : b.f ? b : a);
+ const failing = (r: Fails) => r.f;
+ const lookupF = (env: FEnv | undefined, n: string): FEntry | undefined => {
+ for (let e = env; e; e = e.parent) {
+ const x = e.vars.get(n);
+ if (x) return x;
+ }
+ };
+ const scopeOf = (decls: Decl[], parent: FEnv | undefined, extra: [string, FEntry][] = []): FEnv => {
+ const env: FEnv = { vars: new Map(extra), parent };
+ for (const d of decls)
+ if (d.k === "import") for (const { name, alias } of d.names) env.vars.set(alias, { k: "decl", d, env, imported: name });
+ else if (d.k !== "provide" && "name" in d && typeof d.name === "string") env.vars.set(d.name, { k: "decl", d, env });
+ return env;
+ };
+ const sites = new Map();
+ const eager: { d: Extract; env: FEnv }[] = [];
+ const walkDecls = (decls: Decl[], env: FEnv): void => {
+ for (const d of decls) {
+ if (d.k === "source" && !d.question) eager.push({ d, env });
+ if (d.k === "component") {
+ const inner = scopeOf(d.decls, env, [[d.props, { k: "props", c: d }]]);
+ walkDecls(d.decls, inner);
+ walkView(d.view, inner);
+ }
+ if (d.k === "fn") {
+ const inner = scopeOf(d.decls, env, d.params.map((p): [string, FEntry] => [p.name, { k: "value" }]));
+ walkDecls(d.decls, inner);
+ }
+ }
+ };
+ const walkView = (view: Markup[], env: FEnv): void => {
+ for (const m of view) {
+ if (m.k === "el") {
+ const target = lookupF(env, m.tag);
+ if (target?.k === "decl" && target.d.k === "component")
+ sites.set(target.d, [...(sites.get(target.d) ?? []), { attrs: m.attrs, env }]);
+ walkView(m.children, env);
+ } else if (m.k === "mif") {
+ const t = scopeOf(m.tDecls, env), f = scopeOf(m.fDecls, env);
+ walkDecls(m.tDecls, t); walkView(m.t, t);
+ walkDecls(m.fDecls, f); walkView(m.f, f);
+ } else if (m.k === "mfor") {
+ const inner = scopeOf(m.decls, env, [[m.name, { k: "value" }]]);
+ walkDecls(m.decls, inner); walkView(m.body, inner);
+ } else if (m.k === "loading" || m.k === "errored") {
+ const inner = scopeOf(m.decls, env);
+ walkDecls(m.decls, inner); walkView(m.body, inner);
+ walkView(m.fallback, m.k === "errored" && m.errName ? scopeOf([], env, [[m.errName, { k: "value" }]]) : env);
+ }
+ }
+ };
+ const moduleEnv = scopeOf(result.decls, undefined);
+ walkDecls(result.decls, moduleEnv);
+ walkView(result.view, moduleEnv);
+ const memo = new Map();
+ const once = (key: object, compute: () => Fails): Fails => {
+ const known = memo.get(key);
+ if (known) return known;
+ // A cycle (a recursive helper, mutually recursive definitions) is not proof of totality: its unbounded evaluation
+ // would fail at the reader, so an in-progress definition counts as possibly failing.
+ memo.set(key, anyFails("a recursive definition, whose totality is not proved"));
+ const r = compute();
+ memo.set(key, r);
+ return r;
+ };
+ const jsRejects = (entry: Extract): string[] | undefined => {
+ if (entry.d.k !== "import") return;
+ const from = entry.d.from, name = entry.imported;
+ const js = options.declarations?.get(from)?.find((x) => x.name === name);
+ return js?.kind === "fn" ? (js.rejects ?? ["Error"]) : js?.kind === "error" ? [] : undefined;
+ };
+ const propsFails = (c: Extract, field?: string): Fails =>
+ once(sitesKey(c, field), () => {
+ if (c.exported) return anyFails("props of exported component " + c.name + ", whose callers in other modules are unknown");
+ let r = noFails();
+ for (const site of sites.get(c) ?? [])
+ for (const attr of site.attrs)
+ if (field === undefined || attr.name === field) {
+ const got = fails(attr.e, site.env);
+ if (failing(got)) return { f: got.f, why: "props." + attr.name + " of <" + c.name + ">, passed " + (got.why ?? "a value that could fail") };
+ r = join(r, got);
+ }
+ return r;
+ });
+ // Props facts belong to the component declaration itself, never to its spelling or line.
+ const siteKeys = new WeakMap>();
+ const sitesKey = (c: Decl, field?: string) => {
+ let byField = siteKeys.get(c);
+ if (!byField) siteKeys.set(c, (byField = new Map()));
+ const k = field === undefined ? "*" : "." + field;
+ if (!byField.has(k)) byField.set(k, {});
+ return byField.get(k)!;
+ };
+ const declFails = (entry: Extract, name: string): Fails => {
+ const d = entry.d;
+ if (d.k === "eq") return once(d, () => fails(d.e, entry.env));
+ if (d.k === "source" && d.question) return once(d, () => fails(d.init, entry.env));
+ if (d.k === "import") {
+ const dep = options.modules?.get(d.from);
+ if (dep && immutableExports.get(dep.program)?.has(entry.imported!)) return noFails();
+ if (jsRejects(entry)?.length === 0) return noFails();
+ return anyFails("a read of imported " + name);
+ }
+ // Other eager sources are checked themselves; functions, classes, contexts and components are values.
+ return noFails();
+ };
+ const fails = (x: unknown, env: FEnv): Fails => {
+ if (Array.isArray(x)) return x.reduce((r: Fails, y) => join(r, fails(y, env)), noFails());
+ if (!x || typeof x !== "object") return noFails();
+ const o = x as Expr;
+ switch (o.k) {
+ case "fn":
+ case "lit":
+ return noFails();
+ case "throw":
+ return anyFails("an explicit throw");
+ case "catch": {
+ const inner = fails(o.e, env);
+ const alt = fails(o.alt, o.name ? scopeOf([], env, [[o.name, { k: "value" }]]) : env);
+ return o.classes ? join(inner, alt) : alt; // only a catch-all discharges
+ }
+ case "id": {
+ if (o.name === "undefined") return noFails();
+ const entry = lookupF(env, o.name);
+ if (!entry) return anyFails("a read of " + o.name);
+ if (entry.k === "value") return noFails();
+ if (entry.k === "props") return propsFails(entry.c);
+ return declFails(entry, o.name);
+ }
+ case "member": {
+ if (o.o.k === "id") {
+ const entry = lookupF(env, o.o.name);
+ if (entry?.k === "props") return propsFails(entry.c, o.p);
+ }
+ const base = fails(o.o, env);
+ return o.o.k === "index" && !o.opt ? join(base, anyFails("a member of a possibly missing element")) : base;
+ }
+ case "call": {
+ const args = fails(o.args, env);
+ if (o.f.k !== "id") return join(args, anyFails("a call of a computed function"));
+ const entry = lookupF(env, o.f.name);
+ const name = o.f.name;
+ if (!entry && arrayBuiltins.has(name))
+ // An array builtin runs its callbacks now: their bodies count, with their parameters as plain values.
+ return o.args.reduce(
+ (r: Fails, a) =>
+ join(r, a.k === "fn" ? fails(a.stmts ?? a.body, scopeOf([], env, a.params.map((n): [string, FEntry] => [n, { k: "value" }]))) : noFails()),
+ args,
+ );
+ if (entry?.k === "decl") {
+ const d = entry.d;
+ if (d.k === "error")
+ // Constructing a failure evaluates its message, with the constructor's parameters as plain values.
+ return join(
+ args,
+ d.msg ? once(d, () => fails(d.msg, scopeOf([], entry.env, d.params.map((n): [string, FEntry] => [n, { k: "value" }])))) : noFails(),
+ );
+ if (d.k === "fn")
+ return join(args, once(d, () => fails(d.tail, scopeOf(d.decls, entry.env, d.params.map((p): [string, FEntry] => [p.name, { k: "value" }])))));
+ if (d.k === "import") {
+ const rejects = jsRejects(entry);
+ if (rejects?.length === 0) return args;
+ return join(args, anyFails("a call of " + name + ", which can reject"));
+ }
+ }
+ return join(args, anyFails("a call of " + name));
+ }
+ case "use": {
+ const entry = lookupF(env, o.name);
+ return entry?.k === "decl" && entry.d.k === "context" && entry.d.dflt ? fails(entry.d.dflt, entry.env) : noFails();
+ }
+ case "await":
+ return anyFails("an await");
+ default:
+ return Object.values(o).reduce((r: Fails, y) => join(r, typeof y === "object" ? fails(y, env) : noFails()), noFails());
+ }
+ };
+ // A bare reactive read (outside peek) is the shared checker's SNAPSHOT_INITIALIZER; this gate does not pre-empt it.
+ const bareReactive = (x: unknown, env: FEnv): boolean => {
+ if (Array.isArray(x)) return x.some((y) => bareReactive(y, env));
+ if (!x || typeof x !== "object") return false;
+ const o = x as { k?: string; name?: string };
+ if (o.k === "peek" || o.k === "fn") return false;
+ if (o.k === "id" && o.name) {
+ const entry = lookupF(env, o.name);
+ if (entry?.k === "props") return true;
+ if (entry?.k !== "decl") return false;
+ if (entry.d.k === "eq" || entry.d.k === "source") return true;
+ return entry.d.k === "import" && jsRejects(entry) === undefined;
+ }
+ return Object.values(x).some((y) => bareReactive(y, env));
+ };
+ for (const { d, env } of eager) {
+ if (bareReactive(d.init, env)) continue;
+ const r = fails(d.init, env);
+ if (!failing(r)) continue;
+ const offset = source.split("\n").slice(0, d.line - 1).reduce((n, l) => n + l.length + 1, 0);
+ const at = constSpans.get(d) ?? { start: offset, end: offset };
+ fail(
+ at,
+ (constSpans.has(d) ? "Setup const " + d.name : "The eager initializer of " + d.name + " (a signal, store, projection or provider seed)") +
+ " can fail at mount (" + (r.why ?? "a failure") + "): Solid throws during setup, before any boundary inside this owner exists, but an RX snapshot source holds the failure and raises it at its readers (possibly inside an inner boundary, an untaken branch or a closure). Catch every failure in the initializer with catchFailure, or read it in JSX or a memo",
+ );
+ }
+ for (const capture of structuredCaptures) {
+ let inside = false;
+ let projection = false;
+ const walk = (x: unknown, draft?: string): void => {
+ if (inside || !x || typeof x !== "object") return;
+ if (Array.isArray(x)) return x.forEach((y) => walk(y, draft));
+ const o = x as { k?: string; name?: string; e?: { k?: string; body?: unknown; seed?: unknown }; target?: { name: string; path: unknown[] } };
+ if (o.k === "eq" && o.name === capture.store && o.e?.k === "draft") {
+ projection = true;
+ walk(o.e.seed, draft);
+ return walk(o.e.body, "d");
+ }
+ const t = o.target;
+ if (t && (t.name === capture.store || (draft && t.name === draft)) && t.path.length > capture.path.length &&
+ capture.path.every((p, i) => t.path[i] === p)) {
+ inside = true;
+ return;
+ }
+ for (const y of Object.values(x)) walk(y, draft);
+ };
+ walk(result.decls);
+ // A derived store other than a projection reconciles each new answer into the existing objects (in place); a
+ // projection's recomputation is its draft statements, which the scan above already checked.
+ if (capture.derived && !projection)
+ return fail(
+ capture.at,
+ "untrack of a structured value of a derived or computed store is unsupported: Solid returns the live store proxy, which shows answers reconciled into it in place, but the RX spelling, peek, snapshots the value at this instant; untrack a primitive field, or read the store path where it is used",
+ );
+ if (inside)
+ return fail(
+ capture.at,
+ "untrack of a structured store value that is later written inside is unsupported: the write updates the captured object in place, which Solid's live proxy shows, but the RX spelling, peek, snapshots the value at this instant; untrack a primitive field, or read the store path where it is used",
+ );
+ }
+ if (defaultComponent) {
+ const entry = result.decls.find((d) => d.k === "component" && d.name === defaultComponent);
+ if (!entry)
+ return fail(parsed.program, "export default " + defaultComponent + " must be a component (a function returning JSX)");
+ result.view = entryView(defaultComponent, entry.line);
+ }
+ return result;
+}
diff --git a/tsx-frontend/tsx/modules.test.ts b/tsx-frontend/tsx/modules.test.ts
new file mode 100644
index 0000000..26d0351
--- /dev/null
+++ b/tsx-frontend/tsx/modules.test.ts
@@ -0,0 +1,195 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { loadTsxModules } from "./modules.ts";
+import { checkModules } from "./rx/compiler.ts";
+import { compileModules } from "./rx/compiler.ts";
+import { lowerViews, STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+import { Runtime } from "./rx/runtime.ts";
+import type { ModuleSet } from "./rx/compiler.ts";
+
+const tsxParent = 'import { createSignal } from "solid-js"; import { Button as Child } from "./Button.EXT"; export function Counter() { const [count, setCount] = createSignal(0); return setCount(count() + 1)} />; }';
+const rxParent = 'import { Button as Child } from "./Button.EXT"; export component Counter() { source count := 0; { count := count + 1 }} /> }';
+const tsxChild = 'export function Button(props: { value: number; onIncrement: () => void }) { return props.onIncrement()}>{props.value} ; }';
+const rxChild = 'export component Button(props: { value: number; onIncrement: action catches(nothing) }) { { props.onIncrement(); }}>{"" + props.value} }';
+
+function load(files: Record, entry = "main.tsx") {
+ return loadTsxModules(entry, path => files[path]);
+}
+
+function mount(set: ModuleSet) {
+ const entry = set.programs.get(set.entry)!;
+ entry.view = [{ k: "el", tag: "Counter", attrs: [], children: [] }];
+ return set;
+}
+
+async function trace(set: ModuleSet, expected = ["0", "1", "2", "3"]) {
+ assert.deepEqual(checkModules(set).diags, []);
+ const rt = new Runtime(set);
+ const interpreted = [rt.step([]).at(-1)!.frame];
+ const findButton = (items: any[]): any => {
+ for (const item of items) {
+ if (item.k === "el" && item.tag === "button") return item;
+ const found = findButton(item.children ?? item.inst?.items ?? item.committed?.inst?.items ?? []);
+ if (found) return found;
+ }
+ };
+ for (let i = 1; i < expected.length; i++) {
+ const handler = findButton(rt.view.items)?.attrs.find((a: any) => a.name === "onClick").node;
+ assert.ok(handler, "the interpreter must expose the authored module handler");
+ handler.compute(rt)();
+ interpreted.push(rt.handler([]).at(-1)!.frame);
+ }
+ assert.deepEqual(interpreted, expected, "interpreter");
+ const built = compileModules(set);
+ const files = lowerViews(built.files, "universal");
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-modules-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ try {
+ for (const [name, text] of files) writeFileSync(dir + "/" + name, text.replaceAll(STRING_RENDERER, renderer));
+ const module = await import(pathToFileURL(dir + "/" + built.entry).href);
+ const app = createApp();
+ const dispose = app.mount(module.default(app));
+ try {
+ flush();
+ const frames = [app.frame()];
+ const button = (node: any): any => node.tag === "button" ? node : node.children?.map(button).find(Boolean);
+ for (let i = 1; i < expected.length; i++) { button(app.root).props.onClick(); flush(); frames.push(app.frame()); }
+ assert.deepEqual(frames, expected);
+ } finally { dispose(); }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+}
+
+for (const [parentExt, childExt] of [["tsx", "tsx"], ["tsx", "rx"], ["rx", "tsx"], ["rx", "rx"]]) {
+ test(parentExt + " imports " + childExt + ": aliases, live props and callbacks cross module factories", async () => {
+ const parent = (parentExt === "tsx" ? tsxParent : rxParent).replace(".EXT", "." + childExt);
+ const set = load({ ["main." + parentExt]: parent, ["Button." + childExt]: childExt === "tsx" ? tsxChild : rxChild }, "main." + parentExt);
+ assert.deepEqual(set.order, ["Button." + childExt, "main." + parentExt]);
+ assert.deepEqual(set.programs.get(set.entry)!.view, [], "exports are not implicit mounts");
+ await trace(mount(set));
+ });
+}
+
+test("a diamond reads and lowers each module once", () => {
+ const files: Record = {
+ "main.rx": 'import { Left } from "./left.rx"; import { Right } from "./right.rx"; export default component App() { }',
+ "left.rx": 'import { Shared } from "./shared.tsx"; export component Left() { }',
+ "right.rx": 'import { Shared } from "./shared.tsx"; export component Right() { }',
+ "shared.tsx": 'export function Shared() { return shared
; }',
+ };
+ const reads = new Map();
+ const set = loadTsxModules("main.rx", path => { reads.set(path, (reads.get(path) ?? 0) + 1); return files[path]; });
+ assert.deepEqual(checkModules(set).diags, []);
+ assert.equal(reads.get("shared.tsx"), 1);
+ assert.deepEqual(set.order, ["shared.tsx", "left.rx", "right.rx", "main.rx"]);
+ assert.equal(new Runtime(set).step([]).at(-1)!.frame, "sharedshared");
+});
+
+test("missing entry is a loader diagnostic, not an empty successful build", () => {
+ const set = load({});
+ assert.equal(set.diags[0].code, "MODULE_NOT_FOUND");
+ assert.equal(set.diags[0].file, "main.tsx");
+});
+
+test("missing imports retain the importing filename and line", () => {
+ const set = load({ "main.tsx": 'import { Child } from "./missing.rx"; export function Counter() { return ; }' });
+ assert.equal(set.diags[0].code, "MODULE_NOT_FOUND");
+ assert.equal(set.diags[0].file, "main.tsx");
+ assert.equal(set.diags[0].line, 1);
+});
+
+test("mixed-language cycles are rejected before lowering incomplete imports", () => {
+ const set = load({
+ "main.tsx": 'import { Child } from "./child.rx"; export function Counter() { return ; }',
+ "child.rx": 'import { Counter } from "./main.tsx"; export component Child() { }',
+ });
+ assert.equal(set.diags[0].code, "MODULE_CYCLE");
+ assert.match(set.diags[0].msg, /main.tsx.*child.rx.*main.tsx/);
+});
+
+test("RX declaration files retain JS purity contracts in a mixed graph", () => {
+ const set = load({
+ "main.rx": 'import { f } from "./api"; answer = f(1); export default component App() { {answer}
}',
+ "api.d.rx": 'export pure fn f(value: number): number',
+ }, "main.rx");
+ assert.equal(set.declarations?.size, 1);
+ assert.deepEqual(checkModules(set).diags, []);
+});
+
+test("module paths normalize without guessing extensions", () => {
+ const set = load({
+ "main.rx": 'import { Shared } from "./folder/../shared.tsx"; export default component App() { }',
+ "shared.tsx": 'export function Shared() { return ok
; }',
+ }, "./main.rx");
+ assert.equal(set.entry, "main.rx");
+ assert.deepEqual(checkModules(set).diags, []);
+});
+
+test("a shared missing dependency cannot accidentally lower a second incomplete importer", () => {
+ const set = load({
+ "main.rx": 'import { Left } from "./left.rx"; import { Right } from "./right.rx"; export default component App() { }',
+ "left.rx": 'import { Shared } from "./shared.rx"; export component Left() { }',
+ "right.rx": 'import { Shared } from "./shared.rx"; export component Right() { }',
+ "shared.rx": 'import { Missing } from "./missing.rx"; export component Shared() { }',
+ }, "main.rx");
+ assert.equal(set.diags[0].code, "MODULE_NOT_FOUND");
+ assert.equal(set.diags[0].file, "shared.rx");
+ assert.equal(set.programs.has("main.rx"), false);
+});
+
+test("unknown RX imports are still checked against exported signatures", () => {
+ const set = load({ "main.rx": 'import { Hidden } from "./child.rx"; export default component App() { ok
}', "child.rx": 'component Hidden() { hidden
}' }, "main.rx");
+ assert.ok(checkModules(set).diags.some(d => d.code === "NOT_EXPORTED"));
+});
+
+test("importing a source does not permit writing through the module boundary", () => {
+ const set = load({ "main.rx": 'import { value } from "./child.rx"; export default component App() { { value := 1 }}>{value} }', "child.rx": 'export source value := 0' }, "main.rx");
+ assert.ok(checkModules(set).diags.some(d => d.code === "WRITE_TO_NON_SOURCE" && d.rule === "MO-2"));
+});
+
+test("JS declaration contracts do not silently make a dirty TSX import pure", () => {
+ const set = load({
+ "main.tsx": 'import { createMemo } from "solid-js"; import { request as fetchValue } from "./api"; export function Counter() { const result = createMemo(async (): Promise => await fetchValue(1)); return {result()}
; }',
+ "api.d.rx": 'export fn request(n: number): number',
+ });
+ assert.ok(checkModules(mount(set)).diags.some(d => d.code === "IO_IN_REACTIVE"));
+});
+
+test("an RX component's failure contract remains checked at a TSX application", () => {
+ const set = load({
+ "main.tsx": 'import { Reader } from "./reader.rx"; export function Counter() { return ; }',
+ "reader.rx": 'import pure { request } from "./api"; value = await request(1); export component Reader() { {value}
}',
+ });
+ assert.ok(checkModules(mount(set)).diags.some(d => d.code === "UNHANDLED_FAILURE" && d.file === "main.tsx"));
+});
+
+for (const contextSyntax of ["tsx", "rx"]) for (const readerSyntax of ["tsx", "rx"]) {
+ test(contextSyntax + " context identity survives aliases in a " + readerSyntax + " reader", async () => {
+ const main = 'import { createSignal } from "solid-js"; import { Theme as Current } from "./context.' + contextSyntax + '"; import { Button as Reader } from "./reader.' + readerSyntax + '"; export function Counter() { const [n,setN] = createSignal(0); return setN(n()+1)}/> ; }';
+ const context = contextSyntax === "tsx"
+ ? 'import { createContext } from "solid-js"; export const Theme = createContext(7);'
+ : 'export context Theme: number = 7';
+ const reader = readerSyntax === "tsx"
+ ? 'import { useContext } from "solid-js"; import { Theme as ReadTheme } from "./context.' + contextSyntax + '"; export function Button(props: {onIncrement: () => void}) { const theme = useContext(ReadTheme); return props.onIncrement()}>{theme} ; }'
+ : 'import { Theme as ReadTheme } from "./context.' + contextSyntax + '"; export component Button(props: {onIncrement: action catches(nothing)}) { value = use ReadTheme; { props.onIncrement(); }}>{"" + value} }';
+ const set = load({ "main.tsx": main, ["context." + contextSyntax]: context, ["reader." + readerSyntax]: reader });
+ await trace(mount(set), ["0", "0", "0", "0"]);
+ });
+}
+
+for (const contextSyntax of ["tsx", "rx"]) for (const readerSyntax of ["tsx", "rx"]) {
+ test(contextSyntax + " source-context writes cross aliases in a " + readerSyntax + " reader", async () => {
+ const main = 'import { createSignal } from "solid-js"; import { Count as Current } from "./context.' + contextSyntax + '"; import { Reader } from "./reader.' + readerSyntax + '"; export function Counter() { const [count,setCount] = createSignal(0); return ; }';
+ const context = contextSyntax === "tsx"
+ ? 'import { createSourceContext } from "reactivescript-tsx"; export const Count = createSourceContext();'
+ : 'export context Count: source number';
+ const reader = readerSyntax === "tsx"
+ ? 'import { useSourceContext } from "reactivescript-tsx"; import { Count as Current } from "./context.' + contextSyntax + '"; export function Reader() { const [count,setCount] = useSourceContext(Current); return setCount(count()+1)}>{count()} ; }'
+ : 'import { Count as Current } from "./context.' + contextSyntax + '"; export component Reader() { { use Current := use Current + 1 }}>{"" + use Current} }';
+ await trace(mount(load({ "main.tsx": main, ["context." + contextSyntax]: context, ["reader." + readerSyntax]: reader })));
+ });
+}
diff --git a/tsx-frontend/tsx/modules.ts b/tsx-frontend/tsx/modules.ts
new file mode 100644
index 0000000..5f61eb8
--- /dev/null
+++ b/tsx-frontend/tsx/modules.ts
@@ -0,0 +1,99 @@
+// Load TSX and RX into the same dependency-ordered module set and checker.
+import { parseSync } from "oxc-parser/src-js/index";
+import { parseProgram, parseDeclarations, ParseError, type JsDecl } from "./rx/compiler.ts";
+import { joinPath, type ModuleSet } from "./rx/compiler.ts";
+import type { Program } from "./rx/ast.ts";
+import { lowerTsx, TsxError } from "./lower.ts";
+
+/** File extensions are explicit, just as they are for the RX module loader. */
+export function loadTsxModules(entry: string, read: (path: string) => string | undefined): ModuleSet {
+ entry = joinPath("", entry);
+ const programs = new Map();
+ const order: string[] = [];
+ const diags: ModuleSet["diags"] = [];
+ const declarations = new Map();
+ const state = new Map();
+ const sources = new Map();
+ const sourceOf = (path: string) => {
+ if (!sources.has(path)) sources.set(path, read(path));
+ return sources.get(path);
+ };
+ const isModule = (path: string) => /\.(rx|tsx)$/.test(path);
+ const jsDeclaration = (path: string, from: string) => {
+ if (!from.startsWith(".")) return;
+ const key = joinPath(path, from.replace(/\.(m?js|ts)$/, "") + ".d.rx");
+ const text = sourceOf(key);
+ if (text === undefined) return;
+ if (!declarations.has(key)) {
+ try { declarations.set(key, parseDeclarations(text)); }
+ catch (e) { if (e instanceof ParseError) throw new ParseError(key + ": " + e.message); throw e; }
+ }
+ return key;
+ };
+ const visit = (path: string, stack: string[]): void => {
+ if (state.get(path) === "done") return;
+ const source = sourceOf(path);
+ if (source === undefined) return;
+ if (!isModule(path)) throw new Error("Expected an explicit .tsx or .rx module: " + path);
+ state.set(path, "busy");
+ let program: Program | undefined;
+ let imports: { from: string; line: number }[];
+ if (path.endsWith(".tsx")) {
+ const parsed = parseSync(path, source, { sourceType: "module", lang: "tsx" });
+ if (parsed.errors.length) {
+ const error = parsed.errors[0];
+ throw new TsxError(path, source, error.labels[0] ?? { start: 0, end: 0 }, error.message);
+ }
+ imports = parsed.program.body.flatMap(n => n.type === "ImportDeclaration"
+ ? [{ from: n.source.value, line: source.slice(0, n.start).split("\n").length }] : []);
+ } else {
+ try { program = parseProgram(source); }
+ catch (e) { if (e instanceof ParseError) throw new ParseError(path + ": " + e.message); throw e; }
+ imports = program.decls.flatMap(d => d.k === "import" ? [{ from: d.from, line: d.line }] : []);
+ }
+ const modules = new Map();
+ const jsDeclarations = new Map();
+ const before = diags.length;
+ for (const imp of imports) {
+ if (!isModule(imp.from)) {
+ const key = jsDeclaration(path, imp.from);
+ if (key) jsDeclarations.set(imp.from, declarations.get(key)!);
+ continue;
+ }
+ const to = joinPath(path, imp.from);
+ if (state.get(to) === "busy") {
+ diags.push({ code: "MODULE_CYCLE", rule: "MO-5", file: path, line: imp.line,
+ msg: [...stack, path, to].join(" -> ") + ": modules may not import each other in a cycle" });
+ } else if (sourceOf(to) === undefined) {
+ diags.push({ code: "MODULE_NOT_FOUND", rule: "MO-6", file: path, line: imp.line,
+ msg: "no module " + imp.from + " (from " + path + ")" });
+ } else {
+ visit(to, [...stack, path]);
+ const dependency = programs.get(to);
+ if (dependency) modules.set(imp.from, { path: to, program: dependency });
+ }
+ }
+ // Never lower an incomplete graph as though its imports were ordinary JS.
+ if (diags.length !== before || imports.some(imp => isModule(imp.from) && !modules.has(imp.from))) {
+ state.set(path, "done"); return;
+ }
+ if (!program) program = lowerTsx(source, path, { modules, declarations: jsDeclarations });
+ for (const d of program.decls) {
+ if (d.k !== "import") continue;
+ if (isModule(d.from)) { d.resolved = joinPath(path, d.from); continue; }
+ const key = jsDeclaration(path, d.from);
+ if (!key) continue;
+ const ds = declarations.get(key)!;
+ d.declared = key;
+ d.declaredErrors = ds.filter(x => x.kind === "error").map(x => x.name);
+ d.declaredPure = ds.filter(x => x.kind === "fn" && x.pure).map(x => x.name);
+ d.declaredSide = Object.fromEntries(ds.flatMap(x => x.kind === "fn" && x.side ? [[x.name, x.side]] : []));
+ }
+ programs.set(path, program);
+ state.set(path, "done");
+ order.push(path);
+ };
+ if (sourceOf(entry) === undefined) diags.push({ code: "MODULE_NOT_FOUND", rule: "MO-6", file: entry, line: 1, msg: "no entry module " + entry });
+ else visit(entry, []);
+ return { entry, programs, order, diags, declarations };
+}
diff --git a/tsx-frontend/tsx/owned-helpers.test.ts b/tsx-frontend/tsx/owned-helpers.test.ts
new file mode 100644
index 0000000..18ee5c0
--- /dev/null
+++ b/tsx-frontend/tsx/owned-helpers.test.ts
@@ -0,0 +1,118 @@
+import { test, type TestContext } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { buildTsx } from "./build.ts";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+import { Runtime } from "./rx/runtime.ts";
+
+async function compare(t: TestContext, source: string, expected: string[]) {
+ const root = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(root, { recursive: true });
+ const dir = mkdtempSync(root + "tsx-owned-helpers-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ let nativeFrames: string[] = [];
+ try {
+ for (const name of ["native.mts", "main.mjs"]) {
+ const built = name === "main.mjs"
+ ? buildTsx("main.tsx", path => path === "main.tsx" ? source : undefined, { target: "universal", mount: "Counter" })
+ : undefined;
+ const code = built ? built.files.get(name)!
+ : transform(source, { filename: "Counter.tsx", generate: "universal", moduleName: STRING_RENDERER }).code;
+ writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(dir + "/" + name).href);
+ const app = createApp();
+ const dispose = app.mount(name === "native.mts" ? () => mod.Counter() : mod.default(app));
+ const settle = async () => {
+ for (let i = 0; i < 3; i++) {
+ await new Promise(done => setImmediate(done));
+ flush();
+ }
+ };
+ try {
+ await settle();
+ const frames = [app.frame()];
+ const button = (node: any): any => node.tag === "button" ? node : node.children?.map(button).find(Boolean);
+ for (let i = 1; i < expected.length; i++) {
+ const el = button(app.root);
+ assert.equal(typeof el?.props.onClick, "function", name + " must render the authored button, not stringify it");
+ el.props.onClick();
+ await settle();
+ frames.push(app.frame());
+ }
+ t.diagnostic(name + " frames: " + JSON.stringify(frames));
+ assert.deepEqual(frames, expected, name);
+ if (name === "native.mts") nativeFrames = frames;
+ else assert.deepEqual(frames, nativeFrames, "compiled TSX must match native Solid");
+ } finally { dispose(); }
+
+ if (built) {
+ const rt = new Runtime(built.set);
+ try {
+ const frames = [rt.step([]).at(-1)!.frame];
+ const button = (items: any[]): any => {
+ for (const item of items) {
+ if (item.k === "el" && item.tag === "button") return item;
+ const nested = item.children ?? item.inst?.items ?? item.node?.value?.items ?? [];
+ const found = button(nested);
+ if (found) return found;
+ }
+ };
+ for (let i = 1; i < expected.length; i++) {
+ const handler = button(rt.view.items)?.attrs.find((attr: any) => attr.name === "onClick")?.node;
+ assert.ok(handler, "interpreter must render the authored button handler through child slots");
+ const click = handler.compute(rt);
+ assert.equal(typeof click, "function");
+ click();
+ frames.push(rt.handler([]).at(-1)!.frame);
+ }
+ t.diagnostic("interpreter frames: " + JSON.stringify(frames));
+ assert.deepEqual(frames, nativeFrames, "interpreter must match native Solid");
+ } finally { rt.root.dispose(); }
+ }
+ }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+}
+
+test("accessor-returning helpers keep ref arguments live and snapshot value arguments and locals", async t => {
+ await compare(t, `import { createSignal, createMemo, untrack } from "solid-js";
+function useCounter(base: () => number, seed: number): () => number {
+ const captured = untrack(() => base());
+ const value = createMemo(() => base() + seed + captured);
+ return value;
+}
+export function Counter() {
+ const [n, setN] = createSignal(1);
+ const value = useCounter(n, n());
+ return setN(n() + 1)}>{n()}/{value()} ;
+}`, ["1/3", "2/4", "3/5", "4/6"]);
+});
+
+test("accessor-returning helpers retain owned signal and effect state across ref updates", async t => {
+ await compare(t, `import { createSignal, createMemo } from "solid-js";
+import { createRxEffect } from "reactivescript-tsx";
+function useCounter(base: () => number, seed: number): () => number {
+ const [local, setLocal] = createSignal(seed);
+ createRxEffect([() => base()], n => { setLocal(p => p + 1); });
+ const value = createMemo(() => base() + local());
+ return value;
+}
+export function Counter() {
+ const [n, setN] = createSignal(1);
+ const value = useCounter(n, n());
+ return setN(n() + 1)}>{n()}/{value()} ;
+}`, ["1/3", "2/5", "3/7", "4/9"]);
+});
+
+test("Solid Element children render caller-owned live nodes instead of stringifying them", async t => {
+ await compare(t, `import { type Element, createSignal } from "solid-js";
+function Frame(props: { children: Element }) {
+ return {props.children}
;
+}
+export function Counter() {
+ const [n, setN] = createSignal(0);
+ return setN(n() + 1)}>{n()} ;
+}`, ["0", "1", "2", "3"]);
+});
diff --git a/tsx-frontend/tsx/owned-lifetimes.test.ts b/tsx-frontend/tsx/owned-lifetimes.test.ts
new file mode 100644
index 0000000..5a4d4e9
--- /dev/null
+++ b/tsx-frontend/tsx/owned-lifetimes.test.ts
@@ -0,0 +1,218 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp, flush } from "./rx/runtime.ts";
+import { Runtime } from "./rx/runtime.ts";
+import { check } from "./rx/compiler.ts";
+import { parseProgram } from "./rx/compiler.ts";
+import { buildTsx } from "./build.ts";
+
+const fixtures = [
+ {
+ name: "S-region-7 structured context registration and cleanup",
+ // Native R.render flushes mount effects; the interpreter also exposes the first instant.
+ mount: "0 valid",
+ frames: ["2 invalid", "a2 invalid", "ae2 valid", "a1 valid", "ae2 valid", "e2 invalid", "1 invalid", "1 invalid", "hidden2 invalid"],
+ source: `import {createSignal,createStore,createMemo,Show,type Element} from "solid-js";
+import {createSourceContext,useSourceContext,createRxEffect} from "reactivescript-tsx";
+const Fields=createSourceContext<{ok:boolean}[]>();
+function Form(props:{children:Element}) {
+ const initial:{ok:boolean}[]=[];
+ const [rows,setRows]=createStore(initial);
+ return {props.children}{rows.length} {rows.every(row=>row.ok)?"valid":"invalid"}
;
+}
+function Field(props:{value:string}) {
+ const [rows,setRows]=useSourceContext(Fields);
+ // Store rows keep their identity through the parent array's proxies.
+ const [row,setRow]=createStore({ok:false});
+ const valid=createMemo(()=>props.value!=="");
+ createRxEffect([()=>valid()],ok=>{
+ setRow(previous=>{previous.ok=ok;});
+ setRows(previous=>[...previous,row]);
+ return ()=>{setRows(previous=>previous.filter(entry=>entry!==row));};
+ });
+ return {props.value} ;
+}
+export function Counter() {
+ const [name,setName]=createSignal("");
+ const [email,setEmail]=createSignal("");
+ const [shown,setShown]=createSignal(true);
+ const [step,setStep]=createSignal(0);
+ return ;
+}`,
+ },
+ {
+ name: "Show helper retains live refs, disposes effects, and captures a fresh remount seed",
+ frames: ["1/4/1/1/0", "2/6/2/1/1", "2/-/2/0/2", "3/-/2/0/2", "3/10/3/1/2", "4/12/4/1/3", "4/-/4/0/4", "5/-/4/0/4", "5/16/5/1/4"],
+ source: `import {createSignal,createMemo,Show,untrack} from "solid-js";
+import {createRxEffect} from "reactivescript-tsx";
+const [runs,setRuns]=createSignal(0);
+const [active,setActive]=createSignal(0);
+const [cleanups,setCleanups]=createSignal(0);
+function useCounter(base:()=>number,seed:number):()=>number {
+ const captured=untrack(()=>base());
+ const [local,setLocal]=createSignal(seed);
+ const current=createMemo(()=>base());
+ createRxEffect([()=>current()],sample=>{
+ setLocal(previous=>previous+1);
+ setRuns(previous=>previous+1);
+ setActive(previous=>previous+1);
+ return ()=>{setActive(previous=>previous-1);setCleanups(previous=>previous+1);};
+ });
+ const value=createMemo(()=>current()+local()+captured);
+ return value;
+}
+function Child(props:{n:number}) {
+ const base=createMemo(()=>props.n);
+ const value=useCounter(base,props.n);
+ return {value()} ;
+}
+export function Counter() {
+ const [n,setN]=createSignal(1);
+ const [shown,setShown]=createSignal(true);
+ const [step,setStep]=createSignal(0);
+ return {
+ if(step()===0) setN(2);
+ else if(step()===1) setShown(false);
+ else if(step()===2) setN(3);
+ else if(step()===3) setShown(true);
+ else if(step()===4) setN(4);
+ else if(step()===5) setShown(false);
+ else if(step()===6) setN(5);
+ else setShown(true);
+ setStep(previous=>previous+1);
+ }}>{n()}/ /{runs()}/{active()}/{cleanups()} ;
+}`,
+ },
+ {
+ name: "keyed row helpers survive reorder, dispose on removal, and remount after readdition",
+ frames: ["1:31;2:61;|2/2/0", "2:61;1:33;|3/2/1", "1:33;2:61;|3/2/1", "2:61;|3/1/2", "2:63;|4/1/3", "|4/0/4", "|4/0/4", "1:50;2:65;|6/2/4", "2:65;1:50;|6/2/4", "2:65;1:52;|7/2/5"],
+ source: `import {createSignal,createStore,createMemo,For,untrack} from "solid-js";
+import {createRxEffect} from "reactivescript-tsx";
+const [runs,setRuns]=createSignal(0);
+const [active,setActive]=createSignal(0);
+const [cleanups,setCleanups]=createSignal(0);
+function useCounter(base:()=>number,seed:number):()=>number {
+ const captured=untrack(()=>base());
+ const [local,setLocal]=createSignal(seed);
+ const current=createMemo(()=>base());
+ createRxEffect([()=>current()],sample=>{
+ setLocal(previous=>previous+1);
+ setRuns(previous=>previous+1);
+ setActive(previous=>previous+1);
+ return ()=>{setActive(previous=>previous-1);setCleanups(previous=>previous+1);};
+ });
+ const value=createMemo(()=>current()+local()+captured);
+ return value;
+}
+function Row(props:{id:number;first:number;tick:number}) {
+ const base=createMemo(()=>(props.id===1?props.first:20)+props.tick);
+ const value=useCounter(base,props.id===1?props.first:20);
+ return {props.id}:{value()}; ;
+}
+export function Counter() {
+ const [rows,setRows]=createStore([1,2]);
+ const [first,setFirst]=createSignal(10);
+ const [tick,setTick]=createSignal(0);
+ const [step,setStep]=createSignal(0);
+ return {
+ if(step()===0) {setFirst(11);setRows(ids=>[2,1]);}
+ else if(step()===1) setRows(ids=>[1,2]);
+ else if(step()===2) setRows(ids=>[2]);
+ else if(step()===3) setTick(1);
+ else if(step()===4) setRows(ids=>[]);
+ else if(step()===5) setTick(2);
+ else if(step()===6) {setFirst(15);setRows(ids=>[1,2]);}
+ else if(step()===7) setRows(ids=>[2,1]);
+ else setFirst(16);
+ setStep(previous=>previous+1);
+ }}>{id=>}
|{runs()}/{active()}/{cleanups()} ;
+}`,
+ },
+];
+
+test("functional writes discard transient predicates but retain stored closure provenance", () => {
+ const cases = [
+ { value: "previous => [...previous, local]", escape: false },
+ { value: "previous => filter(previous, entry => entry != local)", escape: false },
+ { value: "previous => [...previous, () => local]", escape: true },
+ { value: "previous => () => local", escape: true },
+ { value: "previous => filter([() => local], entry => true)", escape: true },
+ { value: "previous => map([1], entry => () => local)", escape: true },
+ { value: "[() => filter([], entry => local)]", escape: true },
+ { value: "previous => filter(previous, entry => local)", escape: true, shadow: true },
+ ];
+ for (const { value, escape, shadow } of cases) {
+ const source = "source rows := [1]; source saved := []; export default component App() { {for item in rows {source local := 1; " +
+ (shadow ? "fn filter(items, predicate) => predicate; " : "") +
+ " {saved := " + value + "}}>x }} }";
+ const diagnostics = check(parseProgram(source));
+ assert.equal(diagnostics.some(d => d.code === "REGION_ESCAPE"), escape, value);
+ if (!escape) assert.deepEqual(diagnostics, [], value);
+ }
+});
+
+for (const fixture of fixtures) {
+ for (const native of [true, false]) {
+ test((native ? "native: " : "compiled/interpreter: ") + fixture.name, async () => {
+ const out = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(out, { recursive: true });
+ const dir = mkdtempSync(out + "tsx-owned-lifetimes-");
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ try {
+ const built = native ? undefined : buildTsx("main.tsx", path => path === "main.tsx" ? fixture.source : undefined, { target: "universal", mount: "Counter" });
+ const files = built?.files ?? new Map([["native.mts", transform(fixture.source, { filename: "main.tsx", generate: "universal", moduleName: STRING_RENDERER }).code]]);
+ for (const [name, code] of files) writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(dir + "/" + (built?.entry ?? "native.mts")).href);
+ const app = createApp();
+ const dispose = app.mount(native ? () => mod.Counter() : mod.default(app));
+ const settle = async () => { flush(); await Promise.resolve(); flush(); };
+ const button = (node: any): any => node.tag === "button" ? node : node.children?.map(button).find(Boolean);
+ try {
+ await settle();
+ const frames = [app.frame()];
+ for (let i = 1; i < fixture.frames.length; i++) {
+ const click = button(app.root)?.props.onClick;
+ assert.equal(typeof click, "function", "the authored child button must remain live");
+ click(); await settle(); frames.push(app.frame());
+ }
+ assert.deepEqual(frames, fixture.frames);
+ } finally { dispose(); flush(); }
+ if (built) {
+ const rt = new Runtime(built.set);
+ try {
+ const mounted = rt.step([]);
+ if (fixture.mount !== undefined) assert.equal(mounted[0].frame, fixture.mount, "interpreter before registration effects");
+ const frames = [mounted.at(-1)!.frame];
+ const button = (items: any[]): any => {
+ for (const item of items) {
+ if (item.k === "el" && item.tag === "button") return item;
+ const nested = item.children ?? item.inst?.items ?? item.node?.value?.items ?? item.committed?.inst?.items ?? item.committedRows?.flatMap((row: any) => row.inst.items) ?? [];
+ const hit = button(nested); if (hit) return hit;
+ }
+ };
+ for (let i = 1; i < fixture.frames.length; i++) {
+ const handler = button(rt.view.items)?.attrs.find((attr: any) => attr.name === "onClick")?.node;
+ assert.ok(handler, "interpreter must retain the authored child handler");
+ handler.compute(rt)(); frames.push(rt.handler([]).at(-1)!.frame);
+ }
+ assert.deepEqual(frames, fixture.frames, "interpreter");
+ } finally { rt.root.dispose(); }
+ }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+ });
+ }
+}
diff --git a/tsx-frontend/tsx/paired.test.ts b/tsx-frontend/tsx/paired.test.ts
new file mode 100644
index 0000000..fcecc6f
--- /dev/null
+++ b/tsx-frontend/tsx/paired.test.ts
@@ -0,0 +1,98 @@
+// First executable milestone of the moving-foundation layout: an authored TSX counter and an authored RX counter,
+// both default-component entries (MO-1c), lowered/parsed onto the pinned upstream Program and checker. They must have the
+// same canonical semantic facts (tsx/rx/facts.ts) and the same interpreter, compiled and native frames. No RX import,
+// raw AST or shared constructor stands in for the TSX side.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { transform } from "@solidjs/compiler";
+import { loadTsxModules } from "./modules.ts";
+import { semanticFacts } from "./rx/facts.ts";
+import { compileModules, lowerViews, STRING_RENDERER, type ModuleSet } from "./rx/compiler.ts";
+import { createApp, flush, Runtime } from "./rx/runtime.ts";
+
+const fixture = (name: string) => readFileSync(new URL("./fixtures/paired/" + name, import.meta.url), "utf8");
+const load = (name: string): ModuleSet => {
+ const files: Record = { [name]: fixture(name) };
+ const set = loadTsxModules(name, path => files[path]);
+ assert.deepEqual(set.diags, [], name + " loads");
+ return set;
+};
+const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+const outRoot = fileURLToPath(new URL("../.out/", import.meta.url));
+const buttons = (node: any): any[] => node?.tag === "button" ? [node] : (node?.children ?? []).flatMap(buttons);
+const CLICKS = 2;
+
+function interpreted(set: ModuleSet): string[] {
+ const rt = new Runtime(set);
+ const frames = [rt.step([]).at(-1)!.frame];
+ const find = (items: any[]): any => {
+ for (const item of items) {
+ if (item.k === "el" && item.tag === "button") return item;
+ const hit = find(item.children ?? item.inst?.items ?? []);
+ if (hit) return hit;
+ }
+ };
+ for (let i = 0; i < CLICKS; i++) {
+ const handler = find(rt.view.items)?.attrs.find((a: any) => a.name === "onClick")?.node;
+ assert.ok(handler, "the interpreter exposes the authored handler");
+ handler.compute(rt)();
+ frames.push(rt.handler([]).at(-1)!.frame);
+ }
+ return frames;
+}
+
+async function rendered(files: Map, entry: string, mountOf: (mod: any, app: any) => unknown): Promise {
+ mkdirSync(outRoot, { recursive: true });
+ const dir = mkdtempSync(outRoot + "paired-");
+ try {
+ for (const [name, code] of files) writeFileSync(dir + "/" + name, code.replaceAll(STRING_RENDERER, renderer));
+ const mod = await import(pathToFileURL(dir + "/" + entry).href);
+ const app = createApp();
+ const dispose = app.mount(mountOf(mod, app));
+ try {
+ flush();
+ const frames = [app.frame()];
+ for (let i = 0; i < CLICKS; i++) {
+ const [button] = buttons(app.root);
+ assert.equal(typeof button?.props.onClick, "function", "the authored handler is rendered");
+ button.props.onClick();
+ flush();
+ frames.push(app.frame());
+ }
+ return frames;
+ } finally { dispose(); }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+}
+
+const compiled = (set: ModuleSet) => {
+ const built = compileModules(set);
+ return rendered(lowerViews(built.files, "universal"), built.entry, (mod, app) => mod.default(app));
+};
+
+test("paired counter: TSX and RX have the same canonical semantic facts", () => {
+ const tsx = semanticFacts(load("counter.tsx"));
+ const rx = semanticFacts(load("counter.rx"));
+ assert.deepEqual(tsx.diagnostics, []);
+ assert.ok(tsx.modules[0].computations.some(c => c.kind === "memo" && c.reads.some(r => r.includes("count tracked"))), "the memo tracks count");
+ assert.ok(tsx.modules[0].computations.some(c => c.kind === "handler" && c.writes.some(w => w.includes("count write"))), "the handler writes count");
+ assert.deepEqual(tsx, rx);
+});
+
+test("paired counter: a different read mode is a fact difference, not normalized away", () => {
+ assert.notDeepEqual(semanticFacts(load("counter.tsx")), semanticFacts(load("counter-peek.rx")));
+});
+
+test("paired counter: interpreter, compiled and native frames agree", async () => {
+ const expected = ["0", "2", "4"];
+ const native = await rendered(
+ new Map([["native.mjs", transform(fixture("counter.tsx"), { filename: "counter.tsx", generate: "universal", moduleName: STRING_RENDERER }).code]]),
+ "native.mjs", mod => () => mod.default());
+ assert.deepEqual(native, expected, "native Solid");
+ for (const name of ["counter.tsx", "counter.rx"]) {
+ const set = load(name);
+ assert.deepEqual(interpreted(set), expected, name + " interpreter");
+ assert.deepEqual(await compiled(set), expected, name + " compiled");
+ }
+});
diff --git a/tsx-frontend/tsx/previous.test.ts b/tsx-frontend/tsx/previous.test.ts
new file mode 100644
index 0000000..27260e5
--- /dev/null
+++ b/tsx-frontend/tsx/previous.test.ts
@@ -0,0 +1,402 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import {
+ Errored, Loading, Show, createComponent, createEffect, createMemo, createProjection,
+ createRoot, createSignal, flush, onCleanup, type Accessor, type Element,
+} from "solid-js";
+import { createPrevious } from "reactivescript-tsx";
+import { parseProgram, parseStmts } from "./rx/compiler.ts";
+import { check } from "./rx/compiler.ts";
+import { Runtime } from "./rx/runtime.ts";
+import { compile } from "./rx/compiler.ts";
+import { lowerViews, STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp } from "./rx/runtime.ts";
+import { R, serialize } from "./rx/runtime.ts";
+import { lowerTsx } from "./lower.ts";
+import { traces } from "./test-utils.ts";
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(yes => { resolve = yes; });
+ return { promise, resolve };
+}
+
+function text(read: () => unknown) {
+ const node = R.createElement("span");
+ R.insert(node, read);
+ return node;
+}
+
+function mount(view: () => Element, host: ReturnType) {
+ return R.render(() => text(view), host);
+}
+
+type Writes = [string, number | boolean][][];
+async function assertCore(source: string, writes: Writes, expected: string[], effects: string[] = [], interpreterExpected = expected) {
+ const program = parseProgram(source);
+ assert.deepEqual(check(program), []);
+ const runtime = new Runtime(program);
+ try {
+ const first = runtime.step([]);
+ const frames = [first.at(-1)!.frame], log = first.flatMap(instant => instant.effects);
+ for (const instant of writes) {
+ const trace = runtime.handler(parseStmts(instant.map(([name, value]) => name + " := " + String(value)).join(";")));
+ frames.push(trace.at(-1)!.frame); log.push(...trace.flatMap(instant => instant.effects));
+ }
+ assert.deepEqual(frames, interpreterExpected, "raw RX interpreter");
+ assert.deepEqual(log, effects, "raw RX interpreter effects");
+ } finally { runtime.root.dispose(); }
+ const out = fileURLToPath(new URL("../.out/", import.meta.url));
+ mkdirSync(out, { recursive: true });
+ const dir = mkdtempSync(out + "tsx-previous-core-");
+ try {
+ const renderer = fileURLToPath(new URL("./test-renderer.ts", import.meta.url));
+ const code = lowerViews(new Map([["main.mjs", compile(program)]]), "universal").get("main.mjs")!;
+ writeFileSync(dir + "/main.mjs", code.replaceAll(STRING_RENDERER, renderer));
+ const module = await import(pathToFileURL(dir + "/main.mjs").href);
+ const app = createApp(), dispose = app.mount(module.default(app));
+ try {
+ flush(); const frames = [app.frame()];
+ for (const instant of writes) {
+ for (const [name, value] of instant) app.dset(name, [], value);
+ flush(); frames.push(app.frame());
+ }
+ assert.deepEqual(frames, expected, "raw RX compiled");
+ assert.deepEqual(app.log, effects, "raw RX compiled effects");
+ } finally { dispose(); }
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+}
+
+test("native S-core-1 previous values survive equal writes, batching, and hiding the current reader", async () => {
+ const expected = ["0 prev 0", "2 prev 0", "2 prev 0", "14 prev 2", "- prev 2", "- prev 14"];
+ const effects = ["log(0)", "log(2)", "log(14)", "log(16)"];
+ const writes: Writes = [[["count", 1]], [["count", 1]], [["count", 5], ["count", 7]], [["show", false]], [["count", 8]]];
+ for (const withEffect of [false, true]) {
+ const computed: number[] = [], log: string[] = [];
+ const app = createRoot(dispose => {
+ const [count, setCount] = createSignal(0), [show, setShow] = createSignal(true);
+ const double = createMemo(() => { const n = count(); computed.push(n); return n * 2; });
+ const previous = createPrevious(double, () => 0);
+ const label = createMemo(() => "prev " + previous());
+ if (withEffect) createEffect(() => double(), value => { log.push("log(" + value + ")"); });
+ const frame = createMemo(() => (show() ? double() : "-") + " " + label());
+ return { dispose, setCount, setShow, frame };
+ });
+ try {
+ flush(); const frames = [app.frame()];
+ app.setCount(1); flush(); frames.push(app.frame());
+ app.setCount(1); flush(); frames.push(app.frame());
+ app.setCount(5); app.setCount(7); flush(); frames.push(app.frame());
+ app.setShow(false); flush(); frames.push(app.frame());
+ app.setCount(8); flush(); frames.push(app.frame());
+ assert.deepEqual(frames, expected);
+ assert.deepEqual(computed, [0, 1, 7, 8]);
+ assert.deepEqual(log, withEffect ? effects : []);
+ } finally { app.dispose(); }
+ await assertCore('source count := 0; source show := true; double = count * 2; label = "prev " + pre double -> 0; ' +
+ (withEffect ? 'effect (double) {log(double)} ' : '') + 'export default component App() { {show ? double : "-"} {label}
}', writes, expected, withEffect ? effects : []);
+ }
+});
+
+test("a same-setup companion retains history before the previous accessor is first displayed", async () => {
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ const double = createMemo(() => n() * 2);
+ const previous = createPrevious(double, () => 0);
+ const frame = createMemo(() => double() + "/" + (n() >= 3 ? previous() : "-"));
+ return { dispose, setN, frame };
+ });
+ const expected = ["0/-", "2/-", "4/-", "6/4", "8/6"];
+ try {
+ const frames = [app.frame()];
+ for (const n of [1, 2, 3, 4]) { app.setN(n); flush(); frames.push(app.frame()); }
+ assert.deepEqual(frames, expected);
+ } finally { app.dispose(); }
+ await assertCore('source n := 0; double = n * 2; export default component App() { {double}/{n >= 3 ? pre double -> 0 : "-"}
}',
+ [[["n", 1]], [["n", 2]], [["n", 3]], [["n", 4]]], expected);
+});
+
+test("a companion created later cannot reconstruct an inherited target's earlier history", () => {
+ const target = createRoot(dispose => {
+ const [n, setN] = createSignal(0), double = createMemo(() => n() * 2);
+ return { dispose, setN, double };
+ });
+ let disposePrevious = () => {};
+ try {
+ assert.equal(target.double(), 0);
+ target.setN(1); flush(); assert.equal(target.double(), 2);
+ target.setN(2); flush(); assert.equal(target.double(), 4);
+ const previous = createRoot(dispose => { disposePrevious = dispose; return createPrevious(target.double, () => -1); });
+ assert.equal(previous(), -1);
+ target.setN(3); flush(); assert.equal(previous(), 4);
+ } finally { disposePrevious(); target.dispose(); }
+});
+
+test("same-owner Show demand preserves warm history and records the existing cold-target baseline divergence", async t => {
+ for (const displayCurrent of [true, false]) {
+ const host = R.createElement("main"), computed: number[] = [];
+ let setN!: (n: number) => void, setShown!: (shown: boolean) => void;
+ const dispose = mount(() => {
+ const [n, update] = createSignal(0), [shown, show] = createSignal(false); setN = update; setShown = show;
+ const input = createMemo(() => { computed.push(n()); return n() * 2; });
+ const previous = createPrevious(input, () => -1);
+ return [text(() => displayCurrent ? input() : "-"), "|", createComponent(Show, {
+ get when() { return shown(); }, fallback: "-", get children() { return text(previous); },
+ })];
+ }, host);
+ const expected = displayCurrent ? ["0|-", "2|-", "4|-", "4|2"] : ["-|-", "-|-", "-|-", "-|2"];
+ try {
+ flush(); const frames = [serialize(host)];
+ setN(1); flush(); frames.push(serialize(host));
+ setN(2); flush(); frames.push(serialize(host));
+ assert.deepEqual(computed, [0, 1, 2], "pinned Solid eagerly maintains the packet input even when hidden");
+ setShown(true); flush(); frames.push(serialize(host));
+ assert.deepEqual(frames, expected);
+ } finally { dispose(); }
+ const interpreted = displayCurrent ? expected : ["-|-", "-|-", "-|-", "-|-1"];
+ if (!displayCurrent) t.diagnostic("Existing cold-target demand divergence: native/compiled reveal previous 2; interpreter first demands input on reveal and uses fallback -1.");
+ await assertCore('source n := 0; source shown := false; double = n * 2; previous = pre double -> -1; export default component App() { ' +
+ (displayCurrent ? '{double}' : '-') + '|{shown ? previous : "-"}
}',
+ [[["n", 1]], [["n", 2]], [["shown", true]]], expected, [], interpreted);
+ }
+});
+
+test("previous follows original memo equality cuts rather than every upstream write or accessor read", async () => {
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ const current = createMemo(() => n() >= 2 ? 1 : 0);
+ const previous = createPrevious(current, () => -1);
+ return { dispose, setN, current, previous };
+ });
+ const expected = ["0/-1", "0/-1", "1/0", "1/0", "1/0", "0/1"];
+ try {
+ const frame = () => app.current() + "/" + app.previous();
+ const frames = [frame()];
+ assert.equal(app.previous(), -1); assert.equal(app.previous(), -1);
+ for (const n of [1, 2, 3, 3, 0]) { app.setN(n); flush(); frames.push(frame()); }
+ assert.deepEqual(frames, expected);
+ } finally { app.dispose(); }
+ await assertCore('source n := 0; current = n >= 2 ? 1 : 0; export default component App() { {current}/{pre current -> -1}
}',
+ [[["n", 1]], [["n", 2]], [["n", 3]], [["n", 3]], [["n", 0]]], expected);
+});
+
+test("chained previous accessors equality-cut repeated intermediate answers", async () => {
+ const app = createRoot(dispose => {
+ const [x, setX] = createSignal(0);
+ const previous = createPrevious(x, () => 0), twice = createPrevious(previous, () => -1);
+ return { dispose, setX, frame: () => x() + "/" + previous() + "/" + twice() };
+ });
+ const expected = ["0/0/-1", "1/0/-1", "2/1/0", "3/2/1"];
+ try {
+ const frames = [app.frame()];
+ for (const x of [1, 2, 3]) { app.setX(x); flush(); frames.push(app.frame()); }
+ assert.deepEqual(frames, expected);
+ } finally { app.dispose(); }
+ await assertCore('source x := 0; previous = pre x -> 0; twice = pre previous -> -1; export default component App() { {x}/{previous}/{twice}
}',
+ [[["x", 1]], [["x", 2]], [["x", 3]]], expected);
+ const source = 'import {createSignal} from "solid-js";import {createPrevious} from "reactivescript-tsx";export function Counter(){const [x,setX]=createSignal(0);const previous=createPrevious(x,()=>0);const twice=createPrevious(previous,()=>-1);return setX(x()+1)}>{x()}/{previous()}/{twice()} }';
+ await traces(source, lowerTsx(source), expected);
+});
+
+test("seeded previous falls back on undefined history values, but never on null", async () => {
+ for (const initial of ["undefined", "null"]) {
+ const expected = initial === "undefined" ? ["undefined/99", "1/99", "2/1"] : ["null/99", "1/null", "2/1"];
+ const source = 'import {createSignal,createMemo} from "solid-js";import {createPrevious} from "reactivescript-tsx";export function Counter(){const [n,setN]=createSignal(0);const current=createMemo(()=>n()===0?' + initial + ':n());const previous=createPrevious(current,()=>99);return setN(n()+1)}>{current()===' + initial + '?"' + initial + '":current()}/{previous()===' + initial + '?"' + initial + '":previous()} }';
+ await traces(source, lowerTsx(source), expected);
+ await assertCore('source x := ' + initial + '; previous = pre x -> 99; export default component App() { {x == ' + initial + ' ? "' + initial + '" : x}/{previous == ' + initial + ' ? "' + initial + '" : previous}
}',
+ [[["x", 1]], [["x", 2]]], expected);
+ }
+});
+
+test("source previous coalesces multiple writes in one handler", async () => {
+ const source = 'import {createSignal} from "solid-js";import {createPrevious} from "reactivescript-tsx";export function Counter(){const [x,setX]=createSignal(0);const [step,setStep]=createSignal(0);const previous=createPrevious(x,()=>99);return {if(step()===0)setX(1);else{setX(5);setX(7)}setStep(step()+1)}}>{x()}/{previous()} }';
+ const expected = ["0/99", "1/0", "7/1"];
+ await traces(source, lowerTsx(source), expected);
+ await assertCore('source x := 0; previous = pre x -> 99; export default component App() { {x}/{previous}
}', [[["x", 1]], [["x", 5], ["x", 7]]], expected);
+});
+
+test("same-setup history can be consumed later, but inherited targets fail closed at setup", async () => {
+ const prefix = 'import {createSignal,createMemo,Show} from "solid-js";import {createPrevious} from "reactivescript-tsx";const [n,setN]=createSignal(0);const double=createMemo(()=>n()*2);';
+ const tail = 'export function Counter(){const [shown,setShown]=createSignal(false);const [step,setStep]=createSignal(0);return {if(step()===0)setN(1);else if(step()===1)setN(2);else setShown(true);setStep(step()+1)}}>{double()}| }';
+ const legal = prefix + 'const previous=createPrevious(double,()=>-1);function Panel(){return {previous()} }' + tail;
+ await traces(legal, lowerTsx(legal), ["0|-", "2|-", "4|-", "4|2"]);
+ const inherited = prefix + 'function Panel(){const previous=createPrevious(double,()=>-1);return {previous()} }' + tail;
+ assert.throws(() => lowerTsx(inherited), /same owned setup/);
+});
+
+test("unseeded history is undefined and the public overloads preserve accessor result types", () => {
+ const app = createRoot(dispose => {
+ const [input, setInput] = createSignal(1);
+ const previous: Accessor = createPrevious(input);
+ const seeded: Accessor = createPrevious(input, () => 0);
+ const optionalFallback: (() => number) | undefined = undefined;
+ const optional: Accessor = createPrevious(input, optionalFallback);
+ // @ts-expect-error Unseeded history must not promise a number, or widen to any.
+ const invalid: Accessor = createPrevious(input);
+ assert.equal(typeof invalid, "function");
+ return { dispose, setInput, previous, seeded, optional };
+ });
+ try {
+ assert.equal(app.previous(), undefined); assert.equal(app.optional(), undefined); assert.equal(app.seeded(), 0);
+ app.setInput(2); flush(); assert.equal(app.previous(), 1); assert.equal(app.seeded(), 1);
+ } finally { app.dispose(); }
+});
+
+test("fallbacks are lazy, caller-tracked, and used whenever the previous value is undefined", () => {
+ let calls = 0;
+ const app = createRoot(dispose => {
+ const [input, setInput] = createSignal(1), [seed, setSeed] = createSignal(10);
+ const previous = createPrevious(input, () => { calls++; return seed(); });
+ return { dispose, setInput, setSeed, previous };
+ });
+ let disposeView = () => {};
+ try {
+ assert.equal(calls, 0); assert.equal(app.previous(), 10); assert.equal(app.previous(), 10); assert.equal(calls, 1);
+ const view = createRoot(dispose => { disposeView = dispose; return createMemo(() => app.previous()); });
+ assert.equal(view(), 10);
+ app.setSeed(11); flush(); assert.equal(view(), 11);
+ app.setInput(2); flush(); assert.equal(view(), 1);
+ const settledCalls = calls;
+ app.setSeed(20); flush(); assert.equal(view(), 1); assert.equal(calls, settledCalls);
+ app.setInput(undefined); flush(); assert.equal(view(), 2); assert.equal(calls, settledCalls);
+ app.setInput(3); flush(); assert.equal(view(), 20); assert.equal(calls, settledCalls + 1);
+ app.setSeed(30); flush(); assert.equal(view(), 30); assert.equal(calls, settledCalls + 2);
+ } finally { disposeView(); app.dispose(); }
+});
+
+test("null, false, zero and empty-string previous values do not trigger the fallback", () => {
+ let fallbacks = 0;
+ const app = createRoot(dispose => {
+ const [input, setInput] = createSignal(0);
+ const previous = createPrevious(input, () => { fallbacks++; return "fallback"; });
+ return { dispose, setInput, previous };
+ });
+ try {
+ assert.equal(app.previous(), "fallback");
+ for (const [next, expected] of [[false, 0], ["", false], [null, ""], [1, null]] as const) {
+ app.setInput(next); flush(); assert.equal(app.previous(), expected);
+ }
+ assert.equal(fallbacks, 1);
+ } finally { app.dispose(); }
+});
+
+test("failed input attempts are skipped, and successful equal-value recovery advances the companion", () => {
+ const host = R.createElement("main"), failure = new Error("failed");
+ let setN!: (n: number) => void, setFailed!: (failed: boolean) => void, fallbacks = 0;
+ const dispose = mount(() => {
+ const [n, update] = createSignal(1), [failed, fail] = createSignal(false); setN = update; setFailed = fail;
+ const input = createMemo(() => { if (failed()) throw failure; return n(); });
+ const previous = createPrevious(input, () => { fallbacks++; return 0; });
+ return createComponent(Errored, {
+ fallback(error: Accessor) { assert.equal(error(), failure); return "failed"; },
+ get children() { return text(previous); },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "0");
+ setN(2); flush(); assert.equal(serialize(host), "1");
+ setFailed(true); flush(); assert.equal(serialize(host), "failed");
+ setN(3); flush(); assert.equal(serialize(host), "failed");
+ setFailed(false); flush(); assert.equal(serialize(host), "2");
+ setFailed(true); flush(); assert.equal(serialize(host), "failed");
+ setFailed(false); flush(); assert.equal(serialize(host), "3");
+ setN(4); flush(); assert.equal(serialize(host), "3"); assert.equal(fallbacks, 1);
+ } finally { dispose(); }
+});
+
+test("authored and raw RX previous recover from failed current inputs with native packet history", async () => {
+ const expected = ["0", "1", "failed", "failed", "2", "failed", "3", "3"];
+ const source = 'import {createSignal,createMemo,Errored} from "solid-js";import {createPrevious,defineError,raiseFailure} from "reactivescript-tsx";const Failed=defineError("Failed",[],()=>"failed");function Content(props:{n:number;failed:boolean}){const input=createMemo(()=>props.failed?raiseFailure(Failed()):props.n);const previous=createPrevious(input,()=>0);return {previous()} }export function Counter(){const [n,setN]=createSignal(1);const [failed,setFailed]=createSignal(false);const [step,setStep]=createSignal(0);return {if(step()===0)setN(2);else if(step()===1)setFailed(true);else if(step()===2)setN(3);else if(step()===3)setFailed(false);else if(step()===4)setFailed(true);else if(step()===5)setFailed(false);else setN(4);setStep(step()+1)}}>failed }> }';
+ await traces(source, lowerTsx(source), expected);
+ await assertCore('source n := 1; source failed := false; error Failed() => "failed"; input = failed ? throw Failed() : n; previous = pre input -> 0; export default component App() { {previous catch (error) => "failed"}
}',
+ [[["n", 2]], [["failed", true]], [["n", 3]], [["failed", false]], [["failed", true]], [["failed", false]], [["n", 4]]], expected);
+});
+
+test("initial pending bypasses the fallback and starts history only after the first answer", async () => {
+ const host = R.createElement("main"), request = deferred(); let fallbacks = 0;
+ const dispose = mount(() => {
+ const input = createMemo(() => request.promise);
+ const previous = createPrevious(input, () => { fallbacks++; return 0; });
+ return createComponent(Loading, { fallback: "waiting", get children() { return text(previous); } });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "waiting"); assert.equal(fallbacks, 0);
+ request.resolve(1); await request.promise; flush(); await Promise.resolve(); flush();
+ assert.equal(serialize(host), "0"); assert.equal(fallbacks, 1);
+ } finally { dispose(); }
+});
+
+test("pending recomputes skip history until their answer settles", async () => {
+ const host = R.createElement("main"), request = deferred();
+ let setN!: (n: number) => void, setWaiting!: (waiting: boolean) => void, fallbacks = 0;
+ const dispose = mount(() => {
+ const [n, update] = createSignal(1), [waiting, wait] = createSignal(false); setN = update; setWaiting = wait;
+ const input = createMemo(() => waiting() ? request.promise : n());
+ const previous = createPrevious(input, () => { fallbacks++; return 0; });
+ return createComponent(Loading, {
+ get on() { return waiting(); }, fallback: "waiting", get children() { return text(previous); },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "0");
+ setN(2); flush(); assert.equal(serialize(host), "1");
+ setWaiting(true); flush(); assert.equal(serialize(host), "waiting"); assert.equal(fallbacks, 1);
+ setN(9); flush(); assert.equal(serialize(host), "waiting");
+ request.resolve(3); await request.promise; flush(); await Promise.resolve(); flush();
+ assert.equal(serialize(host), "2");
+ setWaiting(false); flush(); assert.equal(serialize(host), "3");
+ setN(10); flush(); assert.equal(serialize(host), "9"); assert.equal(fallbacks, 1);
+ } finally { dispose(); }
+});
+
+test("previous companions dispose with their target owner and start fresh on remount", () => {
+ const host = R.createElement("main"), values: (number | undefined)[] = [];
+ let setN!: (n: number) => void, setShown!: (shown: boolean) => void, cleanups = 0;
+ const dispose = mount(() => {
+ const [n, update] = createSignal(0), [shown, show] = createSignal(true); setN = update; setShown = show;
+ const Child = () => {
+ const input = createMemo(() => n()), previous = createPrevious(input, () => -1);
+ createEffect(() => previous(), value => { values.push(value); }); onCleanup(() => { cleanups++; });
+ return text(previous);
+ };
+ return createComponent(Show, {
+ get when() { return shown(); }, fallback: "-", get children() { return createComponent(Child, {}); },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "-1");
+ setN(1); flush(); assert.equal(serialize(host), "0");
+ setShown(false); flush(); assert.equal(serialize(host), "-"); assert.equal(cleanups, 1);
+ setN(4); flush(); assert.deepEqual(values, [-1, 0]);
+ setShown(true); flush(); assert.equal(serialize(host), "-1");
+ setN(5); flush(); assert.equal(serialize(host), "4"); assert.deepEqual(values, [-1, 0, -1, 4]);
+ } finally { dispose(); }
+ assert.equal(cleanups, 2);
+});
+
+test("native S-store-2 projection uses cross-node previous id while untouched paths stay quiet", async () => {
+ const log: string[] = [];
+ const app = createRoot(dispose => {
+ const [id, setId] = createSignal(1), previous = createPrevious(id, () => 0);
+ const selected = createProjection>(draft => {
+ draft[String(previous())] = false; draft[String(id())] = true;
+ }, {});
+ const one = createMemo(() => selected["1"]), two = createMemo(() => selected["2"]);
+ createEffect(() => one(), value => { log.push("log(one, " + value + ")"); });
+ createEffect(() => two(), value => { log.push("log(two, " + value + ")"); });
+ const frame = () => (one() ? "1" : "-") + " " + (two() ? "2" : "-");
+ return { dispose, setId, frame };
+ });
+ const expected = ["1 -", "- 2", "- 2", "- -"];
+ const effects = ["log(one, true)", "log(two, undefined)", "log(one, false)", "log(two, true)", "log(two, false)"];
+ try {
+ flush(); const frames = [app.frame()];
+ for (const id of [2, 2, 3]) { app.setId(id); flush(); frames.push(app.frame()); }
+ assert.deepEqual(frames, expected); assert.deepEqual(log, effects);
+ } finally { app.dispose(); }
+ await assertCore('source id := 1; selected = draft(pre selected -> {}) {d[str(pre id -> 0)] := false; d[str(id)] := true} ' +
+ 'effect (selected["1"]) {log("one", $0)} effect (selected["2"]) {log("two", $0)} ' +
+ 'export default component App() { {selected["1"] ? "1" : "-"} {selected["2"] ? "2" : "-"}
}', [[["id", 2]], [["id", 2]], [["id", 3]]], expected, effects);
+});
diff --git a/tsx-frontend/tsx/previous.ts b/tsx-frontend/tsx/previous.ts
new file mode 100644
index 0000000..a34a611
--- /dev/null
+++ b/tsx-frontend/tsx/previous.ts
@@ -0,0 +1,15 @@
+import { createMemo, type Accessor } from "solid-js";
+
+/** History starts with this companion's owner lifetime; declare it alongside its input. */
+export function createPrevious(input: Accessor, fallback: () => T): Accessor;
+export function createPrevious(input: Accessor, fallback?: () => T): Accessor;
+export function createPrevious(input: Accessor, fallback?: () => T): Accessor {
+ const packet = createMemo<{ cur: T; prev: T | undefined }>(previous => ({
+ cur: input(),
+ prev: previous?.cur,
+ }));
+ return createMemo(() => {
+ const previous = packet().prev;
+ return previous === undefined ? fallback?.() : previous;
+ }, { lazy: true });
+}
diff --git a/tsx-frontend/tsx/primitives.test.ts b/tsx-frontend/tsx/primitives.test.ts
new file mode 100644
index 0000000..036671c
--- /dev/null
+++ b/tsx-frontend/tsx/primitives.test.ts
@@ -0,0 +1,762 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ Errored, Loading, NotReadyError, createComponent, createMemo, createRoot, createSignal, createStore, flush, getOwner, onCleanup,
+ refresh as solidRefresh, resolve,
+ type Accessor, type Element, type Signal, type Store, type StoreSetter,
+} from "solid-js";
+import {
+ FailureBoundary, catchFailure, createServerMemo, createSourceContext, defineError, raiseFailure,
+ refresh, serverAction, serverFunction, useSourceContext, type SourcePair,
+} from "reactivescript-tsx";
+import { isA, PANIC, wireCopy } from "./rx/runtime.ts";
+import { wireCacheKey } from "./wire-key.ts";
+import { R, serialize } from "./rx/runtime.ts";
+
+const Missing = defineError("Missing", ["id"], (id: number) => `Missing ${id}`);
+const Denied = defineError("Denied", [], () => "Denied");
+
+function deferred() {
+ let resolve!: (value: T) => void, reject!: (error: unknown) => void;
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
+ return { promise, resolve, reject };
+}
+
+async function settle() {
+ await new Promise(resolve => setTimeout(resolve, 0)); flush();
+ await new Promise(resolve => setTimeout(resolve, 0)); flush();
+}
+
+function text(read: () => unknown) {
+ const node = R.createElement("span");
+ R.insert(node, read);
+ return node;
+}
+
+function mount(view: () => Element, host: ReturnType) {
+ return R.render(() => text(view), host);
+}
+
+test("defineError and raiseFailure use shared failure identity, fields and class matching", () => {
+ const Moved = defineError("Moved", ["id", "destination"], (id: number, destination: string) => `${id} -> ${destination}`);
+ const error = Moved(7, "elsewhere");
+ const id: number = error.id;
+ const destination: string = error.destination;
+ assert.equal(id, 7); assert.equal(destination, "elsewhere");
+ assert.equal(error.message, "7 -> elsewhere"); assert.equal(error.name, "Moved");
+ assert.equal(Moved.errorClass, "Moved"); assert.ok(error instanceof Moved.cls);
+ assert.ok(isA(error, [Moved])); assert.ok(isA(error, [Moved.cls]));
+ assert.ok(!isA(error, [defineError("Moved", [], () => "different class")]));
+ assert.throws(() => raiseFailure(error), value => value === error);
+ const fromClass: typeof error = new Moved.cls(8, "home");
+ assert.equal(fromClass.id, 8);
+ if (false) {
+ // @ts-expect-error Constructor arguments follow the typed message callback.
+ Moved("wrong", "elsewhere");
+ // @ts-expect-error Field count and message callback arity must agree.
+ defineError("Bad", ["id"], (id: number, extra: string) => extra + id);
+ }
+});
+
+test("catchFailure handles matching synchronous failures without catching fallback failures", () => {
+ const value = { ok: true };
+ assert.equal(catchFailure(() => value, [], () => null), value);
+ const error = Missing(4);
+ assert.equal(catchFailure(() => raiseFailure(error), [Missing], e => { assert.equal(e, error); return 4; }), 4);
+ assert.equal(catchFailure(() => raiseFailure(error), undefined, () => "any"), "any");
+ assert.equal(catchFailure(() => raiseFailure(error), [Missing], () => 4).toFixed(), "4");
+ assert.throws(() => catchFailure(() => raiseFailure(error), [Denied], () => 0), e => e === error);
+ assert.throws(() => catchFailure(() => raiseFailure(error), [], () => 0), e => e === error);
+ const fallbackError = Denied();
+ assert.throws(() => catchFailure(() => raiseFailure(error), [Missing], () => raiseFailure(fallbackError)), e => e === fallbackError);
+});
+
+test("catchFailure maps controlled promise rejection and retains unmatched rejection identity", async () => {
+ const request = deferred();
+ const events: unknown[] = [];
+ const result = catchFailure(() => request.promise, [Missing], error => { events.push(error); return 12; });
+ assert.deepEqual(events, []);
+ const error = Missing(12); request.reject(error);
+ assert.equal(await result, 12); assert.deepEqual(events, [error]);
+ assert.equal(await catchFailure(() => Promise.resolve(5), [Missing], () => 0), 5);
+ const unmatched = Denied();
+ await assert.rejects(Promise.resolve(catchFailure(() => Promise.reject(unmatched), [Missing], () => 0)), e => e === unmatched);
+});
+
+test("catchFailure maps a controlled stream failure after preserving its earlier answers", async () => {
+ const first = deferred(), second = deferred();
+ let closed = false;
+ async function* values() {
+ try { yield await first.promise; yield await second.promise; }
+ finally { closed = true; }
+ }
+ const caught = catchFailure(values, [Missing], () => 9);
+ assert.notEqual(typeof caught, "number");
+ const iterator = (caught as AsyncIterable)[Symbol.asyncIterator]();
+ const pendingFirst = iterator.next(); first.resolve(3);
+ assert.deepEqual(await pendingFirst, { value: 3, done: false });
+ const pendingSecond = iterator.next(); second.reject(Missing(9));
+ assert.deepEqual(await pendingSecond, { value: 9, done: false });
+ assert.deepEqual(await iterator.next(), { value: undefined, done: true });
+ assert.ok(closed);
+ const error = Denied();
+ async function* failed() { yield 1; throw error; }
+ const unmatched = catchFailure(failed, [Missing], () => 0) as AsyncIterable;
+ const other = unmatched[Symbol.asyncIterator]();
+ assert.deepEqual(await other.next(), { value: 1, done: false });
+ await assert.rejects(other.next(), e => e === error);
+});
+
+test("catchFailure forwards stream cancellation to the source", async () => {
+ let closed = false;
+ async function* values() { try { yield 1; yield 2; } finally { closed = true; } }
+ const result = catchFailure(values, undefined, () => 0) as AsyncIterable;
+ const iterator = result[Symbol.asyncIterator]();
+ await iterator.next(); await iterator.return!();
+ assert.ok(closed);
+});
+
+test("global Error is catchFailure's top class for throws, rejections and stream failures", async () => {
+ const failures = [Missing(8), new Error("native"), "primitive", 23, false, null, undefined, NaN];
+ for (const failure of failures) {
+ const fallback = (error: unknown) => { assert.equal(error, failure); return "caught"; };
+ assert.equal(catchFailure(() => raiseFailure(failure), [Error], fallback), "caught");
+ assert.equal(await catchFailure(() => Promise.reject(failure), [Error], fallback), "caught");
+ async function* values() { yield 1; throw failure; }
+ const answers: unknown[] = [];
+ for await (const answer of catchFailure(values, [Error], fallback)) answers.push(answer);
+ assert.deepEqual(answers, [1, "caught"]);
+ }
+});
+
+test("a declared error named Error stays filtered rather than becoming the top class", async () => {
+ const Error = defineError("Error", [], () => "declared");
+ const failure = Error(), other = Denied();
+ for (const classes of [[Error], [Error.cls]]) {
+ assert.equal(catchFailure(() => raiseFailure(failure), classes, () => "declared"), "declared");
+ assert.throws(() => catchFailure(() => raiseFailure(other), classes, () => "wrong"), error => error === other);
+ await assert.rejects(Promise.resolve(catchFailure(() => Promise.reject("primitive"), classes, () => "wrong")), error => error === "primitive");
+ }
+ assert.throws(() => catchFailure(() => raiseFailure("primitive"), ["Error"], () => "wrong"), error => error === "primitive");
+ const host = R.createElement("main");
+ let received: unknown, innerFallbacks = 0;
+ const dispose = mount(() => createComponent(Errored, {
+ fallback(error: Accessor) { received = error(); return "outer"; },
+ get children() {
+ return createComponent(FailureBoundary, {
+ classes: [Error],
+ fallback() { innerFallbacks++; return "wrong"; },
+ get children() { return text(createMemo(() => raiseFailure("primitive"))); },
+ });
+ },
+ }), host);
+ try { flush(); assert.equal(serialize(host), "outer"); assert.equal(received, "primitive"); assert.equal(innerFallbacks, 0); }
+ finally { dispose(); }
+});
+
+test("global Error boundaries catch thrown and rejected values while Loading still owns pending", async () => {
+ const failures = [Missing(8), new Error("native"), "primitive", 23, false, null, undefined];
+ for (const failure of failures) {
+ for (const asynchronous of [false, true]) {
+ const host = R.createElement("main"), request = deferred();
+ let fallbacks = 0, received: unknown;
+ const dispose = mount(() => createComponent(Loading, {
+ fallback: "loading",
+ get children() {
+ return createComponent(FailureBoundary, {
+ classes: [Error],
+ fallback(error) { fallbacks++; received = error(); return "caught"; },
+ get children() { return text(createMemo(() => asynchronous ? request.promise : raiseFailure(failure))); },
+ });
+ },
+ }), host);
+ try {
+ flush();
+ if (asynchronous) {
+ assert.equal(serialize(host), "loading"); assert.equal(fallbacks, 0);
+ const rejection = assert.rejects(request.promise, error => Object.is(error, failure));
+ request.reject(failure); await rejection; flush(); await Promise.resolve(); flush();
+ }
+ assert.equal(serialize(host), "caught"); assert.equal(fallbacks, 1); assert.equal(received, failure);
+ } finally { dispose(); }
+ }
+ }
+});
+
+test("catchFailure never turns native pending into a failure, including promises and streams", async () => {
+ const pending = new NotReadyError({});
+ let caught = 0;
+ const fallback = () => ++caught;
+ assert.throws(() => catchFailure(() => { throw pending; }, [Error], fallback), e => e === pending);
+ await assert.rejects(Promise.resolve(catchFailure(() => Promise.reject(pending), [Error, PANIC], fallback)), e => e === pending);
+ async function* values() { throw pending; }
+ const result = catchFailure(values, [Error], fallback) as AsyncIterable;
+ await assert.rejects(result[Symbol.asyncIterator]().next(), e => e === pending);
+ assert.equal(caught, 0);
+});
+
+test("FailureBoundary gives the fallback a real native error accessor and reset", () => {
+ const host = R.createElement("main");
+ const failure = Missing(6);
+ let setFailed!: (value: boolean) => void, error!: Accessor, reset!: () => void;
+ const dispose = mount(() => {
+ const [failed, set] = createSignal(true); setFailed = set;
+ return createComponent(FailureBoundary, {
+ classes: [Missing],
+ fallback(read, retry) { error = read; reset = retry; return text(() => (read() as Error).message); },
+ get children() { return text(createMemo(() => failed() ? raiseFailure(failure) : "ready")); },
+ });
+ }, host);
+ try {
+ flush();
+ assert.equal(serialize(host), "Missing 6"); assert.equal(error(), failure);
+ setFailed(false); reset(); flush();
+ assert.equal(serialize(host), "ready");
+ } finally { dispose(); }
+});
+
+test("FailureBoundary rethrows unmatched failures to the enclosing native Errored", () => {
+ const host = R.createElement("main");
+ const failure = Missing(8);
+ let received: unknown, innerFallbacks = 0;
+ const dispose = mount(() => createComponent(Errored, {
+ fallback(error: Accessor) { received = error(); return "outer"; },
+ get children() {
+ return createComponent(FailureBoundary, {
+ classes: [Denied],
+ fallback(_error) { innerFallbacks++; return "wrong"; },
+ get children() { return raiseFailure(failure); },
+ });
+ },
+ }), host);
+ try { flush(); assert.equal(serialize(host), "outer"); assert.equal(received, failure); assert.equal(innerFallbacks, 0); }
+ finally { dispose(); }
+});
+
+test("matching failure payloads stay live without remounting fallback snapshots or local state", () => {
+ const host = R.createElement("main");
+ let setId!: (id: number) => void, bump!: () => void, mounts = 0, cleanups = 0;
+ const dispose = mount(() => {
+ const [id, set] = createSignal(0); setId = set;
+ const result = createMemo(() => id() ? raiseFailure(Missing(id())) : "ok");
+ return createComponent(FailureBoundary, {
+ classes: [Missing],
+ fallback(error) {
+ mounts++; onCleanup(() => { cleanups++; });
+ const captured = (error() as ReturnType).id;
+ const [local, setLocal] = createSignal(10); bump = () => setLocal(n => n + 1);
+ return text(() => `${(error() as ReturnType).id}/${captured}/${local()}`);
+ },
+ get children() { return text(result); },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "ok"); assert.equal(mounts, 0);
+ setId(1); flush(); assert.equal(serialize(host), "1/1/10"); assert.equal(mounts, 1);
+ bump(); flush(); setId(2); flush(); assert.equal(serialize(host), "2/1/11");
+ assert.equal(mounts, 1); assert.equal(cleanups, 0);
+ setId(0); flush(); assert.equal(serialize(host), "ok"); assert.equal(cleanups, 1);
+ setId(3); flush(); assert.equal(serialize(host), "3/3/10"); assert.equal(mounts, 2);
+ } finally { dispose(); }
+ assert.equal(cleanups, 2);
+});
+
+test("filtered nested failures rethrow every new payload and react to a changed matching class", () => {
+ const host = R.createElement("main");
+ let setId!: (id: number) => void, outerMounts = 0, innerMounts = 0;
+ const dispose = mount(() => {
+ const [id, set] = createSignal(0); setId = set;
+ const result = createMemo(() => { if (id() === 4) throw Denied(); return id() ? raiseFailure(Missing(id())) : "ok"; });
+ return createComponent(FailureBoundary, {
+ classes: [Missing],
+ fallback(error) {
+ outerMounts++; const captured = (error() as ReturnType).id;
+ return text(() => `${(error() as Error).message}/${captured}`);
+ },
+ get children() {
+ return createComponent(FailureBoundary, {
+ classes: [Denied],
+ fallback(error) { innerMounts++; return text(() => (error() as Error).message); },
+ get children() { return text(result); },
+ });
+ },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "ok");
+ for (const id of [1, 2, 3]) { setId(id); flush(); assert.equal(serialize(host), `Missing ${id}/1`); }
+ assert.equal(outerMounts, 1); assert.equal(innerMounts, 0);
+ setId(0); flush(); assert.equal(serialize(host), "ok");
+ setId(4); flush(); assert.equal(serialize(host), "Denied"); assert.equal(innerMounts, 1);
+ setId(5); flush(); assert.equal(serialize(host), "Missing 5/5"); assert.equal(outerMounts, 2);
+ } finally { dispose(); }
+});
+
+test("native Loading still owns pending through catchFailure and FailureBoundary", async () => {
+ const request = deferred();
+ const host = R.createElement("main");
+ let fallbacks = 0;
+ const dispose = mount(() => {
+ const [data] = createSignal(() => request.promise);
+ return createComponent(Loading, {
+ fallback: "loading",
+ get children() {
+ return createComponent(FailureBoundary, {
+ fallback(_error) { fallbacks++; return "wrong"; },
+ get children() { return text(() => catchFailure(data, undefined, () => { fallbacks++; return "caught"; })); },
+ });
+ },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "loading"); assert.equal(fallbacks, 0);
+ request.resolve("ready"); await settle();
+ assert.equal(serialize(host), "ready"); assert.equal(fallbacks, 0);
+ } finally { dispose(); }
+});
+
+test("source contexts share canonical native Signal tuples with lexical provider shadowing", () => {
+ const Source = createSourceContext();
+ const host = R.createElement("main");
+ let outer!: Signal, inner!: Signal;
+ const dispose = mount(() => {
+ outer = createSignal(1);
+ return createComponent(Source, {
+ value: outer,
+ get children() {
+ const source = useSourceContext(Source); assert.equal(source, outer);
+ return [
+ text(() => String(source[0]())), "/",
+ createComponent(Source, {
+ value: createSignal(5),
+ get children() { inner = useSourceContext(Source); return text(() => String(inner[0]())); },
+ }),
+ "/", text(() => String(source[0]())),
+ ];
+ },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "1/5/1");
+ outer[1](n => n + 1); flush(); assert.equal(serialize(host), "2/5/2");
+ inner[1](9); flush(); assert.equal(serialize(host), "2/9/2");
+ assert.notEqual(inner, outer);
+ } finally { dispose(); }
+});
+
+test("structured source contexts retain canonical store pairs and lexical provider identity", () => {
+ type State = { count: number };
+ const Source = createSourceContext();
+ const host = R.createElement("main");
+ let outer!: SourcePair, inner!: SourcePair;
+ const dispose = mount(() => {
+ outer = createStore({ count: 1 });
+ return createComponent(Source, {
+ value: outer,
+ get children() {
+ const source = useSourceContext(Source);
+ const typed: SourcePair = source;
+ assert.equal(typed, outer); assert.equal(source[0], outer[0]); assert.equal(source[1], outer[1]);
+ return [
+ text(() => String(source[0].count)), "/",
+ createComponent(Source, {
+ value: createStore({ count: 5 }),
+ get children() { inner = useSourceContext(Source); return text(() => String(inner[0].count)); },
+ }),
+ "/", text(() => String(source[0].count)),
+ ];
+ },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "1/5/1");
+ outer[1](state => ({ count: state.count + 1 })); flush(); assert.equal(serialize(host), "2/5/2");
+ inner[1](state => { state.count = 9; }); flush(); assert.equal(serialize(host), "2/9/2");
+ assert.notEqual(inner, outer);
+ } finally { dispose(); }
+});
+
+test("source pair types preserve scalar signals and reject object signals as store providers", () => {
+ const scalar: SourcePair = createSignal(false);
+ const structured: SourcePair<{ ok: boolean }[]> = createStore<{ ok: boolean }[]>([]);
+ const objectSignal = createSignal<{ ok: boolean }[]>([]);
+ // @ts-expect-error An object signal accessor is not a Store.
+ const invalid: SourcePair<{ ok: boolean }[]> = objectSignal;
+ assert.equal(invalid, objectSignal); assert.equal(typeof objectSignal[0], "function");
+ assert.equal(scalar[0](), false); assert.equal(structured[0].length, 0);
+ const Source = createSourceContext<{ ok: boolean }[]>();
+ const invalidProvider = () => createComponent(Source, {
+ // @ts-expect-error A structured source provider requires the real store pair.
+ value: objectSignal,
+ children: null,
+ });
+ assert.equal(typeof invalidProvider, "function");
+});
+
+test("source context inference keeps natural scalar and array destructuring fully typed", () => {
+ type Row = { ok: boolean };
+ const Fields = createSourceContext();
+ const Enabled = createSourceContext();
+ const host = R.createElement("main");
+ let update!: StoreSetter, toggle!: Signal[1];
+ const dispose = mount(() => {
+ const pair = createStore([{ ok: false }]);
+ const flag = createSignal(false);
+ return createComponent(Fields, {
+ value: pair,
+ get children() {
+ const [rows, setRows] = useSourceContext(Fields);
+ const store: Store = rows;
+ const setter: StoreSetter = setRows;
+ assert.equal(store, pair[0]); assert.equal(setter, pair[1]); update = setRows;
+ // @ts-expect-error A store is not a signal accessor, nor an inferred any.
+ const wrongRead = () => rows();
+ assert.equal(typeof wrongRead, "function");
+ return createComponent(Enabled, {
+ value: flag,
+ get children() {
+ const [enabled, setEnabled] = useSourceContext(Enabled);
+ const signal: Signal = [enabled, setEnabled];
+ assert.equal(signal[0], flag[0]); assert.equal(signal[1], flag[1]); toggle = setEnabled;
+ // @ts-expect-error Natural scalar inference must not widen to any.
+ const wrongScalar: () => number = enabled;
+ assert.equal(typeof wrongScalar, "function");
+ return text(() => String(enabled() && rows.every(row => row.ok)));
+ },
+ });
+ },
+ });
+ }, host);
+ try {
+ flush(); assert.equal(serialize(host), "false");
+ update(rows => [...rows, { ok: true }]); toggle(true); flush(); assert.equal(serialize(host), "false");
+ update(rows => rows.filter(row => row.ok)); flush(); assert.equal(serialize(host), "true");
+ } finally { dispose(); }
+});
+
+test("source contexts fail explicitly without a provider or owner", () => {
+ const Source = createSourceContext();
+ assert.throws(() => useSourceContext(Source), /source context provider/);
+ createRoot(dispose => {
+ try { assert.throws(() => useSourceContext(Source), /source context provider/); }
+ finally { dispose(); }
+ });
+ const Structured = createSourceContext<{ ok: boolean }[]>();
+ assert.throws(() => useSourceContext(Structured), /source context provider/);
+ createRoot(dispose => {
+ try { assert.throws(() => useSourceContext(Structured), /source context provider/); }
+ finally { dispose(); }
+ });
+});
+
+for (const [name, factory] of [["serverFunction", serverFunction], ["serverAction", serverAction]] as const) {
+ test(`${name} copies arguments and results across its real in-process wire`, async () => {
+ const request = deferred();
+ let remote!: { items: number[] }, answer!: { items: number[]; extra: number };
+ const endpoint = factory("copy", function* (input: { items: number[] }) {
+ remote = input; input.items.push(2);
+ const extra: number = yield request.promise;
+ answer = { items: input.items, extra }; return answer;
+ });
+ const input = { items: [1] };
+ const pending = endpoint(input); assert.ok(pending instanceof Promise);
+ assert.notEqual(remote, input); assert.notEqual(remote.items, input.items);
+ assert.deepEqual(input, { items: [1] }); input.items.push(99);
+ request.resolve(4); const result = await pending;
+ assert.deepEqual(result, { items: [1, 2], extra: 4 });
+ assert.notEqual(result, answer); assert.notEqual(result.items, answer.items);
+ result.items.push(3); assert.deepEqual(answer.items, [1, 2]);
+ });
+}
+
+test("server generator drivers resume promises and throw rejections back into the server body", async () => {
+ const first = deferred(), second = deferred();
+ const failure = new Error("server detail");
+ const events: unknown[] = [];
+ const endpoint = serverAction("resume", function* () {
+ const value: number = yield first.promise; events.push(value);
+ try { yield second.promise; } catch (error) { events.push(error); }
+ return value + 1;
+ });
+ const result = endpoint(); assert.deepEqual(events, []);
+ first.resolve(5); await Promise.resolve(); assert.deepEqual(events, [5]);
+ second.reject(failure); assert.equal(await result, 6); assert.deepEqual(events, [5, failure]);
+});
+
+test("server failures preserve declared classes and sanitize undeclared throws and rejections", async () => {
+ const declared = Missing(3);
+ const typed = serverFunction("typed", function* () { throw declared; });
+ await assert.rejects(typed(), error => error === declared && error instanceof Missing.cls);
+ for (const error of [new Error("secret"), "secret", undefined, null, 0]) {
+ const thrown = serverAction("thrown", function* () { throw error; });
+ await assert.rejects(thrown(), value => value instanceof Error && value.message === "Internal Server Error");
+ const rejected = serverFunction("rejected", function* () { yield Promise.reject(error); });
+ await assert.rejects(rejected(), value => value instanceof Error && value.message === "Internal Server Error");
+ }
+});
+
+test("server wire rejects non-data arguments before execution and non-data results after execution", async () => {
+ let calls = 0;
+ const endpoint = serverFunction("data", function* (value: unknown) { calls++; return value; });
+ for (const value of [() => 1, new Date(0), Symbol("secret"), 1n])
+ await assert.rejects(endpoint({ nested: [value] }), error => error instanceof Error && error.message.includes("WIRE_VALUE: data: argument 1.nested[0]"));
+ assert.equal(calls, 0);
+ const result = serverAction("result", function* () { return { callback: () => 1 }; });
+ await assert.rejects(result(), /WIRE_VALUE: result: its result.callback/);
+});
+
+test("server drivers detach the native caller owner and dependencies while the callsite remains reactive", async () => {
+ let read!: Accessor<{ request: Promise }>, second!: typeof read, setKey!: (n: number) => void, setHidden!: (n: number) => void;
+ const owners: unknown[] = [];
+ let calls = 0, dispose!: () => void;
+ createRoot(stop => {
+ dispose = stop;
+ const [key, writeKey] = createSignal(1), [hidden, writeHidden] = createSignal(0);
+ setKey = writeKey; setHidden = writeHidden;
+ const endpoint = serverFunction("read", function* (n: number) {
+ calls++; owners.push(getOwner());
+ // JavaScript closure purity cannot be checked here; this read must at least stay untracked.
+ const snapshot = hidden(); yield Promise.resolve(); owners.push(getOwner());
+ return n + snapshot;
+ });
+ read = createMemo(() => ({ request: endpoint(key()) }));
+ second = createMemo(() => ({ request: endpoint(key()) }));
+ });
+ try {
+ const first = read().request; assert.equal(second().request, first); assert.equal(await first, 1); assert.equal(calls, 1);
+ setHidden(10); flush(); assert.equal(read().request, first); assert.equal(calls, 1);
+ setKey(2); flush(); assert.equal(await read().request, 12); assert.equal(calls, 2);
+ setKey(1); flush(); assert.equal(read().request, first); assert.equal(calls, 2);
+ assert.deepEqual(owners, [null, null, null, null]);
+ let requests = 0;
+ const uncached = serverAction("uncached", function* () { return ++requests; });
+ const a = uncached(), b = uncached(); assert.notEqual(a, b);
+ assert.deepEqual(await Promise.all([a, b]), [1, 2]);
+ } finally { dispose(); }
+});
+
+test("serverFunction deduplicates equal arguments across readers and reuses earlier flights", async () => {
+ const request = deferred();
+ let calls = 0;
+ const load = serverFunction("load", function* (input: { id: number }) { calls++; return (yield request.promise) + input.id; });
+ const first = load({ id: 1 }), same = load({ id: 1 }), other = load({ id: 2 });
+ assert.equal(first, same); assert.notEqual(first, other); assert.equal(calls, 2);
+ request.resolve(10); assert.deepEqual(await Promise.all([first, same, other]), [11, 11, 12]);
+ assert.equal(load({ id: 1 }), first); assert.equal(calls, 2);
+ const failed = serverFunction("failed", function* () { calls++; throw new Error("secret"); });
+ const rejection = failed(); await assert.rejects(rejection, /Internal Server Error/);
+ assert.equal(failed(), rejection);
+});
+
+test("new loader factories in independent native owners do not share a cache", async () => {
+ let calls = 0;
+ const requests = [0, 1].map(() => createRoot(dispose => {
+ try { return serverFunction("load", function* (id: number) { calls++; return id; })(1); }
+ finally { dispose(); }
+ }));
+ assert.notEqual(requests[0], requests[1]);
+ assert.deepEqual(await Promise.all(requests), [1, 1]); assert.equal(calls, 2);
+});
+
+test("result serialization failures are sanitized but wire value diagnostics remain explicit", async () => {
+ const read = serverFunction("read", function* () { return { get privateData(): unknown { throw new Error("private database details"); } }; });
+ await assert.rejects(read(), error => error instanceof Error && error.message === "Internal Server Error");
+ const load = serverFunction("validated", function* (value: unknown) { return value; });
+ assert.equal(await load(undefined), undefined);
+ await assert.rejects(load(() => 1), /WIRE_VALUE: validated: argument 1/);
+});
+
+test("server memo refresh reasks only its reader, records cache hits, and retains other argument entries", async () => {
+ let calls = 0;
+ const app = createRoot(dispose => {
+ const load = serverFunction("load", function* (id: number) { return { id, value: ++calls }; });
+ const [id, setId] = createSignal(1);
+ const first = createServerMemo(async () => await load(id()), { name: "first" });
+ const second = createServerMemo(() => load(id()));
+ return { dispose, load, first, second, setId };
+ });
+ try {
+ await Promise.all([resolve(app.first), resolve(app.second)]); flush();
+ assert.equal(calls, 1); assert.deepEqual([app.first().value, app.second().value], [1, 1]);
+ const once = refresh(app.first), coalesced = refresh(app.first); flush();
+ assert.deepEqual(await Promise.all([once, coalesced]), [{ id: 1, value: 2 }, { id: 1, value: 2 }]);
+ flush(); assert.equal(calls, 2); assert.deepEqual([app.first().value, app.second().value], [2, 1]);
+ const twice = refresh(app.first); flush(); await twice; flush();
+ assert.equal(calls, 3); assert.deepEqual([app.first().value, app.second().value], [3, 1]);
+ const cacheHit = refresh(app.second); flush(); await cacheHit; flush();
+ assert.equal(calls, 4); assert.deepEqual([app.first().value, app.second().value], [3, 4]);
+ const other = app.load(2); assert.deepEqual(await other, { id: 2, value: 5 });
+ app.setId(2); flush(); await Promise.all([resolve(app.first), resolve(app.second)]); flush();
+ assert.equal(calls, 5); assert.deepEqual([app.first().value, app.second().value], [5, 5]);
+ const changed = refresh(app.first); flush(); await changed; flush();
+ assert.equal(calls, 6); assert.deepEqual([app.first().value, app.second().value], [6, 5]);
+ assert.deepEqual(await app.load(1), { id: 1, value: 4 }); assert.equal(calls, 6);
+ } finally { app.dispose(); }
+});
+
+test("server refresh invalidation stays within its factory and leaves unrelated memos alone", async () => {
+ let leftCalls = 0, rightCalls = 0, plainCalls = 0;
+ const app = createRoot(dispose => {
+ const left = serverFunction("same", function* (id: number) { return { id, value: ++leftCalls }; });
+ const right = serverFunction("same", function* (id: number) { return { id, value: ++rightCalls }; });
+ const first = createServerMemo(() => left(1));
+ const second = createServerMemo(() => right(1));
+ const unrelated = createServerMemo(() => left(2));
+ const plain = createMemo(() => ++plainCalls);
+ return { dispose, first, second, unrelated, plain, left };
+ });
+ try {
+ await Promise.all([resolve(app.first), resolve(app.second), resolve(app.unrelated)]); flush();
+ const before = app.first(), second = app.second(), unrelated = app.unrelated();
+ assert.equal(leftCalls, 2); assert.equal(rightCalls, 1); assert.equal(app.plain(), 1);
+ const renewed = refresh(app.first); flush(); await renewed; flush();
+ assert.equal(leftCalls, 3); assert.equal(rightCalls, 1); assert.notEqual(app.first(), before);
+ assert.equal(app.second(), second); assert.equal(app.unrelated(), unrelated);
+ assert.equal(await app.left(2), unrelated);
+ const ordinary = refresh(app.plain); flush(); assert.equal(await ordinary, 2);
+ assert.equal(leftCalls, 3); assert.equal(rightCalls, 1);
+ } finally { app.dispose(); }
+});
+
+test("native Solid refresh and ordinary memos do not implicitly invalidate loader caches", async () => {
+ let calls = 0;
+ const app = createRoot(dispose => {
+ const load = serverFunction("plain", function* () { return ++calls; });
+ return { dispose, ordinary: createMemo(() => load()), explicit: createServerMemo(() => load()) };
+ });
+ try {
+ await Promise.all([resolve(app.ordinary), resolve(app.explicit)]); flush(); assert.equal(calls, 1);
+ const native = solidRefresh(app.explicit); flush(); assert.equal(await native, 1);
+ const ordinary = refresh(app.ordinary); flush(); assert.equal(await ordinary, 1);
+ assert.equal(calls, 1);
+ const explicit = refresh(app.explicit); flush(); assert.equal(await explicit, 2); flush();
+ assert.equal(calls, 2); assert.equal(app.explicit(), 2); assert.equal(app.ordinary(), 1);
+ } finally { app.dispose(); }
+});
+
+test("server memo refresh evicts cached rejections without rerunning other failed readers", async () => {
+ let calls = 0; const failure = Missing(404);
+ const app = createRoot(dispose => {
+ const load = serverFunction("retry", function* () { if (++calls === 1) throw failure; return calls; });
+ return { dispose, first: createServerMemo(() => load()), second: createServerMemo(() => load()) };
+ });
+ try {
+ const results = await Promise.allSettled([resolve(app.first), resolve(app.second)]); flush();
+ assert.deepEqual(results, [{ status: "rejected", reason: failure }, { status: "rejected", reason: failure }]);
+ assert.equal(calls, 1);
+ const retry = refresh(app.first); flush(); assert.equal(await retry, 2); flush();
+ assert.equal(calls, 2); assert.equal(app.first(), 2);
+ assert.throws(app.second, error => error instanceof Error && error.cause === failure); assert.equal(calls, 2);
+ const other = refresh(app.second); flush(); assert.equal(await other, 3); flush();
+ assert.equal(calls, 3); assert.equal(app.first(), 2); assert.equal(app.second(), 3);
+ } finally { app.dispose(); }
+});
+
+test("server memo refresh supersedes pending answers without discarding older argument cache entries", async () => {
+ const requests: ReturnType>[] = [];
+ const app = createRoot(dispose => {
+ const load = serverFunction("pending", function* (_id: number) {
+ const request = deferred(); requests.push(request);
+ return (yield request.promise) as number;
+ });
+ const [id, setId] = createSignal(1);
+ const first = load(1), second = load(2);
+ return { dispose, load, first, second, setId, memo: createServerMemo(() => load(id())) };
+ });
+ try {
+ const mounted = resolve(app.memo);
+ app.setId(2); flush(); const switched = resolve(app.memo);
+ const newest = refresh(app.memo); flush(); await Promise.resolve(); flush();
+ assert.equal(requests.length, 3);
+ requests[2].resolve(22); assert.equal(await newest, 22); flush(); assert.equal(app.memo(), 22);
+ requests[1].resolve(21); requests[0].resolve(11);
+ assert.deepEqual(await Promise.all([app.first, app.second]), [11, 21]);
+ await Promise.all([mounted, switched]); flush();
+ assert.equal(app.memo(), 22); assert.equal(await app.load(1), 11); assert.equal(await app.load(2), 22);
+ assert.equal(requests.length, 3);
+ } finally { app.dispose(); }
+});
+
+test("server memo forwards previous values and native memo options without creating another reactive layer", async () => {
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(1);
+ const values: number[] = [];
+ const total = createServerMemo((previous: number) => { values.push(previous); return previous + n(); }, { loadingValue: 10, name: "total" });
+ return { dispose, total, setN, values };
+ });
+ try {
+ assert.equal(await resolve(app.total), 11);
+ app.setN(2); flush(); assert.equal(app.total(), 13);
+ const next = refresh(app.total); flush(); assert.equal(await next, 15);
+ assert.deepEqual(app.values, [10, 11, 13]);
+ } finally { app.dispose(); }
+});
+
+test("server memo request collection stops when an async compute returns its promise", async () => {
+ const start = deferred(); let calls = 0;
+ const app = createRoot(dispose => {
+ const load = serverFunction("late", function* () { return ++calls; });
+ const memo = createServerMemo(async () => { await start.promise; return load(); });
+ return { dispose, memo };
+ });
+ try {
+ const first = resolve(app.memo); start.resolve(); assert.equal(await first, 1);
+ const next = refresh(app.memo); flush(); assert.equal(await next, 1);
+ assert.equal(calls, 1);
+ } finally { app.dispose(); }
+});
+
+test("wire argument cache keys distinguish types, delimiters, special numbers and data structures", async () => {
+ const variants: unknown[][] = [
+ [], [""], [undefined], [null], [false], [true], [0], [-0], [1], ["1"],
+ [NaN], [Infinity], [-Infinity], ["NaN"], ["Infinity"], ["-Infinity"],
+ ["undefined"], ["null"], ["false"], ["true"],
+ ["a,b", "c"], ["a", "b,c"], ["a,b,c"], ["a", "b", "c"],
+ [["a,b", "c"]], [["a", "b,c"]], [[1]], [["1"]],
+ [[]], [{}], [new Array(1)], [[undefined]], [[null]], [[NaN]], [[Infinity]], [[-Infinity]], [[0]], [[-0]],
+ [{ value: undefined }], [{ value: null }], [{ value: NaN }], [{ value: Infinity }], [{ value: -Infinity }],
+ [{ value: 0 }], [{ value: -0 }], [{ value: [] }], [{ value: {} }],
+ [["x"]], [{ "0": "x", length: 1 }],
+ ];
+ const validated = variants.map(args => wireCopy(args, "arguments", message => { throw new Error(message); }) as unknown[]);
+ assert.equal(new Set(validated.map(wireCacheKey)).size, variants.length);
+ let calls = 0;
+ const load = serverFunction("collisions", function* (..._args: unknown[]) { return ++calls; });
+ const requests = variants.map(args => load(...args));
+ assert.deepEqual(await Promise.all(requests), variants.map((_, i) => i + 1));
+ for (const [i, args] of validated.entries()) assert.equal(load(...args), requests[i]);
+ assert.equal(calls, variants.length);
+});
+
+test("wire cache keys canonicalize record order recursively while retaining array order", async () => {
+ const first = { z: [{ b: 2, a: 1 }, undefined, -0], a: { y: true, x: null } };
+ const reordered = { a: { x: null, y: true }, z: [{ a: 1, b: 2 }, undefined, -0] };
+ const reversed = { a: reordered.a, z: [...reordered.z].reverse() };
+ assert.equal(wireCacheKey([first]), wireCacheKey([reordered]));
+ assert.notEqual(wireCacheKey([first]), wireCacheKey([reversed]));
+ assert.equal(wireCacheKey([{ b: 2, a: 1 }]), wireCacheKey([Object.assign(Object.create(null), { a: 1, b: 2 })]));
+ let calls = 0;
+ const load = serverFunction("records", function* (_value: unknown) { return ++calls; });
+ const initial = load(first); assert.equal(load(reordered), initial); assert.equal(await initial, 1);
+ assert.equal(await load(reversed), 2); assert.equal(calls, 2);
+});
+
+test("typed loader cache keys isolate cached rejections and targeted refresh", async () => {
+ const failure = Missing(1); let calls = 0;
+ const app = createRoot(dispose => {
+ const load = serverFunction("typed", function* (value: number | string) {
+ const call = ++calls; if (typeof value === "number" && call === 1) throw failure;
+ return { value, call };
+ });
+ const failed = load(1), cachedText = load("1");
+ return { dispose, load, failed, cachedText, numeric: createServerMemo(() => load(1)), text: createServerMemo(() => load("1")) };
+ });
+ try {
+ const initial = await Promise.allSettled([resolve(app.numeric), resolve(app.text)]); flush();
+ assert.deepEqual(initial, [{ status: "rejected", reason: failure }, { status: "fulfilled", value: { value: "1", call: 2 } }]);
+ assert.equal(app.load(1), app.failed); assert.equal(calls, 2);
+ const retry = refresh(app.numeric); flush(); assert.deepEqual(await retry, { value: 1, call: 3 }); flush();
+ assert.equal(app.text(), await app.cachedText); assert.equal(app.load("1"), app.cachedText);
+ assert.notEqual(app.load(1), app.failed); assert.equal(calls, 3);
+ } finally { app.dispose(); }
+});
diff --git a/tsx-frontend/tsx/primitives.ts b/tsx-frontend/tsx/primitives.ts
new file mode 100644
index 0000000..bba605c
--- /dev/null
+++ b/tsx-frontend/tsx/primitives.ts
@@ -0,0 +1,154 @@
+import {
+ Errored, NotReadyError, createContext, createMemo, getOwner, refresh as solidRefresh, runWithOwner, untrack, useContext,
+ type Accessor, type Context, type Element, type Signal, type Store, type StoreSetter,
+} from "solid-js";
+import { caughtValue, isA, makeErrorClass, wireCopy, wireFailure } from "./rx/failure.ts";
+import { wireCacheKey } from "./wire-key.ts";
+export { createRxEffect } from "./effects.ts";
+export { createPrevious } from "./previous.ts";
+
+type Failure = Error & {
+ [I in keyof Fields as I extends `${number}` ? Fields[I] & string : never]: Args[I];
+};
+
+/** Declares an RX failure class; the message callback receives the named fields by position. */
+export function defineError(
+ name: string, fields: Fields, message: (...args: Args) => string,
+): ((...args: Args) => Failure) & {
+ cls: new (...args: Args) => Failure;
+ errorClass: string;
+} {
+ return makeErrorClass(name, [...fields], args => message(...args as Args)) as unknown as ReturnType>;
+}
+
+export function raiseFailure(error: unknown): never {
+ throw error;
+}
+
+type Caught = [Value] extends [never] ? Fallback : Value extends AsyncIterable
+ ? AsyncIterable> | Fallback
+ : Value extends PromiseLike
+ ? Promise> | Fallback
+ : Value | Fallback;
+
+/** Maps failures in synchronous values, promises and streams, but never catches pending. */
+export function catchFailure(
+ compute: () => Value, classes: readonly unknown[] | undefined, fallback: (error: unknown) => Fallback,
+): Caught {
+ const list = classes?.includes(Error) ? undefined : classes && [...classes];
+ return caughtValue(
+ compute, error => !(error instanceof NotReadyError) && isA(error, list), fallback,
+ error => error instanceof NotReadyError,
+ ) as Caught;
+}
+
+export function FailureBoundary(props: {
+ classes?: readonly unknown[];
+ fallback: (error: Accessor, reset: () => void) => Element;
+ children: Element;
+}): Element {
+ const tag = Symbol("failure-fallback");
+ const out = Errored({
+ get children() { return props.children; },
+ fallback(error: Accessor, reset: () => void) {
+ const matched = createMemo(() => {
+ const value = error();
+ const classes = props.classes;
+ return !(value instanceof NotReadyError) && isA(value, classes?.includes(Error) ? undefined : classes && [...classes]);
+ });
+ const content = createMemo(() => matched() ? untrack(() => props.fallback(error, reset)) : undefined);
+ return { [tag]: { matched, content, error } };
+ },
+ });
+ // As in the shared TypedErrored bridge, forward outside Errored: throwing in its
+ // fallback strands equal-value recovery. Matching payloads do not remount content.
+ return createMemo(() => {
+ const value: unknown = typeof out === "function" ? (out as Accessor)() : out;
+ if (value && typeof value === "object" && tag in value) {
+ const fallback = value[tag] as { matched: Accessor; content: Accessor; error: Accessor };
+ if (!fallback.matched()) throw fallback.error();
+ return fallback.content();
+ }
+ return value;
+ }) as unknown as Element;
+}
+
+/** Structured sources carry store pairs; scalar unions keep a single Signal. */
+export type SourcePair = [T] extends [object] ? [Store, StoreSetter] : Signal;
+
+export function createSourceContext(): Context | null> {
+ return createContext | null>(null);
+}
+
+export function useSourceContext(context: Context<[Store, StoreSetter] | null>): SourcePair;
+export function useSourceContext(context: Context | null>): Signal;
+export function useSourceContext(context: Context): Pair {
+ const source = getOwner() ? useContext(context) : null;
+ if (!source) throw new Error("useSourceContext requires a source context provider");
+ return source;
+}
+
+type ServerBody = (...args: Args) => Generator;
+let requestedLoaders: Set<() => void> | undefined;
+const memoLoaders = new WeakMap void>>();
+
+/** Explicit server-loader memo: only requests made before the compute returns are recorded. */
+export const createServerMemo: typeof createMemo = (compute, options) => {
+ const requests = new Set<() => void>();
+ const memo = createMemo(previous => {
+ const outer = requestedLoaders; requestedLoaders = requests; requests.clear();
+ // Solid supplies the previous value according to its seeded/unseeded overload.
+ try { return (compute as (value: typeof previous) => ReturnType)(previous); }
+ finally { requestedLoaders = outer; }
+ }, options);
+ memoLoaders.set(memo, requests);
+ return memo;
+};
+
+/** Evict this memo pass's loader keys, then use Solid's real refresh scheduling. */
+export const refresh: typeof solidRefresh = target => {
+ for (const invalidate of memoLoaders.get(target) ?? []) invalidate();
+ return solidRefresh(target);
+};
+
+function serverCallable(
+ name: string, body: ServerBody, loader: boolean,
+): (...args: Args) => Promise {
+ const flights = new Map>();
+ return (...args) => {
+ let wireError: Error | undefined;
+ const fail = (message: string): never => { throw wireError = new Error(`WIRE_VALUE: ${name}: ${message}`); };
+ let copied: Args, key: string;
+ try {
+ copied = args.map((arg, i) => wireCopy(arg, `argument ${i + 1}`, fail)) as Args;
+ key = wireCacheKey(copied);
+ } catch (error) { return Promise.reject(error); }
+ if (loader && requestedLoaders) requestedLoaders.add(() => { flights.delete(key); });
+ if (loader && flights.has(key)) return flights.get(key)!;
+ const detached = (work: () => T): T => runWithOwner(null, () => untrack(work));
+ const request = (async () => {
+ try {
+ const generator = detached(() => body(...copied));
+ let input: unknown, thrown = false;
+ for (;;) {
+ const next = detached(() => thrown ? generator.throw(input) : generator.next(input));
+ if (next.done) return detached(() => wireCopy(next.value, "its result", fail)) as Result;
+ try { input = await next.value; thrown = false; }
+ catch (error) { input = error; thrown = true; }
+ }
+ } catch (error) { throw wireError && error === wireError ? error : wireFailure(error); }
+ })();
+ if (loader) flights.set(key, request);
+ return request;
+ };
+}
+
+/** In-process SF-6 wire, not HTTP. Capture/purity checks belong to the frontend.
+ * The factory cache is invalidated explicitly by createServerMemo + this module's refresh. */
+export function serverFunction(name: string, body: ServerBody): (...args: Args) => Promise {
+ return serverCallable(name, body, true);
+}
+
+export function serverAction(name: string, body: ServerBody): (...args: Args) => Promise {
+ return serverCallable(name, body, false);
+}
diff --git a/tsx-frontend/tsx/rx-effects.test.ts b/tsx-frontend/tsx/rx-effects.test.ts
new file mode 100644
index 0000000..8dcaaaf
--- /dev/null
+++ b/tsx-frontend/tsx/rx-effects.test.ts
@@ -0,0 +1,370 @@
+import { test } from "node:test";
+import { runtimeFiles } from "./rx/runtime.ts";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { createEffect, createMemo, createRoot, createSignal, flush } from "solid-js";
+import { createRxEffect, RX_ERROR } from "./effects.ts";
+import { parseProgram } from "./rx/compiler.ts";
+import { check } from "./rx/compiler.ts";
+import { Runtime } from "./rx/runtime.ts";
+import { compile } from "./rx/compiler.ts";
+import { lowerViews, STRING_RENDERER } from "./rx/compiler.ts";
+import { createApp } from "./rx/runtime.ts";
+
+const settle = async () => {
+ await new Promise(resolve => setTimeout(resolve, 0)); flush();
+ await new Promise(resolve => setTimeout(resolve, 0)); flush();
+};
+
+test("RX effects equality-filter direct inputs without disposing their cleanup", () => {
+ const events: string[] = [];
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ createRxEffect([() => n() > 2], value => {
+ events.push(`apply ${value}`);
+ return () => { events.push(`cleanup ${value}`); };
+ });
+ return { dispose, setN };
+ });
+ try {
+ flush();
+ for (const n of [1, 2]) { app.setN(n); flush(); }
+ assert.deepEqual(events, ["apply false"]);
+ app.setN(3); flush();
+ app.setN(4); flush();
+ assert.deepEqual(events, ["apply false", "cleanup false", "apply true"]);
+ } finally { app.dispose(); }
+ assert.deepEqual(events, ["apply false", "cleanup false", "apply true", "cleanup true"]);
+});
+
+test("RX effects isolate synchronous and upstream errors per input and recover", async () => {
+ const values: unknown[][] = [];
+ const events: string[] = [];
+ const phases: string[][] = [];
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ const upstream = createMemo(() => { if (n() === 2 || n() === 3) throw new Error(`upstream ${n()}`); return n(); });
+ createRxEffect([() => { if (n() === 1 || n() === 3) throw new Error(`direct ${n()}`); return n(); }, upstream, () => "healthy"], (...answer) => {
+ values.push(answer); events.push(`log(apply, ${answer.map(String).join(", ")})`);
+ return () => { events.push(`log(cleanup, ${answer.map(String).join(", ")})`); };
+ });
+ return { dispose, setN };
+ });
+ try {
+ flush(); phases.push([...events]);
+ for (const n of [1, 2, 3, 0]) { app.setN(n); flush(); phases.push([...events]); }
+ assert.deepEqual(values, [[0, 0, "healthy"], [RX_ERROR, 1, "healthy"], [2, RX_ERROR, "healthy"], [RX_ERROR, RX_ERROR, "healthy"], [0, 0, "healthy"]]);
+ assert.equal(String(RX_ERROR), "#error");
+ assert.equal(values[1][0], values[3][0]);
+ assert.equal(values[2][1], values[3][1]);
+ } finally { app.dispose(); }
+ phases.push([...events]);
+ assert.equal(events.at(-1), "log(cleanup, 0, 0, healthy)");
+ const actual = await rxTraces(`error Boom(n) => "boom"; source n := 0;
+ left = n == 1 || n == 3 ? throw Boom(n) : n;
+ right = n == 2 || n == 3 ? throw Boom(n) : n;
+ effect(left, right, "healthy") { let a = $0; let b = $1; let c = $2; log("apply", a, b, c); return () => { log("cleanup", a, b, c); }; }`, [1, 2, 3, 0]);
+ assert.deepEqual(actual.compiled, phases);
+ assert.deepEqual(actual.interpreter, phases);
+});
+
+test("consecutive RX error answers compare equal and retain cleanup", () => {
+ const events: string[] = [];
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ createRxEffect([() => { if (n() > 0) throw new Error(String(n())); return 0; }], value => {
+ events.push(`apply ${value}`); return () => { events.push(`cleanup ${value}`); };
+ });
+ return { dispose, setN };
+ });
+ try {
+ flush();
+ for (const n of [1, 2, 3]) { app.setN(n); flush(); }
+ assert.deepEqual(events, ["apply 0", "cleanup 0", "apply #error"]);
+ app.setN(0); flush();
+ assert.deepEqual(events, ["apply 0", "cleanup 0", "apply #error", "cleanup #error", "apply 0"]);
+ } finally { app.dispose(); }
+});
+
+for (const answer of [false, true, "reject"] as const) test(`RX effects preserve cleanup through pending then ${answer}`, async () => {
+ const events: string[] = [];
+ let resolve!: (value: boolean) => void;
+ let reject!: (error: Error) => void;
+ const waiting = new Promise((yes, no) => { resolve = yes; reject = no; });
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ const value = createMemo(() => n() === 1 ? waiting : false);
+ createRxEffect([value, () => "healthy"], (next, healthy) => {
+ events.push(`apply ${next}/${healthy}`);
+ return () => { events.push(`cleanup ${next}/${healthy}`); };
+ });
+ return { dispose, setN };
+ });
+ try {
+ flush(); await settle();
+ app.setN(1); flush(); await settle();
+ assert.deepEqual(events, ["apply false/healthy"]);
+ if (answer === "reject") reject(new Error("async failure")); else resolve(answer);
+ await settle();
+ const expected = answer === "reject" ? "#error" : String(answer);
+ assert.deepEqual(events, answer === false ? ["apply false/healthy"] : ["apply false/healthy", "cleanup false/healthy", `apply ${expected}/healthy`]);
+ app.setN(2); flush(); await settle();
+ assert.equal(events.at(-1), "apply false/healthy");
+ } finally { app.dispose(); }
+ assert.equal(events.at(-1), "cleanup false/healthy");
+});
+
+test("RX effects skip a pending mount, including an earlier errored input", async () => {
+ const values: unknown[][] = [];
+ let resolve!: (value: number) => void;
+ const waiting = new Promise(yes => { resolve = yes; });
+ const dispose = createRoot(dispose => {
+ const pending = createMemo(() => waiting);
+ createRxEffect([() => { throw new Error("first"); }, pending], (...answer) => { values.push(answer); });
+ return dispose;
+ });
+ try {
+ flush(); await settle();
+ assert.deepEqual(values, []);
+ resolve(7); await settle();
+ assert.deepEqual(values, [[RX_ERROR, 7]]);
+ } finally { dispose(); }
+});
+
+test("RX effects do not flatten array/function answers and compare inputs with Object.is", () => {
+ const values: unknown[][] = [];
+ const fn = () => { throw new Error("function answers must not be called"); };
+ const array = [fn];
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ createRxEffect([() => { n(); return array; }, () => fn, () => n() === 0 ? NaN : n() === 1 ? NaN : n() === 2 ? 0 : -0], (...answer) => { values.push(answer); });
+ return { dispose, setN };
+ });
+ try {
+ flush();
+ for (const n of [1, 2, 3]) { app.setN(n); flush(); }
+ assert.deepEqual(values, [[array, fn, NaN], [array, fn, 0], [array, fn, -0]]);
+ } finally { app.dispose(); }
+});
+
+test("RX input equations are cached independently, including function and array allocations", async () => {
+ const events: string[] = [];
+ let arrays = 0, functions = 0;
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ createRxEffect([() => n() > 2, () => { arrays++; return [7]; }, () => { functions++; return () => 7; }], (value, array, fn) => {
+ assert.ok(Array.isArray(array)); assert.ok(typeof fn === "function");
+ events.push(`log(${value}, ${array[0]}, ${fn()})`);
+ });
+ return { dispose, setN };
+ });
+ const native: string[][] = [];
+ try {
+ flush(); native.push([...events]);
+ for (const n of [1, 2, 3, 4]) { app.setN(n); flush(); native.push([...events]); }
+ assert.equal(arrays, 1); assert.equal(functions, 1);
+ } finally { app.dispose(); }
+ native.push([...events]);
+ const expected = [["log(false, 7, 7)"], ["log(false, 7, 7)"], ["log(false, 7, 7)"], ["log(false, 7, 7)", "log(true, 7, 7)"], ["log(false, 7, 7)", "log(true, 7, 7)"], ["log(false, 7, 7)", "log(true, 7, 7)"]];
+ const actual = await rxTraces(`source n := 0; effect(n > 2, [7], () => 7) { log($0, $1[0], $2()); }`, [1, 2, 3, 4]);
+ assert.deepEqual(native, expected);
+ assert.deepEqual(actual.interpreter, expected);
+ assert.deepEqual(actual.compiled, expected);
+});
+
+test("RX effects retain cleanup when pending is disposed and never apply a late landing", async () => {
+ const events: string[] = [];
+ let resolve!: (value: boolean) => void;
+ const waiting = new Promise(yes => { resolve = yes; });
+ const app = createRoot(dispose => {
+ const [n, setN] = createSignal(0);
+ createRxEffect([() => n() === 1 ? waiting : false], value => {
+ events.push(`apply ${value}`); return () => { events.push(`cleanup ${value}`); };
+ });
+ return { dispose, setN };
+ });
+ try {
+ flush(); app.setN(1); flush(); await settle();
+ assert.deepEqual(events, ["apply false"]);
+ } finally { app.dispose(); }
+ resolve(true); await settle();
+ assert.deepEqual(events, ["apply false", "cleanup false"]);
+});
+
+for (const other of [0, 1]) test(`a pending RX tuple compares every input with its last received tuple (${other})`, async () => {
+ const events: string[] = [];
+ let resolve!: (value: boolean) => void;
+ const waiting = new Promise