diff --git a/README.md b/README.md index 9c01f34..f593c7b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ export default component Counter() { Write to the equation instead, `double := 4`, and the checker refuses it: ``` -counter.rx:5: error WRITE_TO_NON_SOURCE [W-1] double is an equation: only sources are written (W-1) +counter.rx:5: error WRITE_TO_NON_SOURCE [W-1] double is an equation: only sources are written ``` Async is an `await` in an equation. The compiler tracks that the value can be diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 7697f96..e83ad74 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -2350,7 +2350,7 @@ impl<'a> Checker<'a> { let info = self.syms[c].ctx.clone().unwrap(); self.uses.push(UseRec { region, name: info.key.clone(), line: self.line, display: target.name.clone() }); if !info.source { - self.err("WRITE_TO_NON_SOURCE", "O-4", format!("context {} is read beneath its provision: call an action it provides that writes it (O-4)", target.name)); + self.err("WRITE_TO_NON_SOURCE", "O-4", format!("context {} is read beneath its provision: call an action it provides that writes it", target.name)); } } } else { @@ -2374,7 +2374,7 @@ impl<'a> Checker<'a> { k => format!("a {}", k.as_str()), }; let hint = if k == SymKind::Props || k == SymKind::Value || self.syms[t].param { " — a source is written only in the region that declares it: have its owner pass an action that writes it (O-4)" } else { "" }; - self.err("WRITE_TO_NON_SOURCE", "W-1", format!("{} is {}: only sources are written (W-1){}", target.name, what, hint)); + self.err("WRITE_TO_NON_SOURCE", "W-1", format!("{} is {}: only sources are written{}", target.name, what, hint)); } else if let Some(imp) = self.syms[t].imported.clone() { self.err( "WRITE_TO_NON_SOURCE", diff --git a/ref/src/check.ts b/ref/src/check.ts index 1528b85..705649a 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -228,6 +228,7 @@ class Checker { private effectIns: { region: Region; e: Expr; env: Env; line: number }[] = []; private apps: { comp: Region; site: Region; line: number; tag: string; args: { field: string; e: Expr; env: Env }[]; passed: { field: string; e: Expr; env: Env }[] }[] = []; // passed: every attribute, handlers too (O-3) private actions: { name: string; body: Stmt[]; env: Env; line: number }[] = []; + private handlers: { attrs: { name: string; e: Expr }[]; kids: Markup[]; h: Expr; env: Env; region: Region; line: number; what: string }[] = []; // AC-3a: element handlers, by where they are private provisions: { key: string; e: Expr; env: Env }[] = []; private importSyms = new Set(); // MO-1d: what imports bound; a nested block may not shadow it private later: (() => void)[] = []; @@ -255,6 +256,112 @@ class Checker { this.retained(); this.resolveTree(); this.loadingOnOutside(); + this.guessWhileLoading(); + } + + // ---- AC-3a: a guess a click can make while its value loads a new question ---- + /** A guess composes on the value as it stands. If a click can run `guess x := …` while `x` is loading a new question that + * the click itself started (its question reads what the click reads), the guess sits on the previous answer and the new + * answer replaces it, so the change disappears while it is out (D-34; Solid's OPTIMISTIC_WRITE_WHILE_LOADING, at run time). + * Covered when the element is inside a `loading` region whose content reads `x`, or is disabled on `x` (a form: all its + * submit buttons are). Mirrors compiler/src/check.rs `guess_while_loading`. */ + private guessWhileLoading() { + type N = Expr | Stmt; + const kidsOf = (n: N): N[] => { + const out: N[] = []; + for (const v of Object.values(n as object)) { + if (Array.isArray(v)) { for (const x of v) if (x && typeof x === "object") { if ("k" in x) out.push(x as N); else if ("v" in x) out.push((x as { v: Expr }).v); else if ("spread" in x) out.push((x as { spread: Expr }).spread); } } + else if (v && typeof v === "object" && "k" in (v as object)) out.push(v as N); + } + return out; + }; + const depends = (e: Expr, env: Env, t: Sym, busy: Set): boolean => { + const go = (x: Expr): boolean => { + if (x.k === "fn" || x.k === "serverfn") return false; + if (x.k === "id") { const u = env.lookup(x.name); if (!u) return false; if (u === t) return true; return !!(u.expr && u.env && !busy.has(u) && (busy.add(u), depends(u.expr, u.env, t, busy))); } + return subExprs(x).some(go); + }; + return go(e); + }; + const awaits = (t: Sym, busy: Set): boolean => { + if (!t.expr || !t.env || busy.has(t)) return false; + busy.add(t); + const env = t.env; + const go = (x: Expr): boolean => { + if (x.k === "fn" || x.k === "serverfn" || x.k === "peek") return false; + if (x.k === "await") return true; + if (x.k === "id") { const u = env.lookup(x.name); return !!u && u !== t && (u.kind === "eq" || u.kind === "source") && awaits(u, busy); } + return subExprs(x).some(go); + }; + return go(t.expr); + }; + const asksAbout = (t: Sym, inputs: Sym[]) => !!t.expr && !!t.env && inputs.some(i => i !== t && !(i.expr && i.env && depends(i.expr, i.env, t, new Set())) && depends(t.expr!, t.env!, i, new Set())); + const inside = (r: Region | null, anc: Region) => { for (let c = r; c; c = c.parent) if (c === anc) return true; return false; }; + const heldBy = (r: Region, t: Sym) => { + for (let c: Region | null = r; c; c = c.parent) { + if (c.provides.has(HOLD) && this.readers.some(rd => inside(rd.region, c!) && depends(rd.e, rd.env, t, new Set()))) return true; + if (c.isComponentRoot) break; + } + return false; + }; + const disabledOn = (attrs: { name: string; e: Expr }[], env: Env, t: Sym) => attrs.some(a => a.name === "disabled" && depends(a.e, env, t, new Set())); + const submitButtons = (ms: Markup[], out: { name: string; e: Expr }[][]) => { + for (const m of ms) { + if (m.k === "el") { if (m.tag === "button" && !m.attrs.some(a => a.name === "type" && !(a.e.k === "lit" && a.e.v === "submit"))) out.push(m.attrs); submitButtons(m.children, out); } + else if (m.k === "mif") { submitButtons(m.t, out); submitButtons(m.f, out); } + else if (m.k === "mfor" || m.k === "loading" || m.k === "errored" || m.k === "portal") submitButtons(m.body, out); + } + }; + const done = new Set(); + for (const hr of this.handlers) { + if (done.has(hr.h)) continue; + done.add(hr.h); + const submits: { name: string; e: Expr }[][] = []; + if (hr.what === "
onSubmit") submitButtons(hr.kids, submits); + // the bodies the click runs: the handler, and the actions it calls by name + const bodies: { root: N; env: Env; via?: string }[] = [{ root: hr.h, env: hr.env }]; + const stack: N[] = [hr.h]; + while (stack.length) { + const n = stack.pop()!; + if (n.k === "call" && (n as Extract).f.k === "id") { + const name = ((n as Extract).f as Extract).name; + const a = hr.env.lookup(name); + if (a?.kind === "action") for (const act of this.actions) if (act.name === name && act.line === a.line) bodies.push(...act.body.map(st => ({ root: st as N, env: act.env, via: name }))); + } + stack.push(...kidsOf(n)); + } + // what the click is about: the names those bodies read, outside their own writes and guesses + const inputs: Sym[] = []; + for (const b of bodies) { + const st: N[] = [b.root]; + while (st.length) { + const n = st.pop()!; + if (n.k === "optimistic" || n.k === "write") continue; + if (n.k === "id") { const v = b.env.lookup((n as Extract).name); if (v && (v.kind === "source" || v.kind === "eq" || v.kind === "value" || v.kind === "props") && !inputs.includes(v)) inputs.push(v); } + st.push(...kidsOf(n)); + } + } + const seen = new Set(); + for (const b of bodies) { + const st: N[] = [b.root]; + while (st.length) { + const n = st.pop()!; + if (n.k === "optimistic") { + const tn = (n as Extract).target.name; + const t = b.env.lookup(tn); + if (t && !seen.has(t) && (seen.add(t), true) + && awaits(t, new Set()) && asksAbout(t, inputs) + && !disabledOn(hr.attrs, hr.env, t) + && !(submits.length > 0 && submits.every(at => disabledOn(at, hr.env, t))) + && !heldBy(hr.region, t)) { + this.line = hr.line; + this.err("GUESS_WHILE_LOADING", "AC-3a", `${hr.what} ${b.via ? `starts \`${b.via}\`, which can run` : "can run"} \`guess ${tn}\` while ${tn} 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 ${tn}, or give it \`disabled={pending? ${tn}}\`, or keep the pending change in a source of its own until it lands`); + } + } + st.push(...kidsOf(n)); + } + } + } } // ---- CN-3e: an outside read holds a `loading on(…)` region's change ---- @@ -835,14 +942,14 @@ class Checker { let target: Sym | undefined; if (s.target.ctx) { const c = this.ctxOf(s.target.name, env); - if (c) { this.model.write(c, s.k, s.target.path.length > 0); this.uses.push({ region: env.region, name: c.ctx!.key, line: this.line, display: s.target.name }); if (!c.ctx!.source) this.err("WRITE_TO_NON_SOURCE", "O-4", `context ${s.target.name} is read beneath its provision: call an action it provides that writes it (O-4)`); } + if (c) { this.model.write(c, s.k, s.target.path.length > 0); this.uses.push({ region: env.region, name: c.ctx!.key, line: this.line, display: s.target.name }); if (!c.ctx!.source) this.err("WRITE_TO_NON_SOURCE", "O-4", `context ${s.target.name} is read beneath its provision: call an action it provides that writes it`); } } else { target = this.resolve(s.target.name, env, ctx); this.model.write(target, s.k, s.target.path.length > 0); const guessable = s.k === "optimistic" && target?.kind === "eq" && !!target.eqDecl && !target.param && !target.imported; // AC-3: a guess may sit over an equation (Solid's createOptimistic(fn)); it is not a write of the truth if (guessable && target!.eqDecl!.deferred) this.err("GUESS_ON_DEFERRED", "AC-3", `${s.target.name} is deferred: its value is a lagging copy (V-16), so nothing would confirm a guess on it — guess the equation it lags`); else if (guessable) {} - else if (target && target.kind !== "source") this.err("WRITE_TO_NON_SOURCE", "W-1", `${s.target.name} is ${target.kind === "eq" ? "an equation" : target.kind === "props" ? "props" : `a ${target.kind}`}: only sources are written (W-1)${target.kind === "props" || target.kind === "value" || target.param ? " — a source is written only in the region that declares it: have its owner pass an action that writes it (O-4)" : ""}`); + else if (target && target.kind !== "source") this.err("WRITE_TO_NON_SOURCE", "W-1", `${s.target.name} is ${target.kind === "eq" ? "an equation" : target.kind === "props" ? "props" : `a ${target.kind}`}: only sources are written${target.kind === "props" || target.kind === "value" || target.param ? " — a source is written only in the region that declares it: have its owner pass an action that writes it (O-4)" : ""}`); else if (target?.imported) this.err("WRITE_TO_NON_SOURCE", "MO-2", `${s.target.name} is a source of ${target.imported}: a module boundary is a value boundary — write it through an action ${target.imported} exports`); } if (s.k === "optimistic") { @@ -994,7 +1101,7 @@ class Checker { } else this.markup(m.children, env); break; } - for (const a of m.attrs) { this.comp(isHandler(a.name) ? "handler" : "reader", env, { at: `${m.tag} ${a.name}` }, () => this.expr(a.e, env, { scope: isHandler(a.name) ? "imperative" : "reactive" })); if (!isHandler(a.name)) this.readers.push({ region: env.region, e: a.e, env, line: this.line }); } + for (const a of m.attrs) { this.comp(isHandler(a.name) ? "handler" : "reader", env, { at: `${m.tag} ${a.name}` }, () => this.expr(a.e, env, { scope: isHandler(a.name) ? "imperative" : "reactive" })); if (!isHandler(a.name)) this.readers.push({ region: env.region, e: a.e, env, line: this.line }); else this.handlers.push({ attrs: m.attrs, kids: m.children, h: a.e, env, region: env.region, line: this.line, what: `<${m.tag}> ${a.name}` }); } this.markup(m.children, env); break; } diff --git a/scenarios/S-guess-equation.md b/scenarios/S-guess-equation.md index c997dd8..fa69eb9 100644 --- a/scenarios/S-guess-equation.md +++ b/scenarios/S-guess-equation.md @@ -99,3 +99,25 @@ export default component App() { A deferred equation's value is a lagging copy (V-16); a guess has nothing to confirm it there. + +```rx +-- reject: GUESS_WHILE_LOADING +export default component App() { + source id := 1 + likes = await fetch("likes:" + id) catch (e) => 0 + action send() { + try { + guess likes := likes + 1 + await fetch("like:" + id) + refresh likes + } catch (e) {} + } + + +} +``` + +A click can guess `likes` while it loads a new question about `id`, which +the click reads too (AC-3a): the guess sits on the old answer and the new +one replaces it. Put the button inside the `loading` region that shows +`likes`, or disable it on `pending? likes`. diff --git a/site/index.html b/site/index.html index 4191657..66a9abb 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)

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