From 4c9b523868a92e764b68f31a36efddd27eae6991 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 02:57:32 +0000 Subject: [PATCH 1/3] Site: a like button with guess, races refused, the Solid mapping, rx-tsc Expands the landing page for 0.2.0. Every error shown is rx-tsc 0.2.0 output on the program shown, and the fixed like button checks clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNWyJ5aFpYuL4JDYyf --- site/index.html | 111 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 16 deletions(-) diff --git a/site/index.html b/site/index.html index 4191657..403042d 100644 --- a/site/index.html +++ b/site/index.html @@ -60,7 +60,16 @@ ul.links { list-style: none; padding: 0; margin: 0; } ul.links li { padding: 8px 0; border-bottom: 1px solid var(--line); } ul.links li span { color: var(--muted); font-size: 15px; } - @media (max-width: 600px) { .grid { grid-template-columns: 1fr; } header.hero { padding-top: 40px; } } + table.map { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 15px; } + table.map th, table.map td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } + table.map th { color: var(--muted); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; } + table.map td:first-child { white-space: nowrap; } + .pair { display: grid; grid-template-columns: 1fr; gap: 0; margin-bottom: 26px; } + .pair h3 { font-size: 18px; margin: 26px 0 6px; } + .pair p { color: var(--muted); } + pre.ok { border-color: #2c5a3a; } + .label { font: 600 12px var(--sans); text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 0 0 6px; } + @media (max-width: 600px) { table.map td:first-child { white-space: normal; } table.map code { word-break: break-word; } .grid { grid-template-columns: 1fr; } header.hero { padding-top: 40px; } } footer { max-width: 880px; margin: 0 auto; padding: 24px 20px 48px; color: var(--muted); font-size: 14px; border-top: 1px solid var(--line); } @@ -79,10 +88,10 @@

ReactiveScript

Read the guide GitHub -

Experimental · 0.1. Files are .rx and compile to plain Solid 2.0. Releases follow Solid 2.0's release candidates, so expect breaking changes until Solid 2.0 is final.

+

Experimental · 0.2. Files are .rx and compile to plain Solid 2.0. Releases follow Solid 2.0's release candidates, so expect breaking changes until Solid 2.0 is final.

-

Mistakes of shape become compile errors

-

In Solid you already write UIs as derivations over signals. Most of what goes wrong is a shape mistake: writing to something that should be derived, writing during a computation, reading async data with no loading or error boundary above it. ReactiveScript makes those things the compiler refuses, the way Rust refuses a borrow.

+

Mistakes of shape become errors

+

In Solid you already write UIs as derivations over signals. Most of what goes wrong is a shape mistake: writing to something that should be derived, writing during a computation, reading async data with no loading or error boundary above it. ReactiveScript makes those things the checker refuses, the way Rust refuses a borrow.

Three kinds of names do the work. A source is state, and only a source is written, with :=. An equation, y = x * 2, is always true and recomputes when what it reads changes. Writes happen only in an event handler or an action.

counter.rxexport default component Counter() {
   source count := 0
@@ -90,11 +99,11 @@ 

Mistakes of shape become compile errors

<button onClick={() => { count := count + 1 }}>{count} × 2 = {double}</button> }
-

Write to the equation instead, double := 4, and it does not compile:

-
counter.rx:5: error WRITE_TO_NON_SOURCE [W-1] double is an equation: only sources are written (W-1)
+

Write to the equation instead, double := 4, and the checker refuses it, in your editor and in CI:

+
counter.rx:5:3 - error WRITE_TO_NON_SOURCE: double is an equation: only sources are written (W-1) (W-1)

Async is just await

-

Put an await in an equation. The compiler tracks that the value can be pending and can fail, and asks you to say what to show in both cases.

+

Put an await in an equation. The checker tracks that the value can be pending and can fail, and asks you to say what to show in both cases.

profile.rximport pure { fetchUser } from "./api"
 
 export default component Profile() {
@@ -103,19 +112,86 @@ 

Async is just await

<section> <button onClick={() => { id := id + 1 }}>Next</button> - {errored fallback(e) { <p>Couldn't load: {e.message}</p> } { - {loading fallback { <p>Loading…</p> } { - <h1>{user.name}</h1> - }} + {loading fallback { <p>Loading…</p> } { + <h1>{user.name}</h1> }} </section> }
-

Leave out the errored boundary and the checker says what's missing and how to fix it:

-
profile.rx:10: error UNHANDLED_FAILURE [R-f-5] a reader of user.name (in <Profile>) can fail with Error and no catch or errored boundary above it discharges Error — wrap it in `errored fallback(e) { … } { … }`, or catch it: `… catch (e) => …`
+

There's a loading boundary but no errored one, so the checker says what's missing and how to fix it:

+
profile.rx:10:7 - error UNHANDLED_FAILURE: a reader of user.name (in <Profile>) can fail with Error and no catch or errored boundary above it discharges Error — wrap it in `errored fallback(e) { … } { … }`, or catch it: `… catch (e) => …` (R-f-5)
-

Why it's built this way

+

A like button, the first time you write it

+

Show the new count at once, send it, and let the server's answer replace the guess. That's guess, which lowers to Solid's createOptimistic. This is how most people write it first:

+
like.rximport pure { fetchLikes } from "./api"
+import { like } from "./api"
+
+export default component Like(props: { id: number }) {
+  likes = await fetchLikes(props.id)
+
+  action send() {
+    guess likes := likes + 1        // show it now; the server's answer replaces it
+    await like(props.id)
+    refresh likes
+  }
+
+  <button onClick={() => { send() }}>♥ {likes}</button>
+}
+

It has three bugs, and two only show when the network is slow or a request fails. The checker finds all three:

+
like.rx:13:3 - error UNHANDLED_FAILURE: a reader of likes (in <Like>) can fail with Error and no catch or errored boundary above it discharges Error — wrap it in `errored fallback(e) { … } { … }`, or catch it: `… catch (e) => …` (R-f-5)
+like.rx:7:3 - error UNCAUGHT_ACTION_FAILURE: action send can fail with Error and does not catch it: a handler cannot (it does not await the action) — `try { … } catch (e) { … }` in the body (R-f-6)
+like.rx:13:3 - error GUESS_WHILE_LOADING: <button> onClick starts `send`, which can run `guess likes` while likes is loading a new question (its question reads what the click reads): the guess composes on the previous answer, and the new answer replaces it, so the change disappears while it is out (D-34) — put the element inside the `loading` region whose content reads likes, or give it `disabled={pending? likes}`, or keep the pending change in a source of its own until it lands (AC-3a)
+

The last one is the subtle one. Click while the count is still reloading and your +1 lands on the old answer, then vanishes when the new one arrives. Each error names the fix, and the fixed version passes:

+
like.rxexport default component Like(props: { id: number }) {
+  likes = await fetchLikes(props.id) catch (e) => 0
+
+  action send() {
+    guess likes := likes + 1
+    try {
+      await like(props.id)
+      refresh likes
+    } catch (e) {}                     // the guess goes away by itself
+  }
+
+  {loading fallback { <p>Loading…</p> } {
+    <button onClick={() => { send() }}>♥ {likes}</button>
+  }}
+}
+ +

Races, refused

+

The bugs that come from await in a handler don't show in a demo. They show when the network is slow or two clicks overlap.

+
+

The "Saving…" that never shows

+

A handler that writes and awaits is an action, and an action's writes land together when it ends. So this status only ever reads the result.

+
<button onClick={() => {
+  status := "Saving…"
+  let r = await saveName("Ann") catch (e) => e.message
+  status := r
+}}>{status}</button>
+
save.rx:5:3 - error HELD_WRITE: status := … comes before an `await` in a body that is an action because it writes and awaits (AC-1b), so it is held until the body ends (AC-1): for state that shows while the work runs, `guess status := …` (a source with a question, ST-5c); for a reset that lands now, `onFlush(() => { status := … })` (EF-8); to hold it on purpose, declare the action (`action name() { … }`); or write it after the `await` (AC-1c)
+

The write that undoes another click

+

Read, await, write back. If anything changed tags in between, this overwrites it.

+
action add(t: string) {
+  let before = tags
+  let saved = await addTag(t) catch (e) => t
+  tags := before + " " + saved
+}
+
tags.rx:5:3 - error STALE_WRITE_BACK: tags := … after an `await` is computed from tags as it was read before that `await`: whatever changed tags in between (another action, a reload) is overwritten — write functionally (`tags := v => …`, W-6), which applies to tags as it stands at the write, or guess it (`guess`, AC-3) and let the answer replace the guess (AC-1e)
+
+ +

Every word is a Solid primitive

+

A .rx program compiles to ordinary Solid 2.0 and adds nothing at run time beyond what Solid does. Each form in the language has one counterpart:

+ + + + + + + + + +
.rxSolid 2.0
source x := 0createSignal, or createStore for objects
y = x * 2createMemo, createProjection
user = await fetchUser(id)an async createMemo
guess likes := likes + 1createOptimistic
action send() { … }action
effect (x) { … }createEffect(compute, apply)
loading / errored<Loading> / <Errored>
-

Static checks only

A .rx program compiles to ordinary Solid 2.0 code. It adds nothing at run time beyond what Solid does.

+

Static checks only

The checks run in your editor and in CI. The compiled code is Solid's, with Solid's runtime behaviour.

Easy for an AI to write

The rules are small and explicit. Every error names the rule it enforces and usually the fix, so an agent can correct itself.

TypeScript underneath

Expressions and JSX are TypeScript's, and types are checked by TypeScript in the editor and in CI.

The spec is the product

Every behaviour is a numbered rule or an executable scenario, checked against Solid itself.

@@ -130,7 +206,10 @@

Install

const app = createApp({ root: document.getElementById("app") }); app.mount(main(app)); -

In your editor. The ReactiveScript extension brings highlighting, the checker's diagnostics, and TypeScript's hover, completion, rename and type errors on .rx. Get it from the VS Code Marketplace, or from Open VSX for Cursor and VSCodium. Other editors can run npx @reactivescript/language-server --stdio.

+

In your editor. The ReactiveScript extension brings highlighting, the checker's errors, and TypeScript's hover, completion, rename and type errors on .rx. Get it from the VS Code Marketplace, or from Open VSX for Cursor and VSCodium. Other editors can run npx @reactivescript/language-server --stdio.

+

In CI. Vite compiles .rx without checking it, as it does .ts. rx-tsc is tsc with .rx in the program: the same check the editor runs, with every error above.

+
npm i -D @reactivescript/language-server typescript
+npx rx-tsc --noEmit

Learn more