From c2c6409dead2a5988c8940fc29b3dac5f8a92e1a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 03:44:06 +0000 Subject: [PATCH 1/7] Rename the `optimistic` statement to `guess` `guess x := e` replaces `optimistic x := e` (07 decision 18, ruled 2026-11-11). Semantics and lowering are unchanged (createOptimistic / createOptimisticStore); only the surface word moves. - Parsers (ref/src/parse.ts, compiler/src/parse.rs): `guess` is the keyword; `optimistic` stays reserved and is a parse error coded OPTIMISTIC whose hint names `guess` (guide/diagnostics.md, S-parse-hints). - Codes: OPTIMISTIC_OUTSIDE_HOLD -> GUESS_OUTSIDE_HOLD, OPTIMISTIC_NO_QUESTION -> GUESS_NO_QUESTION, OPTIMISTIC_REVERTED -> GUESS_REVERTED; HELD_WRITE / STALE_WRITE_BACK / AC-1b messages say `guess`. - Spec, guide, scenarios, examples, eval reference apps and the editor grammar use `guess`. eval/results and preregistrations are left as historical records. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWP7LmQhib6M9zGnmBH --- compiler/src/ast.rs | 2 +- compiler/src/check.rs | 8 ++-- compiler/src/infer.rs | 2 +- compiler/src/parse.rs | 9 +++- eval/README.md | 2 +- eval/reference/chain1/rx/comments.rx | 4 +- eval/reference/chain1/rx/issues.rx | 14 +++--- eval/reference/chain1/rx/labels.rx | 8 ++-- eval/reference/chain1/rx/settings.rx | 4 +- eval/reference/chain2/rx/comments.rx | 4 +- eval/reference/chain2/rx/issues.rx | 14 +++--- eval/reference/chain2/rx/labels.rx | 8 ++-- eval/reference/chain2/rx/settings.rx | 4 +- eval/reference/chain3/rx/comments.rx | 4 +- eval/reference/chain3/rx/issues.rx | 14 +++--- eval/reference/chain3/rx/labels.rx | 8 ++-- eval/reference/chain3/rx/settings.rx | 4 +- eval/reference/chain4/rx/comments.rx | 4 +- eval/reference/chain4/rx/issues.rx | 18 ++++---- eval/reference/chain4/rx/labels.rx | 8 ++-- eval/reference/chain4/rx/settings.rx | 4 +- eval/reference/close/rx/main.rx | 8 ++-- eval/reference/comments/rx/main.rx | 8 ++-- eval/reference/count/rx/comments.rx | 4 +- eval/reference/count/rx/issues.rx | 14 +++--- eval/reference/count/rx/settings.rx | 4 +- eval/reference/desk/rx/comments.rx | 4 +- eval/reference/desk/rx/issues.rx | 14 +++--- eval/reference/desk/rx/settings.rx | 4 +- eval/reference/labels/rx/comments.rx | 4 +- eval/reference/labels/rx/issues.rx | 14 +++--- eval/reference/labels/rx/labels.rx | 8 ++-- eval/reference/labels/rx/settings.rx | 4 +- eval/reference/related/rx/comments.rx | 4 +- eval/reference/related/rx/issues.rx | 14 +++--- eval/reference/related/rx/settings.rx | 4 +- eval/reference/rename/rx/main.rx | 10 ++--- eval/reference/settings/rx/settings.rx | 4 +- eval/reference/tracker/rx/issues.rx | 4 +- examples/ai/feed.rx | 8 ++-- examples/ai2/settings/NOTES.md | 4 +- examples/ai2/settings/settings.rx | 4 +- examples/ai2/tracker/NOTES.md | 2 +- examples/ai2/tracker/issues.rx | 2 +- examples/cart.rx | 2 +- examples/notes/data.rx | 2 +- examples/shop/cart.rx | 2 +- examples/todos.rx | 12 ++--- examples/users.rx | 4 +- guide/async.md | 28 ++++++------ guide/diagnostics.md | 45 +++++++++++++++---- guide/reference.md | 4 +- ref/compile/rt-web.ts | 6 +-- ref/compile/rt.ts | 6 +-- ref/oracle/README.md | 6 +-- ref/oracle/lower.ts | 2 +- ref/src/check.ts | 8 ++-- ref/src/infer.ts | 2 +- ref/src/model.ts | 2 +- ref/src/parse.ts | 7 ++- ref/src/runtime.ts | 4 +- scenarios/S-action-1.md | 6 +-- scenarios/S-action-2.md | 2 +- scenarios/S-action-3.md | 2 +- scenarios/S-action-4.md | 2 +- scenarios/S-ai-feed.md | 4 +- scenarios/S-ai2-tracker.md | 2 +- scenarios/S-check-1.md | 8 ++-- scenarios/S-effect-store-1.md | 2 +- scenarios/S-guess-reask-refresh.md | 4 +- scenarios/S-infer-1.md | 12 ++--- scenarios/S-infer-2.md | 2 +- scenarios/S-infer-3.md | 6 +-- scenarios/S-infer-6.md | 2 +- scenarios/S-latest-reask.md | 2 +- scenarios/S-parse-hints.md | 8 ++++ scenarios/S-remount-refresh.md | 2 +- scenarios/S-solid-26.md | 2 +- scenarios/S-solid-32.md | 8 ++-- scenarios/S-solid-33.md | 2 +- scenarios/S-solid-34.md | 2 +- scenarios/S-solid-35.md | 6 +-- scenarios/S-solid-44.md | 2 +- scenarios/S-store-6.md | 10 ++--- scenarios/S-store-7.md | 2 +- scenarios/S-store-8.md | 8 ++-- spec/05-holds.md | 4 +- spec/06-stores.md | 24 +++++----- spec/07-surface.md | 22 ++++++--- spec/08-constraints.md | 4 +- spec/09-modules.md | 4 +- spec/11-compiler.md | 2 +- spec/99-diagnostics.md | 7 +-- tooling/vscode-rx/markdown.js | 2 +- tooling/vscode-rx/syntaxes/rx.tmLanguage.json | 2 +- 95 files changed, 337 insertions(+), 281 deletions(-) diff --git a/compiler/src/ast.rs b/compiler/src/ast.rs index 1c2828fe..46bcbe77 100644 --- a/compiler/src/ast.rs +++ b/compiler/src/ast.rs @@ -110,7 +110,7 @@ pub enum PathSeg { pub struct Target { pub name: String, pub path: Vec, - /// `use name := …` (RG-7, O-4). `None`: not written by this form (an `optimistic` target). + /// `use name := …` (RG-7, O-4). `None`: not written by this form (a `guess` target). pub ctx: Option, } impl Target { diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 1fe90f97..7a26a9a5 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -2262,9 +2262,9 @@ impl<'a> Checker<'a> { for f in run_findings(stmts, action, bound, inferred) { let n = &f.name; match f.code { - Code::HeldWrite => self.err("HELD_WRITE", "AC-1c", format!("{n} := … 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, `optimistic {n} := …` (a source with a question, ST-5c); for a reset that lands now, `onFlush(() => {{ {n} := … }})` (EF-8); to hold it on purpose, declare the action (`action name() {{ … }}`); or write it after the `await`")), + Code::HeldWrite => self.err("HELD_WRITE", "AC-1c", format!("{n} := … 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 {n} := …` (a source with a question, ST-5c); for a reset that lands now, `onFlush(() => {{ {n} := … }})` (EF-8); to hold it on purpose, declare the action (`action name() {{ … }}`); or write it after the `await`")), Code::StaleOwnRead => self.err("STALE_OWN_READ", "AC-1d", format!("{n} is read after this change wrote it: the read sees the committed value, not the write (W-4) — keep the value you wrote in a local (`let v = …; {n} := v`) and read that, or write functionally (`{n} := v => …`, W-6)")), - Code::StaleWriteBack => self.err("STALE_WRITE_BACK", "AC-1e", format!("{n} := … after an `await` is computed from {n} as it was read before that `await`: whatever changed {n} in between (another action, a reload) is overwritten — write functionally (`{n} := v => …`, W-6), which applies to {n} as it stands at the write, or guess it (`optimistic`, AC-3) and let the answer replace the guess")), + Code::StaleWriteBack => self.err("STALE_WRITE_BACK", "AC-1e", format!("{n} := … after an `await` is computed from {n} as it was read before that `await`: whatever changed {n} in between (another action, a reload) is overwritten — write functionally (`{n} := v => …`, W-6), which applies to {n} as it stands at the write, or guess it (`guess`, AC-3) and let the answer replace the guess")), } } } @@ -2351,12 +2351,12 @@ impl<'a> Checker<'a> { } if op == "optimistic" { if !ctx.in_action { - self.err("OPTIMISTIC_OUTSIDE_HOLD", "AC-3", "optimistic outside an action: a guess needs a hold to live in — an apply block is never an action (its return is the cleanup, EF-5): declare one, `action save(v) { optimistic … }`, and call it from the block".into()); + self.err("GUESS_OUTSIDE_HOLD", "AC-3", "a guess outside an action: it needs a hold to live in — an apply block is never an action (its return is the cleanup, EF-5): declare one, `action save(v) { guess … }`, and call it from the block".into()); } if let Some(t) = tsym { if self.syms[t].kind == SymKind::Source && !self.syms[t].question { self.err( - "OPTIMISTIC_NO_QUESTION", + "GUESS_NO_QUESTION", "AC-3", format!("{} is declared with := — a guess needs a question to confirm it (source {} = …, ST-5c)", target.name, target.name), ); diff --git a/compiler/src/infer.rs b/compiler/src/infer.rs index 6bbb076e..02413af7 100644 --- a/compiler/src/infer.rs +++ b/compiler/src/infer.rs @@ -18,7 +18,7 @@ pub enum Trigger { pub fn trigger_text(t: Trigger) -> &'static str { match t { Trigger::WriteAwait => "it writes and awaits", - Trigger::Optimistic => "it has `optimistic`", + Trigger::Optimistic => "it has `guess`", Trigger::Affects => "it has `affects`", } } diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index 3393da02..370feb38 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -42,7 +42,7 @@ re!(LINE_COMMENT, r"//.*$"); static KEYWORDS: LazyLock> = LazyLock::new(|| { [ - "source", "effect", "component", "ref", "await", "latest", "loading", "errored", "fallback", "on", "deferred", "action", "optimistic", "refresh", "affects", "if", "else", "pre", "peek", "fn", "let", "for", "in", "true", "false", "undefined", + "source", "effect", "component", "ref", "await", "latest", "loading", "errored", "fallback", "on", "deferred", "action", "guess", "optimistic", "refresh", "affects", "if", "else", "pre", "peek", "fn", "let", "for", "in", "true", "false", "undefined", "null", "provide", "context", "use", "import", "export", "from", "pure", "return", "type", "interface", "try", "catch", "finally", "throw", "portal", ] .into_iter() @@ -262,6 +262,7 @@ re!(JS_APIS, r"\b(useState|useReducer|useMemo|useCallback|useEffect|useRef|creat re!(JS_FUNCTION, r"^(export\s+)?(default\s+)?(async\s+)?function\b"); re!(JS_LOCAL, r"^(const|let|var)\b"); re!(JS_RETURN, r"^return\b"); +re!(OLD_OPTIMISTIC, r"(^|[{;]\s*)optimistic\s+[A-Za-z_$]"); re!(NAME_BEFORE, r"([A-Za-z_$][A-Za-z0-9_$.]*)\s*$"); re!(ARROW_BEFORE, r"=>\s*[A-Za-z_$][A-Za-z0-9_$.]*\s*$"); re!(OP_ASSIGN, r"^(\+\+|--|(\*\*|\|\||&&|\?\?|[-+*/%])=)"); @@ -278,6 +279,9 @@ pub fn hint(src: &str, i: usize, msg: &str) -> Option<(&'static str, String)> { let line = src[start..end].trim(); let before = &src[src[..i].rfind('\n').map(|j| j + 1).unwrap_or(0)..i]; let name = NAME_BEFORE.captures(before).map(|c| c[1].to_string()).unwrap_or_else(|| "x".into()); + if OLD_OPTIMISTIC.is_match(line) { + return Some(("OPTIMISTIC", "`optimistic` is spelled `guess`: `guess x := e` shows a guess until the action's answer replaces it (AC-3); it lowers to `createOptimistic`".into())); + } if msg.starts_with("W-2") && rest.starts_with(":=") && ARROW_BEFORE.is_match(before) { return Some(("REACTIVE_WRITE_IN_OWNED_SCOPE", format!("an arrow that writes takes a braced body, `() => {{ {} := … }}`: an expression-bodied arrow is a pure function (SC-3)", name))); } @@ -926,6 +930,9 @@ fn parse_stmt(c: &mut Cursor) -> R { c.ws(true); let start = c.i; if c.keyword("optimistic") { + return c.fail("AC-3: `optimistic` is not a statement"); + } + if c.keyword("guess") { c.ws(true); let name = c.ident_or("target")?; let path = parse_path(c)?; diff --git a/eval/README.md b/eval/README.md index 645cb461..ef04e4a6 100644 --- a/eval/README.md +++ b/eval/README.md @@ -86,7 +86,7 @@ predicted every frame right; the briefs here are stricter. - `tracker`: `assignToMe` cleared its error with a plain write (`assignError := ""`) inside an action that awaits, and a plain write in an async action is held until the action ends (AC-1), so the old error stayed up until the server answered. The fix is the `settings` app's own idiom: `assignError` a question source, - `optimistic assignError := ""`. + `guess assignError := ""`. ## Layout diff --git a/eval/reference/chain1/rx/comments.rx b/eval/reference/chain1/rx/comments.rx index 3e9b7b2b..5476dd55 100644 --- a/eval/reference/chain1/rx/comments.rx +++ b/eval/reference/chain1/rx/comments.rx @@ -15,9 +15,9 @@ export component Comments(props: { id: number; user: string }) { effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - optimistic postError := null + guess postError := null try { - optimistic sending := [...sending, { id: id, author: author, text: text }] + guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) refresh comments } catch (e) { diff --git a/eval/reference/chain1/rx/issues.rx b/eval/reference/chain1/rx/issues.rx index 73d5be5a..2579778c 100644 --- a/eval/reference/chain1/rx/issues.rx +++ b/eval/reference/chain1/rx/issues.rx @@ -21,10 +21,10 @@ export component IssuesPage() { source closeError: string = "" action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = issues.findIndex(t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { @@ -33,14 +33,14 @@ export component IssuesPage() { } action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -51,9 +51,9 @@ export component IssuesPage() { } action closeOne(id: number) { - optimistic closeError := "" + guess closeError := "" try { - optimistic issues := issues.filter(t => t.id != id) + guess issues := issues.filter(t => t.id != id) await closeIssue(id) refresh issues } catch (e) { diff --git a/eval/reference/chain1/rx/labels.rx b/eval/reference/chain1/rx/labels.rx index f1c9c11f..e37c0edc 100644 --- a/eval/reference/chain1/rx/labels.rx +++ b/eval/reference/chain1/rx/labels.rx @@ -19,9 +19,9 @@ export component Labels(props: { id: number }) { effect (props.id) { labelError := "" } action add(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic adding := [...adding, { id: id, label: label }] + guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) refresh labels } catch (e) { @@ -30,9 +30,9 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic removing := [...removing, { id: id, label: label }] + guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) refresh labels } catch (e) { diff --git a/eval/reference/chain1/rx/settings.rx b/eval/reference/chain1/rx/settings.rx index 0df82318..6c45198c 100644 --- a/eval/reference/chain1/rx/settings.rx +++ b/eval/reference/chain1/rx/settings.rx @@ -16,8 +16,8 @@ export component SettingsPage() { source saveError: string = "" action save(next: Settings) { - optimistic settings := next - optimistic saveError := "" + guess settings := next + guess saveError := "" affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/chain2/rx/comments.rx b/eval/reference/chain2/rx/comments.rx index 3e9b7b2b..5476dd55 100644 --- a/eval/reference/chain2/rx/comments.rx +++ b/eval/reference/chain2/rx/comments.rx @@ -15,9 +15,9 @@ export component Comments(props: { id: number; user: string }) { effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - optimistic postError := null + guess postError := null try { - optimistic sending := [...sending, { id: id, author: author, text: text }] + guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) refresh comments } catch (e) { diff --git a/eval/reference/chain2/rx/issues.rx b/eval/reference/chain2/rx/issues.rx index c59ae010..300add69 100644 --- a/eval/reference/chain2/rx/issues.rx +++ b/eval/reference/chain2/rx/issues.rx @@ -30,10 +30,10 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = issues.findIndex(t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { @@ -42,14 +42,14 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { } action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -60,9 +60,9 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { } action closeOne(id: number) { - optimistic closeError := "" + guess closeError := "" try { - optimistic issues := issues.filter(t => t.id != id) + guess issues := issues.filter(t => t.id != id) await closeIssue(id) refresh issues } catch (e) { diff --git a/eval/reference/chain2/rx/labels.rx b/eval/reference/chain2/rx/labels.rx index f1c9c11f..e37c0edc 100644 --- a/eval/reference/chain2/rx/labels.rx +++ b/eval/reference/chain2/rx/labels.rx @@ -19,9 +19,9 @@ export component Labels(props: { id: number }) { effect (props.id) { labelError := "" } action add(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic adding := [...adding, { id: id, label: label }] + guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) refresh labels } catch (e) { @@ -30,9 +30,9 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic removing := [...removing, { id: id, label: label }] + guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) refresh labels } catch (e) { diff --git a/eval/reference/chain2/rx/settings.rx b/eval/reference/chain2/rx/settings.rx index 0df82318..6c45198c 100644 --- a/eval/reference/chain2/rx/settings.rx +++ b/eval/reference/chain2/rx/settings.rx @@ -16,8 +16,8 @@ export component SettingsPage() { source saveError: string = "" action save(next: Settings) { - optimistic settings := next - optimistic saveError := "" + guess settings := next + guess saveError := "" affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/chain3/rx/comments.rx b/eval/reference/chain3/rx/comments.rx index 3e9b7b2b..5476dd55 100644 --- a/eval/reference/chain3/rx/comments.rx +++ b/eval/reference/chain3/rx/comments.rx @@ -15,9 +15,9 @@ export component Comments(props: { id: number; user: string }) { effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - optimistic postError := null + guess postError := null try { - optimistic sending := [...sending, { id: id, author: author, text: text }] + guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) refresh comments } catch (e) { diff --git a/eval/reference/chain3/rx/issues.rx b/eval/reference/chain3/rx/issues.rx index 728712ef..90d04f88 100644 --- a/eval/reference/chain3/rx/issues.rx +++ b/eval/reference/chain3/rx/issues.rx @@ -31,10 +31,10 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = issues.findIndex(t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { @@ -43,14 +43,14 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel } action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -61,9 +61,9 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel } action closeOne(id: number) { - optimistic closeError := "" + guess closeError := "" try { - optimistic issues := issues.filter(t => t.id != id) + guess issues := issues.filter(t => t.id != id) await closeIssue(id) refresh issues } catch (e) { diff --git a/eval/reference/chain3/rx/labels.rx b/eval/reference/chain3/rx/labels.rx index f1c9c11f..e37c0edc 100644 --- a/eval/reference/chain3/rx/labels.rx +++ b/eval/reference/chain3/rx/labels.rx @@ -19,9 +19,9 @@ export component Labels(props: { id: number }) { effect (props.id) { labelError := "" } action add(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic adding := [...adding, { id: id, label: label }] + guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) refresh labels } catch (e) { @@ -30,9 +30,9 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic removing := [...removing, { id: id, label: label }] + guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) refresh labels } catch (e) { diff --git a/eval/reference/chain3/rx/settings.rx b/eval/reference/chain3/rx/settings.rx index 0df82318..6c45198c 100644 --- a/eval/reference/chain3/rx/settings.rx +++ b/eval/reference/chain3/rx/settings.rx @@ -16,8 +16,8 @@ export component SettingsPage() { source saveError: string = "" action save(next: Settings) { - optimistic settings := next - optimistic saveError := "" + guess settings := next + guess saveError := "" affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/chain4/rx/comments.rx b/eval/reference/chain4/rx/comments.rx index 3e9b7b2b..5476dd55 100644 --- a/eval/reference/chain4/rx/comments.rx +++ b/eval/reference/chain4/rx/comments.rx @@ -15,9 +15,9 @@ export component Comments(props: { id: number; user: string }) { effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - optimistic postError := null + guess postError := null try { - optimistic sending := [...sending, { id: id, author: author, text: text }] + guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) refresh comments } catch (e) { diff --git a/eval/reference/chain4/rx/issues.rx b/eval/reference/chain4/rx/issues.rx index 51d7dc72..488ee123 100644 --- a/eval/reference/chain4/rx/issues.rx +++ b/eval/reference/chain4/rx/issues.rx @@ -34,13 +34,13 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel action assignTo(id: number, who: string, fromRow: boolean) { if (fromRow) { - optimistic rowError := "" + guess rowError := "" } else { - optimistic assignError := "" + guess assignError := "" } try { let i = issues.findIndex(t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { @@ -53,14 +53,14 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel } action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -72,12 +72,12 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel action closeOne(id: number, fromRow: boolean) { if (fromRow) { - optimistic rowError := "" + guess rowError := "" } else { - optimistic closeError := "" + guess closeError := "" } try { - optimistic issues := issues.filter(t => t.id != id) + guess issues := issues.filter(t => t.id != id) await closeIssue(id) refresh issues } catch (e) { diff --git a/eval/reference/chain4/rx/labels.rx b/eval/reference/chain4/rx/labels.rx index f1c9c11f..e37c0edc 100644 --- a/eval/reference/chain4/rx/labels.rx +++ b/eval/reference/chain4/rx/labels.rx @@ -19,9 +19,9 @@ export component Labels(props: { id: number }) { effect (props.id) { labelError := "" } action add(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic adding := [...adding, { id: id, label: label }] + guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) refresh labels } catch (e) { @@ -30,9 +30,9 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic removing := [...removing, { id: id, label: label }] + guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) refresh labels } catch (e) { diff --git a/eval/reference/chain4/rx/settings.rx b/eval/reference/chain4/rx/settings.rx index 0df82318..6c45198c 100644 --- a/eval/reference/chain4/rx/settings.rx +++ b/eval/reference/chain4/rx/settings.rx @@ -16,8 +16,8 @@ export component SettingsPage() { source saveError: string = "" action save(next: Settings) { - optimistic settings := next - optimistic saveError := "" + guess settings := next + guess saveError := "" affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/close/rx/main.rx b/eval/reference/close/rx/main.rx index 7768b2cc..1f04fbb0 100644 --- a/eval/reference/close/rx/main.rx +++ b/eval/reference/close/rx/main.rx @@ -14,9 +14,9 @@ export default component App() { source assignError: string = "" source closeError: string = "" action closeOne(id: number) { - optimistic closeError := "" + guess closeError := "" try { - optimistic issues := keep(issues, t => t.id != id) + guess issues := keep(issues, t => t.id != id) await closeIssue(id) refresh issues } catch (e) { @@ -25,10 +25,10 @@ export default component App() { } action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = indexOf(issues, t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { diff --git a/eval/reference/comments/rx/main.rx b/eval/reference/comments/rx/main.rx index a48b9b88..85d7afb3 100644 --- a/eval/reference/comments/rx/main.rx +++ b/eval/reference/comments/rx/main.rx @@ -17,9 +17,9 @@ export default component App() { source comments = selected == null ? [] : await fetchComments(selected) action postComment(id: number, author: string, text: string) { - optimistic postError := "" + guess postError := "" try { - optimistic comments := [...comments, { author: author, text: `${text} (sending…)` }] + guess comments := [...comments, { author: author, text: `${text} (sending…)` }] await addComment(id, author, text) refresh comments } catch (e) { @@ -29,10 +29,10 @@ export default component App() { action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = indexOf(issues, t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { diff --git a/eval/reference/count/rx/comments.rx b/eval/reference/count/rx/comments.rx index 3e9b7b2b..5476dd55 100644 --- a/eval/reference/count/rx/comments.rx +++ b/eval/reference/count/rx/comments.rx @@ -15,9 +15,9 @@ export component Comments(props: { id: number; user: string }) { effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - optimistic postError := null + guess postError := null try { - optimistic sending := [...sending, { id: id, author: author, text: text }] + guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) refresh comments } catch (e) { diff --git a/eval/reference/count/rx/issues.rx b/eval/reference/count/rx/issues.rx index 4e32d57a..5825e67e 100644 --- a/eval/reference/count/rx/issues.rx +++ b/eval/reference/count/rx/issues.rx @@ -29,10 +29,10 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = issues.findIndex(t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { @@ -41,14 +41,14 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { } action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -59,9 +59,9 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { } action closeOne(id: number) { - optimistic closeError := "" + guess closeError := "" try { - optimistic issues := issues.filter(t => t.id != id) + guess issues := issues.filter(t => t.id != id) await closeIssue(id) refresh issues } catch (e) { diff --git a/eval/reference/count/rx/settings.rx b/eval/reference/count/rx/settings.rx index 0df82318..6c45198c 100644 --- a/eval/reference/count/rx/settings.rx +++ b/eval/reference/count/rx/settings.rx @@ -16,8 +16,8 @@ export component SettingsPage() { source saveError: string = "" action save(next: Settings) { - optimistic settings := next - optimistic saveError := "" + guess settings := next + guess saveError := "" affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/desk/rx/comments.rx b/eval/reference/desk/rx/comments.rx index 3e9b7b2b..5476dd55 100644 --- a/eval/reference/desk/rx/comments.rx +++ b/eval/reference/desk/rx/comments.rx @@ -15,9 +15,9 @@ export component Comments(props: { id: number; user: string }) { effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - optimistic postError := null + guess postError := null try { - optimistic sending := [...sending, { id: id, author: author, text: text }] + guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) refresh comments } catch (e) { diff --git a/eval/reference/desk/rx/issues.rx b/eval/reference/desk/rx/issues.rx index 35ab63fe..36fc5323 100644 --- a/eval/reference/desk/rx/issues.rx +++ b/eval/reference/desk/rx/issues.rx @@ -20,10 +20,10 @@ export component IssuesPage() { source closeError: string = "" action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = issues.findIndex(t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { @@ -32,14 +32,14 @@ export component IssuesPage() { } action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -50,9 +50,9 @@ export component IssuesPage() { } action closeOne(id: number) { - optimistic closeError := "" + guess closeError := "" try { - optimistic issues := issues.filter(t => t.id != id) + guess issues := issues.filter(t => t.id != id) await closeIssue(id) refresh issues } catch (e) { diff --git a/eval/reference/desk/rx/settings.rx b/eval/reference/desk/rx/settings.rx index 0df82318..6c45198c 100644 --- a/eval/reference/desk/rx/settings.rx +++ b/eval/reference/desk/rx/settings.rx @@ -16,8 +16,8 @@ export component SettingsPage() { source saveError: string = "" action save(next: Settings) { - optimistic settings := next - optimistic saveError := "" + guess settings := next + guess saveError := "" affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/labels/rx/comments.rx b/eval/reference/labels/rx/comments.rx index 3e9b7b2b..5476dd55 100644 --- a/eval/reference/labels/rx/comments.rx +++ b/eval/reference/labels/rx/comments.rx @@ -15,9 +15,9 @@ export component Comments(props: { id: number; user: string }) { effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - optimistic postError := null + guess postError := null try { - optimistic sending := [...sending, { id: id, author: author, text: text }] + guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) refresh comments } catch (e) { diff --git a/eval/reference/labels/rx/issues.rx b/eval/reference/labels/rx/issues.rx index 73d5be5a..2579778c 100644 --- a/eval/reference/labels/rx/issues.rx +++ b/eval/reference/labels/rx/issues.rx @@ -21,10 +21,10 @@ export component IssuesPage() { source closeError: string = "" action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = issues.findIndex(t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { @@ -33,14 +33,14 @@ export component IssuesPage() { } action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -51,9 +51,9 @@ export component IssuesPage() { } action closeOne(id: number) { - optimistic closeError := "" + guess closeError := "" try { - optimistic issues := issues.filter(t => t.id != id) + guess issues := issues.filter(t => t.id != id) await closeIssue(id) refresh issues } catch (e) { diff --git a/eval/reference/labels/rx/labels.rx b/eval/reference/labels/rx/labels.rx index f1c9c11f..e37c0edc 100644 --- a/eval/reference/labels/rx/labels.rx +++ b/eval/reference/labels/rx/labels.rx @@ -19,9 +19,9 @@ export component Labels(props: { id: number }) { effect (props.id) { labelError := "" } action add(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic adding := [...adding, { id: id, label: label }] + guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) refresh labels } catch (e) { @@ -30,9 +30,9 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - optimistic labelError := "" + guess labelError := "" try { - optimistic removing := [...removing, { id: id, label: label }] + guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) refresh labels } catch (e) { diff --git a/eval/reference/labels/rx/settings.rx b/eval/reference/labels/rx/settings.rx index 0df82318..6c45198c 100644 --- a/eval/reference/labels/rx/settings.rx +++ b/eval/reference/labels/rx/settings.rx @@ -16,8 +16,8 @@ export component SettingsPage() { source saveError: string = "" action save(next: Settings) { - optimistic settings := next - optimistic saveError := "" + guess settings := next + guess saveError := "" affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/related/rx/comments.rx b/eval/reference/related/rx/comments.rx index 3e9b7b2b..5476dd55 100644 --- a/eval/reference/related/rx/comments.rx +++ b/eval/reference/related/rx/comments.rx @@ -15,9 +15,9 @@ export component Comments(props: { id: number; user: string }) { effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - optimistic postError := null + guess postError := null try { - optimistic sending := [...sending, { id: id, author: author, text: text }] + guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) refresh comments } catch (e) { diff --git a/eval/reference/related/rx/issues.rx b/eval/reference/related/rx/issues.rx index 7cd65f6f..878833c3 100644 --- a/eval/reference/related/rx/issues.rx +++ b/eval/reference/related/rx/issues.rx @@ -21,10 +21,10 @@ export component IssuesPage(props: { onOpenHelp: (query: string, id: number) => source closeError: string = "" action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = issues.findIndex(t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { @@ -33,14 +33,14 @@ export component IssuesPage(props: { onOpenHelp: (query: string, id: number) => } action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -51,9 +51,9 @@ export component IssuesPage(props: { onOpenHelp: (query: string, id: number) => } action closeOne(id: number) { - optimistic closeError := "" + guess closeError := "" try { - optimistic issues := issues.filter(t => t.id != id) + guess issues := issues.filter(t => t.id != id) await closeIssue(id) refresh issues } catch (e) { diff --git a/eval/reference/related/rx/settings.rx b/eval/reference/related/rx/settings.rx index 0df82318..6c45198c 100644 --- a/eval/reference/related/rx/settings.rx +++ b/eval/reference/related/rx/settings.rx @@ -16,8 +16,8 @@ export component SettingsPage() { source saveError: string = "" action save(next: Settings) { - optimistic settings := next - optimistic saveError := "" + guess settings := next + guess saveError := "" affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/rename/rx/main.rx b/eval/reference/rename/rx/main.rx index a3f53de2..8bf3450f 100644 --- a/eval/reference/rename/rx/main.rx +++ b/eval/reference/rename/rx/main.rx @@ -17,14 +17,14 @@ export default component App() { source detail = selected == null ? null : found(await fetchIssue(selected), selected) action renameTo(id: number, title: string) { - optimistic renameError := "" + guess renameError := "" try { let i = indexOf(issues, t => t.id == id) if (i >= 0) { - optimistic issues[i].title := title + guess issues[i].title := title } if (detail != null && detail.id == id) { - optimistic detail.title := title + guess detail.title := title } await renameIssue(id, title) refresh issues @@ -35,10 +35,10 @@ export default component App() { } action assignTo(id: number, who: string) { - optimistic assignError := "" + guess assignError := "" try { let i = indexOf(issues, t => t.id == id) - optimistic issues[i].assignee := who + guess issues[i].assignee := who await assign(id, who) refresh issues } catch (e) { diff --git a/eval/reference/settings/rx/settings.rx b/eval/reference/settings/rx/settings.rx index 6a834e8c..f57f9cab 100644 --- a/eval/reference/settings/rx/settings.rx +++ b/eval/reference/settings/rx/settings.rx @@ -29,8 +29,8 @@ export action retryLoad() { } export action save(next: Settings) { - optimistic settings := next // the page shows what is being saved, now (H-11) - optimistic saveError := "" // and stops showing the previous failure + guess settings := next // the page shows what is being saved, now (H-11) + guess saveError := "" // and stops showing the previous failure affects settings // `pending? settings` is true until the save ends (AC-5) try { let saved = await saveSettings(next) diff --git a/eval/reference/tracker/rx/issues.rx b/eval/reference/tracker/rx/issues.rx index 298bb2a2..61cb3b84 100644 --- a/eval/reference/tracker/rx/issues.rx +++ b/eval/reference/tracker/rx/issues.rx @@ -42,8 +42,8 @@ export action select(id: number) { } export action assignToMe(id: number, user: string) { - optimistic assignError := "" - optimistic issues := list => withAssignee(list, id, user) + guess assignError := "" + guess issues := list => withAssignee(list, id, user) try { await assign(id, user) issues := list => withAssignee(list, id, user) diff --git a/examples/ai/feed.rx b/examples/ai/feed.rx index be7e5eb3..5079941d 100644 --- a/examples/ai/feed.rx +++ b/examples/ai/feed.rx @@ -22,7 +22,7 @@ source messages: Message[] = await* streamMessages(selected) -> [] // ST-5 + S msgs key id = messages // ST-4: each message keyed by id source draftText = "" // ST-5a: a constant question — provisional UI state a guess can clear -source sending = false // same shape: `optimistic sending := true` reverts when the hold ends +source sending = false // same shape: `guess sending := true` reverts when the hold ends source error := "" effect (len(msgs)) { log("count", $0) } // EF: runs when the committed count changes @@ -33,9 +33,9 @@ action send() { let text = draftText // snapshots (SC-2) let room = selected let msg = { id: len(messages) + 1, from: "me", text } - optimistic draftText := "" // AC-3: the guesses show now, display-ahead - optimistic sending := true - optimistic messages := m => [...m, msg] // W-6: a functional guess appends + guess draftText := "" // AC-3: the guesses show now, display-ahead + guess sending := true + guess messages := m => [...m, msg] // W-6: a functional guess appends await sendMessage(room, text) // AC-1: a hold-safe suspension draftText := "" // the truth, after the await (07 §5) } catch (e) { diff --git a/examples/ai2/settings/NOTES.md b/examples/ai2/settings/NOTES.md index a18ce4ae..e13e6ac1 100644 --- a/examples/ai2/settings/NOTES.md +++ b/examples/ai2/settings/NOTES.md @@ -34,7 +34,7 @@ Modules: `theme.rx` (contexts only) ← `settings.rx` (server truth + actions), frames would read `compactProfile [compact]Display name…`. A real page would not have these. - **`saveError` needs both a guess and a plain write on success.** I wrote - `optimistic saveError := ""` so a new save clears the old message at once. + `guess saveError := ""` so a new save clears the old message at once. But the source's truth is the last *plain* write (`"quota exceeded"`), so without `saveError := ""` after a successful `await`, the guess would revert and the old error would come back. It is correct per H-11, and easy to get @@ -85,7 +85,7 @@ Modules: `theme.rx` (contexts only) ← `settings.rx` (server truth + actions), a value replaces the error". I assumed that happens automatically when the refreshed flight lands, even though the content readers were behind the fallback (S-solid-42 is cited for this). -5. **A guess closing an `if` branch** (`optimistic saveError := ""` hiding +5. **A guess closing an `if` branch** (`guess saveError := ""` hiding "Save failed" at once). H-7 says "a branch a lane condition opens is a mainline mount and attaches now". It does not explicitly say the same for a branch a lane condition *closes*. I assumed it is symmetric. diff --git a/examples/ai2/settings/settings.rx b/examples/ai2/settings/settings.rx index 6a834e8c..f57f9cab 100644 --- a/examples/ai2/settings/settings.rx +++ b/examples/ai2/settings/settings.rx @@ -29,8 +29,8 @@ export action retryLoad() { } export action save(next: Settings) { - optimistic settings := next // the page shows what is being saved, now (H-11) - optimistic saveError := "" // and stops showing the previous failure + guess settings := next // the page shows what is being saved, now (H-11) + guess saveError := "" // and stops showing the previous failure affects settings // `pending? settings` is true until the save ends (AC-5) try { let saved = await saveSettings(next) diff --git a/examples/ai2/tracker/NOTES.md b/examples/ai2/tracker/NOTES.md index ab99f09b..5b1747b6 100644 --- a/examples/ai2/tracker/NOTES.md +++ b/examples/ai2/tracker/NOTES.md @@ -40,7 +40,7 @@ the list, and actions as the only writers. read (see Ambiguities 1). I'd want either a sync action to be stated to be an ordinary instant, or a lighter exported-setter form. - **The truth after a successful assign repeats the guess.** - `optimistic issues := f` followed later by `issues := f` with the same `f`. + `guess issues := f` followed later by `issues := f` with the same `f`. H-11 makes this necessary: without the plain write, the guess is "corrected by the truth at hand" when the body ends, which reverts a successful assignment. The ideal is `refresh issues` (the server is the truth), but diff --git a/examples/ai2/tracker/issues.rx b/examples/ai2/tracker/issues.rx index f0cdbd7d..30e051b8 100644 --- a/examples/ai2/tracker/issues.rx +++ b/examples/ai2/tracker/issues.rx @@ -41,7 +41,7 @@ export action select(id: number) { export action assignToMe(id: number, user: string) { assignError := "" - optimistic issues := list => withAssignee(list, id, user) + guess issues := list => withAssignee(list, id, user) try { await assign(id, user) issues := list => withAssignee(list, id, user) diff --git a/examples/cart.rx b/examples/cart.rx index 2346c366..daf070bb 100644 --- a/examples/cart.rx +++ b/examples/cart.rx @@ -33,7 +33,7 @@ source orderError: string := "" action placeOrder() { if (count === 0) return let items = cart // SC-2: a snapshot of the lines being ordered - optimistic cart := [] // AC-3: the cart empties now, display-ahead + guess cart := [] // AC-3: the cart empties now, display-ahead affects cart // AC-5: `pending? cart` while the order is in flight orderError := "" try { diff --git a/examples/notes/data.rx b/examples/notes/data.rx index cade54c0..bc8ebc31 100644 --- a/examples/notes/data.rx +++ b/examples/notes/data.rx @@ -34,7 +34,7 @@ export action open(id: number) { route := { page: "note", id: id }; saveError := export action back() { route := { page: "list" } } export action save(id: number, title: string, body: string) { saveError := "" - optimistic notes := ns => ns.map(n => n.id == id ? { ...n, title: title, body: body } : n) + guess notes := ns => ns.map(n => n.id == id ? { ...n, title: title, body: body } : n) try { try { let saved = await saveNote(id, title, body) diff --git a/examples/shop/cart.rx b/examples/shop/cart.rx index 57d5a988..d2b0d3d0 100644 --- a/examples/shop/cart.rx +++ b/examples/shop/cart.rx @@ -25,7 +25,7 @@ export action setQty(id: number, n: number) { export action placeOrder() { if (count === 0) return let items = cart // SC-2: a snapshot of the lines being ordered - optimistic cart := [] // AC-3: the cart empties now, display-ahead + guess cart := [] // AC-3: the cart empties now, display-ahead affects cart // AC-5: `pending? cart` while the order is in flight orderError := "" try { diff --git a/examples/todos.rx b/examples/todos.rx index e9d52c8b..856af618 100644 --- a/examples/todos.rx +++ b/examples/todos.rx @@ -1,7 +1,7 @@ // TodoMVC against a server, built in layers (solid next's examples/todos). Each layer has its own lifetime and is // derived from the one below it; the screen reads only the top one. // -// 3. guesses `optimistic` writes on `todos` rows and on `adding`: shown now, gone when their action ends (AC-3) +// 3. guesses `guess` writes on `todos` rows and on `adding`: shown now, gone when their action ends (AC-3) // 2. client `todos`: what the client owns over (1), here the failures awaiting a retry; the client writes it and // a new answer from (1) re-derives it (ST-5a). The failures themselves outlive that, so they are a // plain source `todos` is derived from @@ -43,7 +43,7 @@ anyDone = live.some(t => t.done) // guess. A new row has no index yet, so an add guesses `adding`, a list of its own. action add(id: string, title: string) { try { - optimistic adding := as => [...as, { id: id, title: title, done: false, gone: false, saving: true, failure: null }] + guess adding := as => [...as, { id: id, title: title, done: false, gone: false, saving: true, failure: null }] await addTodo(id, title) failures := fs => without(fs, id) } catch (e) { @@ -54,8 +54,8 @@ action add(id: string, title: string) { action toggle(id: string, done: boolean) { try { let i = todos.findIndex(t => t.id === id) - optimistic todos[i].done := done - optimistic todos[i].saving := true + guess todos[i].done := done + guess todos[i].saving := true await toggleTodo(id, done) failures := fs => without(fs, id) } catch (e) { @@ -66,7 +66,7 @@ action toggle(id: string, done: boolean) { action remove(id: string) { try { let i = todos.findIndex(t => t.id === id) - optimistic todos[i].gone := true + guess todos[i].gone := true await removeTodo(id) failures := fs => without(fs, id) } catch (e) { @@ -77,7 +77,7 @@ action remove(id: string) { // A bulk write that fails fans out to one failure per row, so each row gets its own retry. action clearDone(ids: string[]) { try { - optimistic todos := ts => ts.map(t => t.done ? { ...t, gone: true } : t) + guess todos := ts => ts.map(t => t.done ? { ...t, gone: true } : t) await removeTodos(ids) failures := fs => fs.filter(f => !ids.includes(f.id)) } catch (e) { diff --git a/examples/users.rx b/examples/users.rx index 35a594b0..2ac1cbd9 100644 --- a/examples/users.rx +++ b/examples/users.rx @@ -16,8 +16,8 @@ total = len(users) status = pending? users ? "loading…" : `${total} users` action rename(id: number, name: string) { - optimistic users := us => us.map(u => u.id === id ? { ...u, name } : u) // AC-3: shown now, verdict-inert - optimistic editing := null // the editor closes now; the plain write below lands at the commit + guess users := us => us.map(u => u.id === id ? { ...u, name } : u) // AC-3: shown now, verdict-inert + guess editing := null // the editor closes now; the plain write below lands at the commit editing := null saveError := "" try { diff --git a/guide/async.md b/guide/async.md index 03fe5f35..08f981e0 100644 --- a/guide/async.md +++ b/guide/async.md @@ -64,8 +64,8 @@ export source profile: Profile = await loadProfile() // a source with a questi export source saveError: string = "" export action save(next: Profile) { - optimistic profile := next // shows now; reverts by itself if the save fails - optimistic saveError := "" // the old error goes now + guess profile := next // shows now; reverts by itself if the save fails + guess saveError := "" // the old error goes now affects profile // `pending? profile` is true until the save ends try { let answer = await saveProfile(next) @@ -76,7 +76,7 @@ export action save(next: Profile) { } ``` -A handler can be the action itself, with no keyword: an arrow that writes `optimistic`, declares `affects`, or both +A handler can be the action itself, with no keyword: an arrow that writes `guess`, declares `affects`, or both writes and awaits is an action (AC-1b). Declare a named `action` when you call it from more than one place. An action's body is **one change**: every plain write in it, before or after an `await`, is held until the body @@ -85,7 +85,7 @@ by what the screen should do: | You want | Write | | --- | --- | -| Show a value now, and take it back if the action fails | `optimistic x := v` (x is a source with a question: `source x = …`) | +| Show a value now, and take it back if the action fails | `guess x := v` (x is a source with a question: `source x = …`) | | `pending? x` true while the action runs | `affects x` | | Land a UI write now and keep it (close a menu, clear a message) | `onFlush(() => { x := v })` (EF-8) | | Record the server's answer | `x := answer` after the `await` | @@ -101,7 +101,7 @@ export source todos: Todo[] key id = await loadTodos() export action markDone(id: number) { try { let i = todos.findIndex(t => t.id == id) // -1 when absent - optimistic todos[i].done := true // this row only + guess todos[i].done := true // this row only await saveDone(id) refresh todos // the server's list confirms the guess } catch (e) { … } @@ -121,12 +121,12 @@ flight, everything shown from `todos` waits for that action to end, including an The screen is never wrong in between: it shows the guesses, then the truth, in one frame. This is the design (H-11), not a bug, and moving the guess to a source of its own doesn't change it: the rows that show it read `todos`. -A guess on a whole list (`optimistic adding := as => [...as, x]`) is one change with every other guess on that list: +A guess on a whole list (`guess adding := as => [...as, x]`) is one change with every other guess on that list: two adds in flight end together, so a declined one stays until the other ends. That's the same rule, not something to work around. - **Show that something is in flight**, so the longer wait isn't silent: a row flag set by the guess - (`optimistic todos[i].saving := true`), or `pending? todos` with `affects todos`. + (`guess todos[i].saving := true`), or `pending? todos` with `affects todos`. - **Don't rebuild guesses by hand** (a plain list of pending ids with your own revert) to end them one by one: it loses the revert and the hold that keep the screen consistent. @@ -144,7 +144,7 @@ shownAdding = adding.filter(a => a.photo == photo) // shown after `tags` export action addTag(p: number, tag: string) { try { - optimistic adding := [...adding, { photo: p, tag: tag }] + guess adding := [...adding, { photo: p, tag: tag }] await saveTag(p, tag) refresh tags // the server's list now has the tag; the guess goes with the action } catch (e) { … } @@ -165,7 +165,7 @@ and the screen reads only the top: (ST-5a). The client writes it (an edit, a draft, a flag, a failure to show), and a new answer from below resets it. Anything that must outlive that reset (in the todos app, the failures awaiting a retry) is a plain source this layer is derived from. -3. **Guesses.** `optimistic` writes on layer 2, made by an action and gone when the action ends. +3. **Guesses.** `guess` writes on layer 2, made by an action and gone when the action ends. `examples/todos.rx` is the whole app: @@ -177,7 +177,7 @@ source adding: Row[] key id = [] // 3. adds in flight export action add(id: string, title: string) { try { - optimistic adding := as => [...as, { id: id, title: title, saving: true }] + guess adding := as => [...as, { id: id, title: title, saving: true }] await addTodo(id, title) failures := fs => without(fs, id) } catch (e) { @@ -198,7 +198,7 @@ export action add(id: string, title: string) { ## 4. Things that look like bugs and aren't -- **A plain write in an action doesn't show until the server answers.** That's the hold. Use `optimistic` or +- **A plain write in an action doesn't show until the server answers.** That's the hold. Use `guess` or `onFlush` (the table above). Don't move the code into a plain handler to escape it. - **A declined row guess stays until another action's re-ask lands.** While an action's `refresh` of the list is in flight, the list's rows wait for that action (H-11), declined guesses included. That's by design; see "When @@ -210,7 +210,7 @@ export action add(id: string, title: string) { `loading on(k)` above the `errored`. - **The server accepted, and the screen went back to the old value.** The action guessed on one question and refreshed another, or none. A guess ends with its action; only a new answer for the question it guessed on shows - what the server took. Refresh every question you guessed on (`optimistic detail.title := t` needs `refresh detail`, + what the server took. Refresh every question you guessed on (`guess detail.title := t` needs `refresh detail`, not only `refresh issues`). - **A write landed and nothing showed it until the reload answered.** A guess needs a value to guess on. A rename sent while the detail is still loading, or for a row the list does not show yet (`findIndex` gave -1), guesses nothing, @@ -220,7 +220,7 @@ export action add(id: string, title: string) { ## 5. Plain handlers A handler that writes and awaits is an action (AC-1b), so it never lands a write early by accident. A write before -its `await` is a compile error (`HELD_WRITE`), because it would wait for the server: make it `optimistic` if it shows +its `await` is a compile error (`HELD_WRITE`), because it would wait for the server: make it `guess` if it shows while the work runs, `onFlush` if it is a reset, or declare the action if it should wait. Plain handlers are for local state: typing, toggles, selection. @@ -239,7 +239,7 @@ a value; an effect is for sending a value somewhere else (a parent's `on…` han the block does not run until the answer lands (R-2). The effect doesn't hold the screen while it waits. - **Failing inputs**: give an input that can fail a `catch` (`effect (x catch (e) => fallback) { … }`), so the block gets a plain value it can test for. -- **Guesses**: an input computed from an `optimistic` write fires as soon as the guess shows, and again with the old +- **Guesses**: an input computed from a `guess` write fires as soon as the guess shows, and again with the old value if the guess reverts (EF-2). It doesn't wait for the action to end. - **Lifetime**: an effect lives as long as its component. When the component goes away the effect stops, and its cleanup (a function the block returns) runs; nothing it would have seen later reaches it. State that must outlive diff --git a/guide/diagnostics.md b/guide/diagnostics.md index 1e0b26d3..3b8177c5 100644 --- a/guide/diagnostics.md +++ b/guide/diagnostics.md @@ -97,6 +97,33 @@ export default component App() { } ``` +### OPTIMISTIC + +A guess is written `guess x := e` (AC-3). The keyword was `optimistic` before 0.2; it lowers to the same +`createOptimistic`, and shows until the action's answer replaces it. + +```rx before +export default component App() { + source liked = false + action like() { + optimistic liked := true + refresh liked + } + +} +``` + +```rx after +export default component App() { + source liked = false + action like() { + guess liked := true + refresh liked + } + +} +``` + ### ASSIGNMENT A source is written with `:=` as a statement, in a handler or an action (W-1). There is no `=`, `+=` or `++` on state. @@ -214,7 +241,7 @@ export default component App() { A write after an `await` that is computed from a read before it overwrites whatever changed the source in between (another action, a reload). Write functionally, so the write applies to the value as it stands (W-6), or guess it with -`optimistic` and let the answer replace the guess (AC-3). +`guess` and let the answer replace the guess (AC-3). ```rx before export default component App() { @@ -379,7 +406,7 @@ export default component App() { ### HELD_WRITE A handler that writes and awaits is an action (AC-1b), and an action's writes land when its body ends (AC-1). So a -write before the `await` does not show while the work runs. Say which you meant: `optimistic x := …` for state that +write before the `await` does not show while the work runs. Say which you meant: `guess x := …` for state that shows while the work runs (on a source with a question, ST-5c); `onFlush(() => { x := … })` for a reset that lands now (EF-8); a declared `action` to hold it on purpose; or write it after the `await`. `async.md` ("What lands together, and what waits") is the long form. @@ -433,7 +460,7 @@ export default component App() { } ``` -### OPTIMISTIC_OUTSIDE_HOLD +### GUESS_OUTSIDE_HOLD A guess lives in an action's hold until the server answers it (AC-3). An effect's apply block is never an action, so declare one and call it from the block. @@ -442,7 +469,7 @@ declare one and call it from the block. export default component App() { source draft := "a" source saved = "a" - effect (draft) { optimistic saved := $0 } + effect (draft) { guess saved := $0 }

{saved}

} ``` @@ -452,7 +479,7 @@ export default component App() { source draft := "a" source saved = "a" action save(v: string) { - optimistic saved := v + guess saved := v await fetch("save:" + v) catch (e) => null } effect (draft) { save($0) } @@ -460,15 +487,15 @@ export default component App() { } ``` -### OPTIMISTIC_NO_QUESTION +### GUESS_NO_QUESTION -A guess is confirmed or replaced by the answer to a question, so `optimistic` writes a source declared with `=` (a +A guess is confirmed or replaced by the answer to a question, so `guess` writes a source declared with `=` (a source with a question, ST-5c). A source seeded with `:=` has no question; write it plainly. ```rx before export default component App() { source shown := "Ann" - action fix() { optimistic shown := "Zed"; await fetch("x") catch (e) => null } + action fix() { guess shown := "Zed"; await fetch("x") catch (e) => null } } ``` @@ -476,7 +503,7 @@ export default component App() { ```rx after export default component App() { source shown = await fetch("name") catch (e) => "Ann" - action fix() { optimistic shown := "Zed"; await fetch("x") catch (e) => null } + action fix() { guess shown := "Zed"; await fetch("x") catch (e) => null } } ``` diff --git a/guide/reference.md b/guide/reference.md index a36c9355..5a0c36f9 100644 --- a/guide/reference.md +++ b/guide/reference.md @@ -25,7 +25,7 @@ Control ::= "if" Expr "{" … "}" ("else" "{" … "}")? -- no `e | "for" Name "in" Expr "{" … "}" | "loading" ("on" "(" Expr ")")? "fallback" "{" … "}" "{" … "}" | "errored" "fallback" ("(" Name (":" Classes)? ")")? "{" … "}" "{" … "}" -Stmt ::= Target ":=" Expr | "optimistic" Target ":=" Expr | "refresh" Name | "affects" Name +Stmt ::= Target ":=" Expr | "guess" Target ":=" Expr | "refresh" Name | "affects" Name | ("let" | "const") Name "=" Expr | "await" Expr | ("let" | "const") Name "=" "await" Expr | "if" "(" Expr ")" … ("else" …)? | "try" … "catch" … "finally" … | "throw" Expr | "return" Expr? | Expr -- imperative `else if` works; markup `{if}` has no `else if` @@ -58,7 +58,7 @@ Expr ::= TypeScript's, without assignment, `this`, `function`, `new` on rea - A failure inside the body must be caught there: `try { … } catch (e) { … }` (R-f-6, AC-7). The catch runs inside the same change: its writes land with the body's end. A rejected body commits its plain writes and reverts its guesses (AC-1, DV-2). -- `optimistic x := e`: a guess on a source with a question (`source x = …`), shown now, kept until the action's change +- `guess x := e`: a guess on a source with a question (`source x = …`), shown now, kept until the action's change commits or `x`'s own answer differs (AC-3, H-11). Guesses on one source hold together. - `refresh x`: asks `x` again, quietly (no `pending?`; the old value stays up until the new one lands). Inside an action, the answer lands with the action's commit (AC-4). diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index 849d001d..3b26a40b 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -162,12 +162,12 @@ export function createApp(opts: { root: any; api?: Record { if (typeof v === "function") { setStore(v as any); set(store); } else if (typeof v === "object" && v !== null) { setStore(() => v as any); set(store); } else set(v); }, path: (keys, v) => setStore((d: any) => { let o = d; for (const k of keys.slice(0, -1)) o = o[k]; o[keys[keys.length - 1]] = v; }), - guess: () => { throw new Error("OPTIMISTIC_NO_QUESTION"); }, + guess: () => { throw new Error("GUESS_NO_QUESTION"); }, }; return [get, w]; } const [get, set] = createSignal(init); - return [get, { set: set as (v: unknown) => void, path: () => { throw new Error("a path write to an atomic source"); }, guess: () => { throw new Error("OPTIMISTIC_NO_QUESTION"); } }]; + return [get, { set: set as (v: unknown) => void, path: () => { throw new Error("a path write to an atomic source"); }, guess: () => { throw new Error("GUESS_NO_QUESTION"); } }]; }, /** `source x = e` (ST-5): createSignal(fn); with a guess written to it, a createOptimistic layer over that truth (AC-3); * written or probed by path, createStore(fn, {}) whose derive writes its draft. */ @@ -191,7 +191,7 @@ export function createApp(opts: { root: any; api?: Record { throw new Error("a root write to a derived store is not lowered yet"); }, path: (keys, v) => setStore((d: any) => { let o = d; for (const k of keys.slice(0, -1)) o = o[k]; o[keys[keys.length - 1]] = v; }), guess: () => { throw new Error("optimistic over a structured question source is not lowered yet"); } }]; } const [get, set] = createSignal(fn as any, opts as any); - return [get, { set: set as any, path: () => { throw new Error("a path write to an atomic source"); }, guess: () => { throw new Error("OPTIMISTIC_NO_QUESTION"); } }]; + return [get, { set: set as any, path: () => { throw new Error("a path write to an atomic source"); }, guess: () => { throw new Error("GUESS_NO_QUESTION"); } }]; }, memo: (name: string | null, fn: (prev: unknown) => unknown, opts?: { loadingValue?: unknown }): Acc => createMemo(named(name, fn) as any, opts as any) as Acc, deferred: (name: string, fn: (prev: unknown) => unknown, opts?: { loadingValue?: unknown }): Acc => createDeferred(named(name, fn) as any, opts as any) as Acc, diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index ad40b5f5..342753ac 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -172,12 +172,12 @@ export function createApp(opts: { api?: Record unknown> const w: Writer = { set: v => { if (typeof v === "function") { setStore(v as any); set(store); } else if (typeof v === "object" && v !== null) { setStore(() => v as any); set(store); } else set(v); }, path: (keys, v) => setStore((d: any) => { let o = d; for (const k of keys.slice(0, -1)) o = o[k]; o[keys[keys.length - 1]] = v; }), - guess: () => { throw new Error("OPTIMISTIC_NO_QUESTION"); }, + guess: () => { throw new Error("GUESS_NO_QUESTION"); }, }; return [get, w]; } const [get, set] = createSignal(init); - return [get, { set: set as (v: unknown) => void, path: () => { throw new Error("a path write to an atomic source"); }, guess: () => { throw new Error("OPTIMISTIC_NO_QUESTION"); } }]; + return [get, { set: set as (v: unknown) => void, path: () => { throw new Error("a path write to an atomic source"); }, guess: () => { throw new Error("GUESS_NO_QUESTION"); } }]; }, /** `source x = e` (ST-5): createSignal(fn); with a guess written to it, a createOptimistic layer over that truth (AC-3); * written or probed by path, createStore(fn, {}) whose derive writes its draft. */ @@ -201,7 +201,7 @@ export function createApp(opts: { api?: Record unknown> return [get, { set: () => { throw new Error("a root write to a derived store is not lowered yet"); }, path: (keys, v) => setStore((d: any) => { let o = d; for (const k of keys.slice(0, -1)) o = o[k]; o[keys[keys.length - 1]] = v; }), guess: () => { throw new Error("optimistic over a structured question source is not lowered yet"); } }]; } const [get, set] = createSignal(fn as any, opts as any); - return [get, { set: set as any, path: () => { throw new Error("a path write to an atomic source"); }, guess: () => { throw new Error("OPTIMISTIC_NO_QUESTION"); } }]; + return [get, { set: set as any, path: () => { throw new Error("a path write to an atomic source"); }, guess: () => { throw new Error("GUESS_NO_QUESTION"); } }]; }, memo: (name: string | null, fn: (prev: unknown) => unknown, opts?: { loadingValue?: unknown }): Acc => createMemo(named(name, fn) as any, opts as any) as Acc, deferred: (name: string, fn: (prev: unknown) => unknown, opts?: { loadingValue?: unknown }): Acc => createDeferred(named(name, fn) as any, opts as any) as Acc, diff --git a/ref/oracle/README.md b/ref/oracle/README.md index cd271f1b..7a8f9ed8 100644 --- a/ref/oracle/README.md +++ b/ref/oracle/README.md @@ -8,7 +8,7 @@ an interpreter (`lower.ts`): | form | lowering | | --- | --- | | `source x := e` | `createSignal`; structured (object/array) → `createStore` behind a signal slot (ST-1, ST-2a) | -| `source x = e` | `createSignal(fn)`; `createOptimistic(fn)` when an `optimistic x :=` exists; `createStore(fn, {})` when written or probed by path (ST-5) | +| `source x = e` | `createSignal(fn)`; `createOptimistic(fn)` when an `guess x :=` exists; `createStore(fn, {})` when written or probed by path (ST-5) | | `x = e`, `await e [-> v]` | `createMemo(fn, { loadingValue })`; the memo returns the flight (a promise/async iterable) | | `deferred x = e` | `createDeferred` | | `x = draft(pre x -> seed) { … }` | `createProjection((draft) => …, seed)` (ST-3) | @@ -18,8 +18,8 @@ an interpreter (`lower.ts`): | `peek`, `latest`, `pending?` | `untrack`, `latest(fn)`, `isPending(fn)` | | `{if}` `{for}` `{loading}` `{errored}` | `Show`, `For`, `Loading`, `Errored` from `solid-js` | | `action`, inline `action { }` | `action(function* …)` — a statement-level `await` is a `yield` of the flight | -| `optimistic`, `refresh`, `affects` | the setter of a `createOptimistic`, `refresh(x)`, `affects(x)` | -| `source x = e` written both plainly and optimistically | `createSignal(fn)` as the truth + `createOptimistic(() => truth())` as the guess layer; plain writes go to the truth, `optimistic` to the layer | +| `guess`, `refresh`, `affects` | the setter of a `createOptimistic`, `refresh(x)`, `affects(x)` | +| `source x = e` written both plainly and optimistically | `createSignal(fn)` as the truth + `createOptimistic(() => truth())` as the guess layer; plain writes go to the truth, `guess` to the layer | | nested `await` | a tail-position `await` is the memo's result (the memo returns the flight); any other position is unsupported | | `provide` / `use` | `setContext` / `getContext` against the declaring owner; a provided source is the source binding (RG-7c) | | `x := f` | the setter's updater form (W-6) | diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index b26eae1c..331f66ab 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -471,7 +471,7 @@ export class Oracle { if (b.k === "store") { const path = s.target.path.map(p => typeof p === "string" ? p : String(untrack(() => this.ev(p, scope, env)))); if (s.k === "optimistic") { - if (!b.setGuess) throw new Error("OPTIMISTIC_NO_QUESTION"); + if (!b.setGuess) throw new Error("GUESS_NO_QUESTION"); b.setGuess((d: any) => { if (path.length === 0) { assignDraft(d, typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v); return; } let o = d; for (const k of path.slice(0, -1)) o = o[k]; const last = path[path.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }); // W-6 per key return; } diff --git a/ref/src/check.ts b/ref/src/check.ts index 089b00b3..7b0113be 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -804,8 +804,8 @@ class Checker { /** AC-1c, AC-1d, AC-1e over one imperative run (ref/src/infer.ts). */ private runChecks(stmts: Stmt[], action: boolean, bound: string[], inferred = false) { for (const f of runFindings(stmts, action, bound, inferred)) { - if (f.code === "HELD_WRITE") this.err("HELD_WRITE", "AC-1c", `${f.name} := … 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, \`optimistic ${f.name} := …\` (a source with a question, ST-5c); for a reset that lands now, \`onFlush(() => { ${f.name} := … })\` (EF-8); to hold it on purpose, declare the action (\`action name() { … }\`); or write it after the \`await\``); - else if (f.code === "STALE_WRITE_BACK") this.err("STALE_WRITE_BACK", "AC-1e", `${f.name} := … after an \`await\` is computed from ${f.name} as it was read before that \`await\`: whatever changed ${f.name} in between (another action, a reload) is overwritten — write functionally (\`${f.name} := v => …\`, W-6), which applies to ${f.name} as it stands at the write, or guess it (\`optimistic\`, AC-3) and let the answer replace the guess`); + if (f.code === "HELD_WRITE") this.err("HELD_WRITE", "AC-1c", `${f.name} := … 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 ${f.name} := …\` (a source with a question, ST-5c); for a reset that lands now, \`onFlush(() => { ${f.name} := … })\` (EF-8); to hold it on purpose, declare the action (\`action name() { … }\`); or write it after the \`await\``); + else if (f.code === "STALE_WRITE_BACK") this.err("STALE_WRITE_BACK", "AC-1e", `${f.name} := … after an \`await\` is computed from ${f.name} as it was read before that \`await\`: whatever changed ${f.name} in between (another action, a reload) is overwritten — write functionally (\`${f.name} := v => …\`, W-6), which applies to ${f.name} as it stands at the write, or guess it (\`guess\`, AC-3) and let the answer replace the guess`); else this.err("STALE_OWN_READ", "AC-1d", `${f.name} is read after this change wrote it: the read sees the committed value, not the write (W-4) — keep the value you wrote in a local (\`let v = …; ${f.name} := v\`) and read that, or write functionally (\`${f.name} := v => …\`, W-6)`); } } @@ -841,8 +841,8 @@ class Checker { 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") { - if (!ctx.inAction) this.err("OPTIMISTIC_OUTSIDE_HOLD", "AC-3", "optimistic outside an action: a guess needs a hold to live in — an apply block is never an action (its return is the cleanup, EF-5): declare one, `action save(v) { optimistic … }`, and call it from the block"); - if (target && target.kind === "source" && !target.question) this.err("OPTIMISTIC_NO_QUESTION", "AC-3", `${s.target.name} is declared with := — a guess needs a question to confirm it (source ${s.target.name} = …, ST-5c)`); + if (!ctx.inAction) this.err("GUESS_OUTSIDE_HOLD", "AC-3", "a guess outside an action: it needs a hold to live in — an apply block is never an action (its return is the cleanup, EF-5): declare one, `action save(v) { guess … }`, and call it from the block"); + if (target && target.kind === "source" && !target.question) this.err("GUESS_NO_QUESTION", "AC-3", `${s.target.name} is declared with := — a guess needs a question to confirm it (source ${s.target.name} = …, ST-5c)`); } s.target.path.forEach(p => typeof p !== "string" && this.expr(p, env, ctx)); this.expr(s.e, env, ctx); diff --git a/ref/src/infer.ts b/ref/src/infer.ts index ebd6a947..1cf2957c 100644 --- a/ref/src/infer.ts +++ b/ref/src/infer.ts @@ -41,7 +41,7 @@ export function inferredAction(stmts: Stmt[]): Trigger | null { go(stmts, new Map()); return guess ?? (write && wait ? "write+await" : null); } -export const triggerText = (t: Trigger): string => t === "write+await" ? "it writes and awaits" : `it has \`${t}\``; +export const triggerText = (t: Trigger): string => t === "write+await" ? "it writes and awaits" : `it has \`${t === "optimistic" ? "guess" : t}\``; export type Finding = { code: "HELD_WRITE" | "STALE_OWN_READ" | "STALE_WRITE_BACK"; name: string }; type Path = string[]; // keys from the source; "*" a computed key diff --git a/ref/src/model.ts b/ref/src/model.ts index e4924907..7ea4ee91 100644 --- a/ref/src/model.ts +++ b/ref/src/model.ts @@ -12,7 +12,7 @@ // tracked reactive scope (SC-1): the computation depends on it // snapshot imperative scope (SC-2), `peek` (E-5), a source's seed (E-4), a value parameter's argument (E-10) // pre `pre x` (C-2): the previous instant's value -// and `probe: "latest" | "pending"` under `latest` / `pending?` (R-9, R-12). A write is `x := e` (W-1), `optimistic` +// and `probe: "latest" | "pending"` under `latest` / `pending?` (R-9, R-12). A write is `x := e` (W-1), `guess` // (AC-3), `refresh` (AC-4) or `affects` (AC-5); `use p := e` writes the context's provided source (RG-7c). A call names a // fn, an action, a server fn or a JS import called by name. diff --git a/ref/src/parse.ts b/ref/src/parse.ts index bdc5143d..319bea21 100644 --- a/ref/src/parse.ts +++ b/ref/src/parse.ts @@ -15,6 +15,8 @@ export function hint(src: string, i: number, msg: string): [string, string] | nu const line = src.slice(lineStart(p), (src.indexOf("\n", p) + 1 || src.length + 1) - 1).trim(); const before = src.slice(lineStart(i), i); const name = /([A-Za-z_$][A-Za-z0-9_$.]*)\s*$/.exec(before)?.[1] ?? "x"; + if (/(^|[{;]\s*)optimistic\s+[A-Za-z_$]/.test(line)) + return ["OPTIMISTIC", "`optimistic` is spelled `guess`: `guess x := e` shows a guess until the action's answer replaces it (AC-3); it lowers to `createOptimistic`"]; if (msg.startsWith("W-2") && rest.startsWith(":=") && /=>\s*[A-Za-z_$][A-Za-z0-9_$.]*\s*$/.test(before)) return ["REACTIVE_WRITE_IN_OWNED_SCOPE", `an arrow that writes takes a braced body, \`() => { ${name} := … }\`: an expression-bodied arrow is a pure function (SC-3)`]; if (/^(\+\+|--|(\*\*|\|\||&&|\?\?|[-+*\/%])=|=(?![=>]))/.test(rest)) @@ -110,7 +112,7 @@ function parseClasses(c: Cursor): string[] | undefined { return out.some(n => n === "Error" || n === "unknown" || n === "any") ? undefined : out; } -const KEYWORDS = new Set(["source", "effect", "component", "ref", "await", "latest", "loading", "errored", "fallback", "on", "deferred", "action", "optimistic", "refresh", "affects", "if", "else", "pre", "peek", "fn", "let", "for", "in", "true", "false", "undefined", "null", "provide", "context", "use", "import", "export", "from", "pure", "return", "type", "interface", "try", "catch", "finally", "throw", "portal"]); +const KEYWORDS = new Set(["source", "effect", "component", "ref", "await", "latest", "loading", "errored", "fallback", "on", "deferred", "action", "guess", "optimistic", "refresh", "affects", "if", "else", "pre", "peek", "fn", "let", "for", "in", "true", "false", "undefined", "null", "provide", "context", "use", "import", "export", "from", "pure", "return", "type", "interface", "try", "catch", "finally", "throw", "portal"]); export function parseProgram(src: string): Program { const c = new Cursor(src); @@ -288,7 +290,8 @@ export function parseStmts(src: string): Stmt[] { function parseStmt(c: Cursor): Stmt { c.ws(); - if (c.keyword("optimistic")) { + if (c.keyword("optimistic")) c.fail("AC-3: `optimistic` is not a statement"); + if (c.keyword("guess")) { c.ws(); const name = c.ident() ?? c.fail("target"); const path: Target["path"] = []; for (;;) { if (c.eat(".")) path.push(c.ident() ?? c.fail("member")); else if (c.eat("[")) { c.ws(); path.push(parseExpr(c)); c.ws(); c.expect("]"); } else break; } c.ws(); c.expect(":="); c.ws(); return { k: "optimistic", target: { name, path }, e: parseExpr(c) }; diff --git a/ref/src/runtime.ts b/ref/src/runtime.ts index c0eee1bc..d60894d6 100644 --- a/ref/src/runtime.ts +++ b/ref/src/runtime.ts @@ -1100,9 +1100,9 @@ export class Runtime { else if (s.k === "refresh") { const n = this.nodeNamed(s.name, region); if (!n || (n.kind !== "eq") || !(n.question || n.flight || n.compute)) throw new RuleError("INVALID_REFRESH_TARGET", `${s.name} has no question to re-ask`); n.refreshInAction = !!run.action; this.pendingRefresh.push(n); } else if (s.k === "affects") { const n = this.nodeNamed(s.name, region); if (!n) throw new RuleError("INVALID_AFFECTS_TARGET", `${s.name} is not reactive`); this.pendingAffects.push(n); } else if (s.k === "optimistic") { - if (!run.action) throw new RuleError("OPTIMISTIC_OUTSIDE_HOLD", "optimistic outside an action body"); + if (!run.action) throw new RuleError("GUESS_OUTSIDE_HOLD", "a guess outside an action body"); const n = this.nodeNamed(s.target.name, region); - if (!n || !n.question) throw new RuleError("OPTIMISTIC_NO_QUESTION", `${s.target.name} is not a source with a question (source x = e)`); + if (!n || !n.question) throw new RuleError("GUESS_NO_QUESTION", `${s.target.name} is not a source with a question (source x = e)`); const path = s.target.path.map(p => typeof p === "string" ? p : String(this.ev(p, region, env))); this.pendingGuesses.push({ node: n, path, value: this.ev(s.e, region, env) }); } else if (s.k === "write") { diff --git a/scenarios/S-action-1.md b/scenarios/S-action-1.md index c303067d..35a130b1 100644 --- a/scenarios/S-action-1.md +++ b/scenarios/S-action-1.md @@ -10,7 +10,7 @@ export default component App() { source status := "" source orders := 0 action placeOrder() { - optimistic cart := [] + guess cart := [] status := "placing" try { let receipt = await fetch("checkout") @@ -50,7 +50,7 @@ export default component App() { source cart = [1, 2] source status := "" action placeOrder() { - optimistic cart := [] + guess cart := [] status := "placing" try { let receipt = await fetch("checkout") } catch (e) { status := "declined" } fail("lost the receipt") @@ -96,7 +96,7 @@ export default component App() { source cart = [1, 2] source status := "" action placeOrder() { - optimistic cart := [] + guess cart := [] status := "placing" let receipt = await fetch("checkout") cart := [] diff --git a/scenarios/S-action-2.md b/scenarios/S-action-2.md index beb3cb3f..ab4995b1 100644 --- a/scenarios/S-action-2.md +++ b/scenarios/S-action-2.md @@ -12,7 +12,7 @@ export default component App() { source status := "" action rename(n) { try { - optimistic shown := n + guess shown := n await fetch("send") // fire-and-forget: the answer is not the confirmation await until(() => shown == n) // the confirmation is the truth arriving status := "confirmed" diff --git a/scenarios/S-action-3.md b/scenarios/S-action-3.md index 7efa5f47..647d3fcc 100644 --- a/scenarios/S-action-3.md +++ b/scenarios/S-action-3.md @@ -10,7 +10,7 @@ export default component App() { source n := 0 source guess = n action save() { - optimistic guess := value => value + 1 + guess guess := value => value + 1 try { await fetch("r1") } catch (error) {} }

{guess}

diff --git a/scenarios/S-action-4.md b/scenarios/S-action-4.md index 6072498b..ca12c0d9 100644 --- a/scenarios/S-action-4.md +++ b/scenarios/S-action-4.md @@ -10,7 +10,7 @@ export default component App() { source n := 0 source guess = n action save(amount) { - optimistic guess := value => value + amount + guess guess := value => value + amount try { let got = await fetch("r" + amount) n := value => value + got diff --git a/scenarios/S-ai-feed.md b/scenarios/S-ai-feed.md index 1c92ae65..cfa8a0bb 100644 --- a/scenarios/S-ai-feed.md +++ b/scenarios/S-ai-feed.md @@ -62,11 +62,11 @@ change and lands with the body's end (AC-7). ## Rejected variants ```rx --- reject: OPTIMISTIC_NO_QUESTION +-- reject: GUESS_NO_QUESTION -- handler: pick() export default component App() { source selected := 1 - action pick() { optimistic selected := 2; await fetch("x") } + action pick() { guess selected := 2; await fetch("x") }

{selected}

} diff --git a/scenarios/S-ai2-tracker.md b/scenarios/S-ai2-tracker.md index 481976ef..cb42669d 100644 --- a/scenarios/S-ai2-tracker.md +++ b/scenarios/S-ai2-tracker.md @@ -47,4 +47,4 @@ Predicted from the spec and not run (the test allows the checker only on `main.r | delete the `errored fallback(e) { … }` wrapper in `DetailPanel` | R-f-5 | `UNHANDLED_FAILURE` (`detail` fails `Error` or `NotFound`) | | in `assignToMe`, `await assign(id, user)` outside `try … catch` | R-f-6 | `UNCAUGHT_ACTION_FAILURE` | | in `ui.rx`, a handler `() => { selected := issue.id }` | MO-2a | `WRITE_TO_NON_SOURCE` | -| `optimistic assignError := "…"` in an action (`assignError` is `:=`, no question) | AC-3 / ST-5c | `OPTIMISTIC_NO_QUESTION` | +| `guess assignError := "…"` in an action (`assignError` is `:=`, no question) | AC-3 / ST-5c | `GUESS_NO_QUESTION` | diff --git a/scenarios/S-check-1.md b/scenarios/S-check-1.md index 9cab6844..32496d2c 100644 --- a/scenarios/S-check-1.md +++ b/scenarios/S-check-1.md @@ -199,21 +199,21 @@ export default component App() { ``` ```rx --- reject: OPTIMISTIC_OUTSIDE_HOLD +-- reject: GUESS_OUTSIDE_HOLD export default component App() { source name := "Ann" source shown = name - effect (name) { optimistic shown := "Zed" } + effect (name) { guess shown := "Zed" }

{shown}

} ``` ```rx --- reject: OPTIMISTIC_NO_QUESTION +-- reject: GUESS_NO_QUESTION export default component App() { source shown := "Ann" - action fix() { optimistic shown := "Zed"; await fetch("x") } + action fix() { guess shown := "Zed"; await fetch("x") }

{shown}

} diff --git a/scenarios/S-effect-store-1.md b/scenarios/S-effect-store-1.md index 4521d5eb..22a5419c 100644 --- a/scenarios/S-effect-store-1.md +++ b/scenarios/S-effect-store-1.md @@ -8,7 +8,7 @@ Rules exercised: EF-1, EF-2, H-11, ST-4, AC-3, AC-4. export default component App() { source rows key id = await fetch("l") effect (rows) { log(rows_title($0)) } - action ren() { try { optimistic rows[0].title := "x"; await fetch("save"); refresh rows } catch (e) {} } + action ren() { try { guess rows[0].title := "x"; await fetch("save"); refresh rows } catch (e) {} } errored fallback(e) {

error

} { loading fallback {

loading

} { {for t in rows { {t.title} }} } } } fn rows_title(rs) => rs.map(r => r.title).join(",") diff --git a/scenarios/S-guess-reask-refresh.md b/scenarios/S-guess-reask-refresh.md index aaffb6bf..7d208cb8 100644 --- a/scenarios/S-guess-reask-refresh.md +++ b/scenarios/S-guess-reask-refresh.md @@ -7,7 +7,7 @@ Rules exercised: AC-3, AC-4, H-11, D-34. ```rx export default component App() { source detail = await fetch("d") - action rename(t) { try { optimistic detail.title := t; await fetch("save"); refresh detail } catch (e) {} } + action rename(t) { try { guess detail.title := t; await fetch("save"); refresh detail } catch (e) {} } errored fallback(e) {

error

} { loading fallback {

loading

} { {detail.title} } } } ``` @@ -39,7 +39,7 @@ effect's view too. ```rx export default component App() { source detail = await fetch("d") - action rename(t) { try { optimistic detail.title := t; await fetch("save"); refresh detail } catch (e) {} } + action rename(t) { try { guess detail.title := t; await fetch("save"); refresh detail } catch (e) {} } effect (detail.title catch (e) => "err") { log($0) }

x

} diff --git a/scenarios/S-infer-1.md b/scenarios/S-infer-1.md index e4747eda..96606399 100644 --- a/scenarios/S-infer-1.md +++ b/scenarios/S-infer-1.md @@ -10,7 +10,7 @@ export default component App() { source error := "" + } ``` @@ -61,11 +61,11 @@ An inferred action catches what its body can fail with, like a declared one (R-f-6): nothing awaits a handler. ```rx --- reject: OPTIMISTIC_OUTSIDE_HOLD +-- reject: GUESS_OUTSIDE_HOLD export default component App() { source draft := "a" source saved = "a" - effect (draft) { optimistic saved := $0 } + effect (draft) { guess saved := $0 }

{saved}

} ``` diff --git a/scenarios/S-infer-2.md b/scenarios/S-infer-2.md index 8fdbd96f..e5bbafb1 100644 --- a/scenarios/S-infer-2.md +++ b/scenarios/S-infer-2.md @@ -74,7 +74,7 @@ export default component App() { Written as a plain handler, this showed `saving` at once. As the action the body now is, `saving` would wait for the server. The fixes are named -by what the write is for: `optimistic` for state that shows while the work +by what the write is for: `guess` for state that shows while the work runs, `onFlush` for a reset, a declared action to hold it on purpose. ```rx diff --git a/scenarios/S-infer-3.md b/scenarios/S-infer-3.md index c1f72260..ba39b636 100644 --- a/scenarios/S-infer-3.md +++ b/scenarios/S-infer-3.md @@ -9,7 +9,7 @@ export default component App() { source liked = "no" action like() { try { let r = await fetch("like"); liked := r } catch (e) {} } - + } ``` @@ -22,7 +22,7 @@ the same instant's change (D-7), so the guess lives until `like` ends. | instant | action | frame | | ------- | ---------------------------------------------- | ----- | | 0 | mount | `no` | -| 1 | handler `optimistic liked := "yes"; like()` | `yes` | +| 1 | handler `guess liked := "yes"; like()` | `yes` | | 2 | resolve "like" "yes" | `yes` | ## Variant: the server declines @@ -30,5 +30,5 @@ the same instant's change (D-7), so the guess lives until `like` ends. | instant | action | frame | | ------- | ---------------------------------------------- | ----- | | 0 | mount | `no` | -| 1 | handler `optimistic liked := "yes"; like()` | `yes` | +| 1 | handler `guess liked := "yes"; like()` | `yes` | | 2 | reject "like" "offline" | `no` | diff --git a/scenarios/S-infer-6.md b/scenarios/S-infer-6.md index 9e798c80..a19202b6 100644 --- a/scenarios/S-infer-6.md +++ b/scenarios/S-infer-6.md @@ -56,7 +56,7 @@ export default component App() { source items := [] action add(x: string) { let before = items - optimistic items := [...before, x + "…"] + guess items := [...before, x + "…"] let r = await fetch(x) catch (e) => "" items := [...before, x] } diff --git a/scenarios/S-latest-reask.md b/scenarios/S-latest-reask.md index e0c8cb8f..e63603e2 100644 --- a/scenarios/S-latest-reask.md +++ b/scenarios/S-latest-reask.md @@ -7,7 +7,7 @@ Rules exercised: R-12, H-7, H-11, AC-3, AC-4, ST-4. ```rx export default component App() { source rows key id = await fetch("l") - action close(id) { try { optimistic rows := rows.filter(r => r.id != id); await fetch("close"); refresh rows } catch (e) {} } + action close(id) { try { guess rows := rows.filter(r => r.id != id); await fetch("close"); refresh rows } catch (e) {} } errored fallback(e) {

error

} { loading fallback {

loading

} {

{rows.length} / {(latest rows).length}

} } } ``` diff --git a/scenarios/S-parse-hints.md b/scenarios/S-parse-hints.md index 5b49203f..6ef2f673 100644 --- a/scenarios/S-parse-hints.md +++ b/scenarios/S-parse-hints.md @@ -54,6 +54,14 @@ export default component App() { } ``` +``` +-- reject: OPTIMISTIC +export default component App() { + source liked = false + +} +``` + ``` -- reject: JS_API export default component App() { diff --git a/scenarios/S-remount-refresh.md b/scenarios/S-remount-refresh.md index ad6f200e..27fdc99b 100644 --- a/scenarios/S-remount-refresh.md +++ b/scenarios/S-remount-refresh.md @@ -7,7 +7,7 @@ Rules exercised: AC-4, H-11, ST-4, O-2. ```rx component List() { source rows key id = tag(await fetch("l")) - action ren() { try { optimistic rows[0].title := "x"; await fetch("save"); refresh rows } catch (e) {} } + action ren() { try { guess rows[0].title := "x"; await fetch("save"); refresh rows } catch (e) {} } errored fallback(e) {

error

} { loading fallback {

loading

} { {for r in rows { {r.title} }} } } } fn tag(rs) => rs.map(r => ({ id: r.id, title: r.title, seen: true })) diff --git a/scenarios/S-solid-26.md b/scenarios/S-solid-26.md index 22309cb6..da1d9826 100644 --- a/scenarios/S-solid-26.md +++ b/scenarios/S-solid-26.md @@ -43,7 +43,7 @@ export default component App() { source id := 1 user = await fetch("u:" + id) source shown = user.name - action nick(n) { try { optimistic shown := n; await fetch("gate") } catch {} } + action nick(n) { try { guess shown := n; await fetch("gate") } catch {} } errored fallback(e) {

error: {e.message}

} { {loading fallback {

…

} {

{shown}

{pending? shown ? "updating" : ""}

}} diff --git a/scenarios/S-solid-32.md b/scenarios/S-solid-32.md index 5652150e..7c350675 100644 --- a/scenarios/S-solid-32.md +++ b/scenarios/S-solid-32.md @@ -9,7 +9,7 @@ export default component App() { source name := "Ann" source shown = name source saved := false - action rename(n) { try { optimistic shown := n; saved := true; await fetch("save"); name := n } catch {} } + action rename(n) { try { guess shown := n; saved := true; await fetch("save"); name := n } catch {} }

{shown} / {saved ? "saved" : "unsaved"} / {pending? shown ? "p" : "-"} {pending? saved ? "p" : "-"}

} @@ -43,7 +43,7 @@ was a plain write and commits (AC-1, DV-2). | instant | action | frame | | ------- | ----------------------------------- | ---------------------- | -| 1 | handler `optimistic shown := "Zed"` | `Ann / unsaved / - -` | +| 1 | handler `guess shown := "Zed"` | `Ann / unsaved / - -` | A guess outside a hold does not exist (D-33). A handler with a guess is an action (AC-1b), so this compiles; the action's change opens and commits in @@ -54,11 +54,11 @@ The `effect` form, which is never an action, is still rejected (S-check-1). ```rx --- reject: OPTIMISTIC_NO_QUESTION +-- reject: GUESS_NO_QUESTION -- handler: fix() export default component App() { source shown := "Ann" - action fix() { try { optimistic shown := "Zed"; await fetch("x") } catch {} } + action fix() { try { guess shown := "Zed"; await fetch("x") } catch {} }

{shown}

} diff --git a/scenarios/S-solid-33.md b/scenarios/S-solid-33.md index 0d5ddab6..850d1013 100644 --- a/scenarios/S-solid-33.md +++ b/scenarios/S-solid-33.md @@ -9,7 +9,7 @@ export default component App() { source value := 0 source double = await fetch("d:" + str(value)) related = await fetch("r:" + str(double)) - action bump() { value := 1; optimistic double := 3 } + action bump() { value := 1; guess double := 3 } errored fallback(e) {

error: {e.message}

} { {loading fallback {

loading

} {

double={double} async={related}

}} diff --git a/scenarios/S-solid-34.md b/scenarios/S-solid-34.md index 02d25d98..7f3f6b32 100644 --- a/scenarios/S-solid-34.md +++ b/scenarios/S-solid-34.md @@ -8,7 +8,7 @@ Rules: AC-3, R-4, H-11. export default component App() { source id := 0 source cat = await fetch("cat:" + str(id)) - action pick(n, name) { try { id := n; optimistic cat := name; await fetch("w" + str(n)) } catch {} } + action pick(n, name) { try { id := n; guess cat := name; await fetch("w" + str(n)) } catch {} } errored fallback(e) {

error: {e.message}

} {

{cat} / {latest cat} {pending? cat ? "p" : "-"}

diff --git a/scenarios/S-solid-35.md b/scenarios/S-solid-35.md index 9cadb111..58c5f6f8 100644 --- a/scenarios/S-solid-35.md +++ b/scenarios/S-solid-35.md @@ -8,7 +8,7 @@ Rules: AC-1, AC-3, H-11. export default component App() { source truth := 1 source guess = truth - action attempt() { try { optimistic guess := 5; await fetch("unrelated") } catch {} } + action attempt() { try { guess guess := 5; await fetch("unrelated") } catch {} }

{guess} / {latest guess} {pending? guess ? "p" : "-"}

} @@ -35,7 +35,7 @@ export default component App() { source truth := 1 source guess = truth derived = await fetch("x:" + str(guess)) - action attempt() { try { optimistic guess := 5; await fetch("unrelated") } catch {} } + action attempt() { try { guess guess := 5; await fetch("unrelated") } catch {} } errored fallback(e) {

error: {e.message}

} {

{guess} {derived} / {latest guess} {pending? guess ? "p" : "-"}

@@ -65,7 +65,7 @@ instant 3, and untracked `pending? guess` is `true` until the commit. ```rx export default component App() { source list = await fetch("list") - action add() { try { optimistic list := list + "+mine"; await fetch("save"); refresh list } catch {} } + action add() { try { guess list := list + "+mine"; await fetch("save"); refresh list } catch {} } errored fallback(e) {

error: {e.message}

} {

{list}

diff --git a/scenarios/S-solid-44.md b/scenarios/S-solid-44.md index 9f4e1e1b..70a8bc6c 100644 --- a/scenarios/S-solid-44.md +++ b/scenarios/S-solid-44.md @@ -8,7 +8,7 @@ Rules: H-9, H-11, AC-3, AC-4, ST-5. export default component App() { source sel := 3 source comments = await fetch("c:" + sel) - action post() { try { optimistic comments := 9; await fetch("post"); refresh comments } catch {} } + action post() { try { guess comments := 9; await fetch("post"); refresh comments } catch {} } errored fallback(e) {

error: {e.message}

} {
{sel} {comments} {latest comments} {str(pending? comments)}
diff --git a/scenarios/S-store-6.md b/scenarios/S-store-6.md index 90889d98..eaf315d7 100644 --- a/scenarios/S-store-6.md +++ b/scenarios/S-store-6.md @@ -9,7 +9,7 @@ D-34 (`createOptimisticStore`). export default component App() { source todos key id = await fetch("todos") action toggle(i) { - try { optimistic todos[i].done := true; await fetch("save"); refresh todos } catch {} + try { guess todos[i].done := true; await fetch("save"); refresh todos } catch {} } errored fallback(e) {

error: {e.message}

} { @@ -21,7 +21,7 @@ export default component App() { } ``` -`optimistic todos[i].done := true` guesses one key of one row; the rest of +`guess todos[i].done := true` guesses one key of one row; the rest of `todos` is the truth beneath it. The action saves, then re-asks the list (`refresh todos`) for the truth that confirms the guess. @@ -65,7 +65,7 @@ corrected by the truth it was made over (D-34). export default component App() { source id := 1 source state = await fetch("s:" + str(id)) - action patch() { try { optimistic state.a := 5; await fetch("w") } catch {} } + action patch() { try { guess state.a := 5; await fetch("w") } catch {} } errored fallback(e) {

error: {e.message}

} { {loading fallback {

loading

} {

{state.a} {state.b} {pending? state.a ? "a!" : "-"} {pending? state.b ? "b!" : "-"}

}} @@ -87,10 +87,10 @@ body's end the guess reverts to the truth. ## Rejected variants ```rx --- reject: OPTIMISTIC_NO_QUESTION +-- reject: GUESS_NO_QUESTION export default component App() { source todos: { done: boolean }[] := [{ done: false }] - action toggle(i) { try { optimistic todos[i].done := true; await fetch("save") } catch {} } + action toggle(i) { try { guess todos[i].done := true; await fetch("save") } catch {} }

{len(todos)}

} diff --git a/scenarios/S-store-7.md b/scenarios/S-store-7.md index 2cc997c1..6ed60617 100644 --- a/scenarios/S-store-7.md +++ b/scenarios/S-store-7.md @@ -9,7 +9,7 @@ export default component App() { source filter := "open" source rows key id = await fetch("l:" + filter) shown = latest rows - action ren(i) { try { optimistic rows[i].title := "x"; await fetch("save") } catch (e) {} } + action ren(i) { try { guess rows[i].title := "x"; await fetch("save") } catch (e) {} } errored fallback(e) {

error: {e.message}

} { {len(shown)}{loading on(filter) fallback {

loading

} {
    {for t in rows {
  • {t.title}
  • }}
}} } diff --git a/scenarios/S-store-8.md b/scenarios/S-store-8.md index 7c9fc7e1..b8aeac25 100644 --- a/scenarios/S-store-8.md +++ b/scenarios/S-store-8.md @@ -9,10 +9,10 @@ export default component App() { source todos key id = await fetch("todos") shown = latest todos action toggle(i) { - try { optimistic todos[i].done := true; await fetch("save") } catch {} + try { guess todos[i].done := true; await fetch("save") } catch {} } action rename(i) { - try { optimistic todos[i].title := "Z"; await fetch("ren") } catch {} + try { guess todos[i].title := "Z"; await fetch("ren") } catch {} } errored fallback(e) {

error: {e.message}

} { @@ -49,10 +49,10 @@ export default component App() { source todos key id = await fetch("todos") shown = latest todos action first(i) { - try { optimistic todos[i].title := "X"; await fetch("one") } catch {} + try { guess todos[i].title := "X"; await fetch("one") } catch {} } action second(i) { - try { optimistic todos[i].title := "Y"; await fetch("two") } catch {} + try { guess todos[i].title := "Y"; await fetch("two") } catch {} } errored fallback(e) {

error: {e.message}

} { diff --git a/spec/05-holds.md b/spec/05-holds.md index fe29966e..98424529 100644 --- a/spec/05-holds.md +++ b/spec/05-holds.md @@ -213,7 +213,7 @@ which showed `A 0` where Solid shows `A 1` — S-hold-3.) not `σ`, not `σ′`, not a probe. The instant begins when the run ends (W-3); from then `v` is in `σ′` (probes see it) and in `σ` at commit. -- **H-11 (Optimistic).** [F-11, D-33, D-34] `optimistic x := e`, legal only +- **H-11 (Optimistic).** [F-11, D-33, D-34] `guess x := e`, legal only inside an action body, declared or inferred (AC-3, AC-1b), and only on a source with a question (ST-5), writes a *guess* for `x`: from the instant's settle it is the graph's value (equations derive from it — S-solid-33: `related` re-asks from the @@ -229,7 +229,7 @@ which showed `A 0` where Solid shows `A 1` — S-hold-3.) `true`, and the new question's answer is not the guess's truth — it neither confirms nor corrects the guess; it commits with the hold (S-solid-44). An input the guess's own action writes is the guess's question - (S-solid-34). A functional guess (`optimistic x := v => …`, W-6) applies + (S-solid-34). A functional guess (`guess x := v => …`, W-6) applies to `x` as the screen shows it: an input its action holds, the launching handler's own write included (AC-1), is not shown yet, so the updater sees the old answer (S-action-3; #33). `latest x` answers the guess diff --git a/spec/06-stores.md b/spec/06-stores.md index 6b0b750a..32a83a90 100644 --- a/spec/06-stores.md +++ b/spec/06-stores.md @@ -2,7 +2,7 @@ **Status: OPEN (2026-10-21).** Structured values, sources with a standing question, drafts, keys, streams, and the imperative forms that tie writes to -async work: `action`, `optimistic`, `refresh`, `affects`. Rules cite Solid's +async work: `action`, `guess`, `refresh`, `affects`. Rules cite Solid's F-9, F-11 and the derived rules they restate. In `ref/`: ST-1…ST-6, AC-1…AC-5 (S-store-1…5, S-solid-8/9/15/20/26/32…35/37). Optimistic path writes: S-store-6. AC-6 `until`: S-action-2. AC-7 try/catch: S-action-1. A functional guess over a held input, and two actions guessing one slot (H-11): S-action-3, S-action-4. AC-1b…AC-1d (inferred actions, held writes, stale own reads): S-infer-1…5. AC-1e (stale write-back): S-infer-6. @@ -84,7 +84,7 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } a `createOptimistic(() => truth())` layer. - **ST-5b.** Its question (V-9) is `e`'s inputs. `refresh x` re-asks it quietly; `affects x` declares that in-flight work will change it (H-9). -- **ST-5c.** It is the only legal target of `optimistic` (AC-3): a guess +- **ST-5c.** It is the only legal target of `guess` (AC-3): a guess needs a truth that will confirm or correct it. ## ST-6 — Streams @@ -142,7 +142,7 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } adoption). - **AC-1b (Inferred action).** [ruled 2026-10-28] An arrow function in imperative scope, including an element's event handler (02 SC-4), is an - action when its own statements include `optimistic x := e` (AC-3), + action when its own statements include `guess x := e` (AC-3), `affects x` (AC-5), or both a write to a source (W-1) and a statement-level `await` (07 decision 16). It is then `(ps) => action { B }` with every consequence of AC-1: one change across its awaited work, its @@ -156,7 +156,7 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } An `effect`'s apply block is never inferred: its return is the effect's cleanup (EF-5), which an action's body cannot give, and the next run's cleanup could not cancel an action in flight. A server function's body is - never inferred. A guess in either stays `OPTIMISTIC_OUTSIDE_HOLD`. + never inferred. A guess in either stays `GUESS_OUTSIDE_HOLD`. Lowers to Solid's `action` exactly as `action { … }` does, nothing added (11). Solid: a TypeScript function's promises are invisible to the transition, so its author writes `action(function* () { … })`; here every @@ -168,7 +168,7 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } so a later write in the body hides it and otherwise it lands late, and nothing in the text says so. The fix names what the write is for: ephemeral state that shows while the work runs ("saving", a spinner, a - pending row) is a guess, `optimistic x := v` on a source with a question + pending row) is a guess, `guess x := v` on a source with a question (ST-5c; often a constant one, `source status = ""`); a reset that lands now and stays (clear an error, clear an input, close a menu) is `onFlush(() => { x := v })` (02 EF-8). A write meant to be held — a @@ -205,7 +205,7 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } index computed from `x` (`let i = indexOf(x, …); … x[i].f := v`) is not the written value and does not count. The fix is the functional write (`x := v => …`, W-6), which applies to `x` as it stands at the write, or - a guess (`optimistic`, AC-3) that the answer replaces. A guess or a write + a guess (`guess`, AC-3) that the answer replaces. A guess or a write before the `await` is the change's own and is not checked; nor is an awaited answer (`let r = await …`), which is the server's, not a snapshot of `x`. The check is the language's (Solid cannot see the read, @@ -217,17 +217,17 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } - **AC-2 (Not special).** An action is only a way to tie writes to async work; the hold is the thing (H-2). `refresh`, `affects` and probes behave the same inside and outside one. -- **AC-3 (optimistic).** `optimistic x := e`, legal only inside an action +- **AC-3 (guess).** `guess x := e`, legal only inside an action body, declared or inferred (AC-1b); in an `effect`'s apply block, which - is never an action, it is `OPTIMISTIC_OUTSIDE_HOLD`, and only on a source with a - question (ST-5; `OPTIMISTIC_NO_QUESTION`): writes a guess that is the + is never an action, it is `GUESS_OUTSIDE_HOLD`, and only on a source with a + question (ST-5; `GUESS_NO_QUESTION`): writes a guess that is the graph's and the screen's value for `x` from the instant's settle, display-ahead and verdict-inert, until the hold it was made over commits or `x`'s own answer differs — the full rule is H-11. `x`'s truth is whatever its question gives: a constant (`source draft = "Ann"`), another source (`source draft = name`, S-solid-32), or an `await`. Lowers to `createOptimistic`/`createOptimisticStore` by usage. Path targets - (`optimistic x.a := v`, `optimistic todos[i].done := true`) guess per key, + (`guess x.a := v`, `guess todos[i].done := true`) guess per key, as Solid's per-node override: the rest of `x` is the truth beneath the guess, a new answer corrects only where it differs at a guessed key, and equal there it confirms silently (S-store-6). A question re-asked by an @@ -299,11 +299,11 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } | `ACTION_CALLED_IN_OWNED_SCOPE` | U | AC-1: an action is `io` (IO-2) | | `INVALID_REFRESH_TARGET` | S | AC-4: not a source with a question / await eq | | `INVALID_AFFECTS_TARGET` | S | AC-5 | -| `OPTIMISTIC_OUTSIDE_HOLD` (new) | S | AC-3: an apply block (never an action, AC-1b) | +| `GUESS_OUTSIDE_HOLD` (new) | S | AC-3: an apply block (never an action, AC-1b) | | `HELD_WRITE` (new) | S | AC-1c: an early write in an inferred action | | `STALE_OWN_READ` (new) | S | AC-1d: a read of the change's own write | | `STALE_WRITE_BACK` (new) | S | AC-1e: a pre-`await` read written back after it | -| `OPTIMISTIC_NO_QUESTION` (new) | S | AC-3 / ST-5c | +| `GUESS_NO_QUESTION` (new) | S | AC-3 / ST-5c | ## Scenarios diff --git a/spec/07-surface.md b/spec/07-surface.md index 3784d3bc..f2df3daf 100644 --- a/spec/07-surface.md +++ b/spec/07-surface.md @@ -198,7 +198,7 @@ effect (count) { } action rename(n: string) { - optimistic user.name := n // AC-3: only here + guess user.name := n // AC-3: only here let saved = await api.rename(n) // AC-1: a hold-safe suspension user := saved } @@ -206,7 +206,7 @@ action rename(n: string) { action { refresh user; affects user } // inline, one run (AC-1) ``` @@ -230,7 +230,7 @@ action { refresh user; affects user } // inline, one run (AC-1) - An arrow that guesses, declares `affects`, or both writes and awaits is an action (06 AC-1b); an `effect`'s apply block never is. In such an arrow a plain write before an `await` is `HELD_WRITE` (AC-1c): make it - `optimistic` if it is ephemeral state that shows while the work runs, + `guess` if it is ephemeral state that shows while the work runs, `onFlush(() => { … })` if it is a reset that lands now, or declare the action to hold it on purpose. In every run, reading a source the run's own change wrote is `STALE_OWN_READ` (AC-1d): keep the value in a local. @@ -252,7 +252,7 @@ action { refresh user; affects user } // inline, one run (AC-1) ```rx action placeOrder() { let items = cart // a snapshot of what is being ordered - optimistic cart := [] // the guess shows now (AC-3) + guess cart := [] // the guess shows now (AC-3) affects cart // `pending? cart` is true until the body ends (AC-5) — the guess alone is verdict-inert try { let receipt = await checkout(items) @@ -268,7 +268,7 @@ action { refresh user; affects user } // inline, one run (AC-1) `await`, inside the `try`, and report failure from the `catch` (AC-7, R-f-6). An optimistic write over a *structured* question source is a root write (functional or whole); path targets are not in `ref/` yet. -- `refresh x`, `affects x`, `optimistic x := e` are statements (AC-3..5). +- `refresh x`, `affects x`, `guess x := e` are statements (AC-3..5). ## 6. Components (RG-4) @@ -445,7 +445,7 @@ Control ::= "if" Expr "{" RBlock "}" ("else" "{" RBlock "}")? | "loading" ("on" "(" Expr ")")? "fallback" "{" RBlock "}" "{" RBlock "}" | "errored" "fallback" ("(" Name (":" Classes)? ")")? "{" RBlock "}" "{" RBlock "}" Block ::= "{" Stmt* "}" -Stmt ::= Target ":=" Expr | "optimistic" Target ":=" Expr -- Target `use` Name Path: WRITE_TO_NON_SOURCE (O-4) +Stmt ::= Target ":=" Expr | "guess" Target ":=" Expr -- Target `use` Name Path: WRITE_TO_NON_SOURCE (O-4) | "refresh" Name | "affects" Name | "action" Block | ("let" | "const") Name Type? "=" Expr | "if" "(" Expr ")" (Block | Stmt) ("else" (Block | Stmt))? @@ -525,3 +525,13 @@ Expr ::= TSExpr with §3's removals and additions apply block is not inferred (its return is the cleanup, EF-5). The declared `action` stays for names, reuse, and holding a write on purpose. +18. **`guess x := e`, not `optimistic x := e`** (ruled 2026-11-11): the + statement is named for what it writes, a guess (AC-3, H-11), which the + answer replaces. The word was `optimistic` through 0.1, after Solid's + `createOptimistic`, which it still lowers to. A guess on state nothing + will answer reads wrong on its face (`guess saveError := …`: the eval's + pilot runs wrote that nine times as `optimistic`, `GUESS_NO_QUESTION`). + `optimistic` stays reserved, and a line that uses it is the parse error + `OPTIMISTIC`, which names `guess`. `source` was weighed against `state` + and `signal` the same day and kept (decision 2): `signal` reads as atomic + beside stores, and an equation is state too. diff --git a/spec/08-constraints.md b/spec/08-constraints.md index 56ba85df..f59d1e0a 100644 --- a/spec/08-constraints.md +++ b/spec/08-constraints.md @@ -24,7 +24,7 @@ runtime check. | constraint | rules | enforcer | | --- | --- | --- | -| no write, statement or `io` call in reactive scope; `optimistic` only in an action; a draft writes only `d` | W-1, W-2, IO-2, AC-3, ST-3a | grammar: no position exists (bin U) | +| no write, statement or `io` call in reactive scope; `guess` only in an action; a draft writes only `d` | W-1, W-2, IO-2, AC-3, ST-3a | grammar: no position exists (bin U) | | `io` rises and never falls; `fn` is pure, `action` is `io`; `pure` only at the JS boundary | R-io-1..4 | row inference in the checker | | `pending` enters only through `await`; `lane` only through probes; rows flow through equations and across modules | R-1, R-10, R-13, R-14 | row inference | | no instantaneous cycle | C-1 | the static input graph (CN-3) | @@ -79,7 +79,7 @@ runtime check. first load. A probe waits like a read (R-9). Only the first load is checked: an answer that lands after the first paint is a transition (H-2), legal anywhere. Whether a later hold shows on screen is the - affordances' business (`pending?`, `latest`, an `optimistic` guess; a + affordances' business (`pending?`, `latest`, a `guess` guess; a `loading` region is an escape, not an affordance), and a hold that shows none is Solid's run-time `SILENT_HOLD` report; the static form stays advice (99, the advisory rows). With SSR the held mount stays correct diff --git a/spec/09-modules.md b/spec/09-modules.md index 134b2541..8333995d 100644 --- a/spec/09-modules.md +++ b/spec/09-modules.md @@ -41,7 +41,7 @@ see the list at the end. ## MO-2 — The value boundary - **MO-2a.** An imported source is read-only in the importing module: - `x := …` and `optimistic x := …` through an import are + `x := …` and `guess x := …` through an import are `WRITE_TO_NON_SOURCE` (rule MO-2). The exporting module exports actions as its write interface (`export action select(id) { selected := id }`). - **MO-2b.** `refresh x` and `affects x` of an imported question are @@ -188,7 +188,7 @@ export component Save(props: { v: unrendered action go catches(nothing) }) text (S-module-3 is S-module-1 with only the text). Beyond decision 5's words, a line carries: - a source's kind: `export source x … = …` asks a question (ST-5), - `:= …` does not (`refresh x` and `optimistic x := …` depend on it); + `:= …` does not (`refresh x` and `guess x := …` depend on it); - a fn's parameters (`ref`, with their written types), `rejects(…)` (absent: the fn returns nothing that rejects — unlike a JS declaration's, MO-8a, where absent is `Error`), and `instantiates` (RG-6); diff --git a/spec/11-compiler.md b/spec/11-compiler.md index 223e84e3..81a43f08 100644 --- a/spec/11-compiler.md +++ b/spec/11-compiler.md @@ -198,7 +198,7 @@ spellings again. Compile-time only; nothing of it reaches run time. `latest` / `pending?` it carries the probe (R-9, R-12); a read of one field of props or a store records the field. - **CP-6c (writes and calls).** A write is `x := e` (W-1, with whether it - writes a path), `optimistic` (AC-3), `refresh` (AC-4) or `affects` + writes a path), `guess` (AC-3), `refresh` (AC-4) or `affects` (AC-5); `use p := e` is recorded for the diagnostic it gets (O-4). A call names a fn, action, server fn or JS import called by name. - **CP-6d (one walk, two implementations).** The facts are recorded by the diff --git a/spec/99-diagnostics.md b/spec/99-diagnostics.md index eff454bd..46e2272a 100644 --- a/spec/99-diagnostics.md +++ b/spec/99-diagnostics.md @@ -62,7 +62,7 @@ Bins: | `UNSTABLE_MEMO_OUTPUT` | R | data-dependent (partly S once ST decides structured equality) | | `UNSTABLE_LIST_IDENTITY` | S | ST: key is on the type; unkeyed `for` over records is a diagnostic | | `IMMUTABLE_UPDATE_IN_STORE` | U | ST: a structured equation is a delta over `pre`, not a replaced container | -| `OPTIMISTIC_REVERTED` | R | data-dependent | +| `GUESS_REVERTED` | R | data-dependent | | `GRAPH_GROWTH` | R | profiling | | `REACTIVITY_HALTED` | R | an error escaping every boundary | | `INVARIANT_VIOLATION` | R | runtime internal | @@ -99,11 +99,11 @@ named. | `WRITE_TO_NON_SOURCE` | S | W-1 | S-core-1, S-check-1 | | `DRAFT_WRITE_OUTSIDE_DRAFT` | S | ST-3a | S-store-2 | | `ASYNC_STORE_SETTER` | S | ST-3b | S-check-1 | -| `OPTIMISTIC_OUTSIDE_HOLD` | S | AC-3 (an `effect`'s apply block: never an action, AC-1b) | S-check-1, S-infer-1 | +| `GUESS_OUTSIDE_HOLD` | S | AC-3 (an `effect`'s apply block: never an action, AC-1b) | S-check-1, S-infer-1 | | `HELD_WRITE` | S | AC-1c | S-infer-2 | | `STALE_OWN_READ` | S | AC-1d | S-infer-5 | | `STALE_WRITE_BACK` | S | AC-1e | S-infer-6 | -| `OPTIMISTIC_NO_QUESTION` | S | AC-3 / ST-5c | S-check-1, S-solid-32 | +| `GUESS_NO_QUESTION` | S | AC-3 / ST-5c | S-check-1, S-solid-32 | | `INVALID_REFRESH_TARGET`, `INVALID_AFFECTS_TARGET` | S | AC-4, AC-5 | S-check-1 | | `REGION_ESCAPE` | S | O-3, O-3a, O-3b | S-check-1 (seven shapes), S-module-1 | | `UNSTABLE_LIST_IDENTITY` | S | ST-4b (narrow form: `map` into fresh records, unkeyed) | S-store-3 | @@ -163,6 +163,7 @@ section with a fix in `guide/diagnostics.md`, and the compiler's tests check its | `JS_FUNCTION`| `function f() { … }` | | `JS_RETURN` | `return
…
` in a component | | `JS_API` | `createSignal(…)`, `useState(…)`, … on a failing line | +| `OPTIMISTIC` | `optimistic x := …`, the old spelling of `guess x := …` (AC-3) | | `ASSIGNMENT` | `x = …`, `x += …`, `x++` where a write `x := …` was meant (W-1) | | `REACTIVE_WRITE_IN_OWNED_SCOPE` | `() => x := …`: the arrow needs a braced body (W-2, SC-3) | diff --git a/tooling/vscode-rx/markdown.js b/tooling/vscode-rx/markdown.js index 06c3da23..fda0ac9c 100644 --- a/tooling/vscode-rx/markdown.js +++ b/tooling/vscode-rx/markdown.js @@ -7,7 +7,7 @@ const typescript = require("highlight.js/lib/languages/typescript"); /** The words .rx adds to TypeScript's: the ones syntaxes/rx.tmLanguage.json highlights (spec/07). */ const RX_KEYWORDS = [ "source", "component", "action", "pure", "server", "client", "fn", "error", "context", "use", "provide", "pre", - "latest", "peek", "pending", "optimistic", "affects", "refresh", "errored", "loading", "fallback", "portal", + "latest", "peek", "pending", "guess", "affects", "refresh", "errored", "loading", "fallback", "portal", "deferred", "key", "rejects", "fails", "effect", ]; diff --git a/tooling/vscode-rx/syntaxes/rx.tmLanguage.json b/tooling/vscode-rx/syntaxes/rx.tmLanguage.json index 2381a671..1711ec8b 100644 --- a/tooling/vscode-rx/syntaxes/rx.tmLanguage.json +++ b/tooling/vscode-rx/syntaxes/rx.tmLanguage.json @@ -247,7 +247,7 @@ "name": "keyword.operator.reactive.rx" }, { - "match": "\\b(optimistic|refresh|affects)\\b", + "match": "\\b(guess|refresh|affects)\\b", "name": "keyword.other.action.rx" }, { From a2989597f9b5966f09fe02dd0e667124c2384d15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 03:59:07 +0000 Subject: [PATCH 2/7] Don't reserve `optimistic`: it names Solid's primitive, not a keyword Drops the OPTIMISTIC parse hint, its guide section, spec row and S-parse-hints variant; 07 decision 18 says why. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWP7LmQhib6M9zGnmBH --- compiler/src/parse.rs | 9 +-------- guide/diagnostics.md | 27 --------------------------- ref/src/parse.ts | 5 +---- scenarios/S-parse-hints.md | 8 -------- spec/07-surface.md | 4 ++-- spec/99-diagnostics.md | 1 - 6 files changed, 4 insertions(+), 50 deletions(-) diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index 370feb38..b0a4e017 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -42,7 +42,7 @@ re!(LINE_COMMENT, r"//.*$"); static KEYWORDS: LazyLock> = LazyLock::new(|| { [ - "source", "effect", "component", "ref", "await", "latest", "loading", "errored", "fallback", "on", "deferred", "action", "guess", "optimistic", "refresh", "affects", "if", "else", "pre", "peek", "fn", "let", "for", "in", "true", "false", "undefined", + "source", "effect", "component", "ref", "await", "latest", "loading", "errored", "fallback", "on", "deferred", "action", "guess", "refresh", "affects", "if", "else", "pre", "peek", "fn", "let", "for", "in", "true", "false", "undefined", "null", "provide", "context", "use", "import", "export", "from", "pure", "return", "type", "interface", "try", "catch", "finally", "throw", "portal", ] .into_iter() @@ -262,7 +262,6 @@ re!(JS_APIS, r"\b(useState|useReducer|useMemo|useCallback|useEffect|useRef|creat re!(JS_FUNCTION, r"^(export\s+)?(default\s+)?(async\s+)?function\b"); re!(JS_LOCAL, r"^(const|let|var)\b"); re!(JS_RETURN, r"^return\b"); -re!(OLD_OPTIMISTIC, r"(^|[{;]\s*)optimistic\s+[A-Za-z_$]"); re!(NAME_BEFORE, r"([A-Za-z_$][A-Za-z0-9_$.]*)\s*$"); re!(ARROW_BEFORE, r"=>\s*[A-Za-z_$][A-Za-z0-9_$.]*\s*$"); re!(OP_ASSIGN, r"^(\+\+|--|(\*\*|\|\||&&|\?\?|[-+*/%])=)"); @@ -279,9 +278,6 @@ pub fn hint(src: &str, i: usize, msg: &str) -> Option<(&'static str, String)> { let line = src[start..end].trim(); let before = &src[src[..i].rfind('\n').map(|j| j + 1).unwrap_or(0)..i]; let name = NAME_BEFORE.captures(before).map(|c| c[1].to_string()).unwrap_or_else(|| "x".into()); - if OLD_OPTIMISTIC.is_match(line) { - return Some(("OPTIMISTIC", "`optimistic` is spelled `guess`: `guess x := e` shows a guess until the action's answer replaces it (AC-3); it lowers to `createOptimistic`".into())); - } if msg.starts_with("W-2") && rest.starts_with(":=") && ARROW_BEFORE.is_match(before) { return Some(("REACTIVE_WRITE_IN_OWNED_SCOPE", format!("an arrow that writes takes a braced body, `() => {{ {} := … }}`: an expression-bodied arrow is a pure function (SC-3)", name))); } @@ -929,9 +925,6 @@ fn parse_path(c: &mut Cursor) -> R> { fn parse_stmt(c: &mut Cursor) -> R { c.ws(true); let start = c.i; - if c.keyword("optimistic") { - return c.fail("AC-3: `optimistic` is not a statement"); - } if c.keyword("guess") { c.ws(true); let name = c.ident_or("target")?; diff --git a/guide/diagnostics.md b/guide/diagnostics.md index 3b8177c5..b634c891 100644 --- a/guide/diagnostics.md +++ b/guide/diagnostics.md @@ -97,33 +97,6 @@ export default component App() { } ``` -### OPTIMISTIC - -A guess is written `guess x := e` (AC-3). The keyword was `optimistic` before 0.2; it lowers to the same -`createOptimistic`, and shows until the action's answer replaces it. - -```rx before -export default component App() { - source liked = false - action like() { - optimistic liked := true - refresh liked - } - -} -``` - -```rx after -export default component App() { - source liked = false - action like() { - guess liked := true - refresh liked - } - -} -``` - ### ASSIGNMENT A source is written with `:=` as a statement, in a handler or an action (W-1). There is no `=`, `+=` or `++` on state. diff --git a/ref/src/parse.ts b/ref/src/parse.ts index 319bea21..946d9182 100644 --- a/ref/src/parse.ts +++ b/ref/src/parse.ts @@ -15,8 +15,6 @@ export function hint(src: string, i: number, msg: string): [string, string] | nu const line = src.slice(lineStart(p), (src.indexOf("\n", p) + 1 || src.length + 1) - 1).trim(); const before = src.slice(lineStart(i), i); const name = /([A-Za-z_$][A-Za-z0-9_$.]*)\s*$/.exec(before)?.[1] ?? "x"; - if (/(^|[{;]\s*)optimistic\s+[A-Za-z_$]/.test(line)) - return ["OPTIMISTIC", "`optimistic` is spelled `guess`: `guess x := e` shows a guess until the action's answer replaces it (AC-3); it lowers to `createOptimistic`"]; if (msg.startsWith("W-2") && rest.startsWith(":=") && /=>\s*[A-Za-z_$][A-Za-z0-9_$.]*\s*$/.test(before)) return ["REACTIVE_WRITE_IN_OWNED_SCOPE", `an arrow that writes takes a braced body, \`() => { ${name} := … }\`: an expression-bodied arrow is a pure function (SC-3)`]; if (/^(\+\+|--|(\*\*|\|\||&&|\?\?|[-+*\/%])=|=(?![=>]))/.test(rest)) @@ -112,7 +110,7 @@ function parseClasses(c: Cursor): string[] | undefined { return out.some(n => n === "Error" || n === "unknown" || n === "any") ? undefined : out; } -const KEYWORDS = new Set(["source", "effect", "component", "ref", "await", "latest", "loading", "errored", "fallback", "on", "deferred", "action", "guess", "optimistic", "refresh", "affects", "if", "else", "pre", "peek", "fn", "let", "for", "in", "true", "false", "undefined", "null", "provide", "context", "use", "import", "export", "from", "pure", "return", "type", "interface", "try", "catch", "finally", "throw", "portal"]); +const KEYWORDS = new Set(["source", "effect", "component", "ref", "await", "latest", "loading", "errored", "fallback", "on", "deferred", "action", "guess", "refresh", "affects", "if", "else", "pre", "peek", "fn", "let", "for", "in", "true", "false", "undefined", "null", "provide", "context", "use", "import", "export", "from", "pure", "return", "type", "interface", "try", "catch", "finally", "throw", "portal"]); export function parseProgram(src: string): Program { const c = new Cursor(src); @@ -290,7 +288,6 @@ export function parseStmts(src: string): Stmt[] { function parseStmt(c: Cursor): Stmt { c.ws(); - if (c.keyword("optimistic")) c.fail("AC-3: `optimistic` is not a statement"); if (c.keyword("guess")) { c.ws(); const name = c.ident() ?? c.fail("target"); const path: Target["path"] = []; for (;;) { if (c.eat(".")) path.push(c.ident() ?? c.fail("member")); else if (c.eat("[")) { c.ws(); path.push(parseExpr(c)); c.ws(); c.expect("]"); } else break; } diff --git a/scenarios/S-parse-hints.md b/scenarios/S-parse-hints.md index 6ef2f673..5b49203f 100644 --- a/scenarios/S-parse-hints.md +++ b/scenarios/S-parse-hints.md @@ -54,14 +54,6 @@ export default component App() { } ``` -``` --- reject: OPTIMISTIC -export default component App() { - source liked = false - -} -``` - ``` -- reject: JS_API export default component App() { diff --git a/spec/07-surface.md b/spec/07-surface.md index f2df3daf..28d150b7 100644 --- a/spec/07-surface.md +++ b/spec/07-surface.md @@ -531,7 +531,7 @@ Expr ::= TSExpr with §3's removals and additions `createOptimistic`, which it still lowers to. A guess on state nothing will answer reads wrong on its face (`guess saveError := …`: the eval's pilot runs wrote that nine times as `optimistic`, `GUESS_NO_QUESTION`). - `optimistic` stays reserved, and a line that uses it is the parse error - `OPTIMISTIC`, which names `guess`. `source` was weighed against `state` + `optimistic` is not reserved: Solid's primitive keeps its name, and the + keyword need not. `source` was weighed against `state` and `signal` the same day and kept (decision 2): `signal` reads as atomic beside stores, and an equation is state too. diff --git a/spec/99-diagnostics.md b/spec/99-diagnostics.md index 46e2272a..54543cfd 100644 --- a/spec/99-diagnostics.md +++ b/spec/99-diagnostics.md @@ -163,7 +163,6 @@ section with a fix in `guide/diagnostics.md`, and the compiler's tests check its | `JS_FUNCTION`| `function f() { … }` | | `JS_RETURN` | `return
…
` in a component | | `JS_API` | `createSignal(…)`, `useState(…)`, … on a failing line | -| `OPTIMISTIC` | `optimistic x := …`, the old spelling of `guess x := …` (AC-3) | | `ASSIGNMENT` | `x = …`, `x += …`, `x++` where a write `x := …` was meant (W-1) | | `REACTIVE_WRITE_IN_OWNED_SCOPE` | `() => x := …`: the arrow needs a braced body (W-2, SC-3) | From bffcff84144dbfd14b852f7ac615f1cec216a444 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 05:41:06 +0000 Subject: [PATCH 3/7] Clear errors with onFlush, not a guess An error clear is a reset that lands now (EF-8), not a guess at the server's answer: `guess saveError := ""` worked only because the source was given a constant question to revert to. The guide's action example, the examples and the eval reference apps now write `onFlush(() => { saveError := "" })`, and those error sources are declared with `:=`. S-ai2-settings gains the flush instant. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWP7LmQhib6M9zGnmBH --- eval/README.md | 2 +- eval/reference/chain1/rx/comments.rx | 4 ++-- eval/reference/chain1/rx/issues.rx | 12 ++++++------ eval/reference/chain1/rx/labels.rx | 6 +++--- eval/reference/chain1/rx/settings.rx | 4 ++-- eval/reference/chain2/rx/comments.rx | 4 ++-- eval/reference/chain2/rx/issues.rx | 12 ++++++------ eval/reference/chain2/rx/labels.rx | 6 +++--- eval/reference/chain2/rx/settings.rx | 4 ++-- eval/reference/chain3/rx/comments.rx | 4 ++-- eval/reference/chain3/rx/issues.rx | 12 ++++++------ eval/reference/chain3/rx/labels.rx | 6 +++--- eval/reference/chain3/rx/settings.rx | 4 ++-- eval/reference/chain4/rx/comments.rx | 4 ++-- eval/reference/chain4/rx/issues.rx | 18 +++++++++--------- eval/reference/chain4/rx/labels.rx | 6 +++--- eval/reference/chain4/rx/settings.rx | 4 ++-- eval/reference/close/rx/main.rx | 8 ++++---- eval/reference/comments/rx/main.rx | 8 ++++---- eval/reference/count/rx/comments.rx | 4 ++-- eval/reference/count/rx/issues.rx | 12 ++++++------ eval/reference/count/rx/settings.rx | 4 ++-- eval/reference/desk/rx/comments.rx | 4 ++-- eval/reference/desk/rx/issues.rx | 12 ++++++------ eval/reference/desk/rx/settings.rx | 4 ++-- eval/reference/labels/rx/comments.rx | 4 ++-- eval/reference/labels/rx/issues.rx | 12 ++++++------ eval/reference/labels/rx/labels.rx | 6 +++--- eval/reference/labels/rx/settings.rx | 4 ++-- eval/reference/related/rx/comments.rx | 4 ++-- eval/reference/related/rx/issues.rx | 12 ++++++------ eval/reference/related/rx/settings.rx | 4 ++-- eval/reference/rename/rx/main.rx | 8 ++++---- eval/reference/settings/rx/settings.rx | 4 ++-- eval/reference/tracker/rx/issues.rx | 4 ++-- examples/ai2/settings/settings.rx | 8 ++++---- guide/async.md | 4 ++-- scenarios/S-ai2-settings.md | 12 +++++++----- 38 files changed, 128 insertions(+), 126 deletions(-) diff --git a/eval/README.md b/eval/README.md index ef04e4a6..6b576b49 100644 --- a/eval/README.md +++ b/eval/README.md @@ -86,7 +86,7 @@ predicted every frame right; the briefs here are stricter. - `tracker`: `assignToMe` cleared its error with a plain write (`assignError := ""`) inside an action that awaits, and a plain write in an async action is held until the action ends (AC-1), so the old error stayed up until the server answered. The fix is the `settings` app's own idiom: `assignError` a question source, - `guess assignError := ""`. + `guess assignError := ""` (since 2026-11-11 the references clear errors with `onFlush`, EF-8). ## Layout diff --git a/eval/reference/chain1/rx/comments.rx b/eval/reference/chain1/rx/comments.rx index 5476dd55..8517b69a 100644 --- a/eval/reference/chain1/rx/comments.rx +++ b/eval/reference/chain1/rx/comments.rx @@ -8,14 +8,14 @@ export component Comments(props: { id: number; user: string }) { source comments = await fetchComments(props.id) source sending: { id: number; author: string; text: string }[] = [] shownSending = sending.filter(s => s.id == props.id) - source postError: string | null = null + source postError: string | null := null source commentInput: string := "" // a post error belongs to the issue it was made on: selecting another clears it effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - guess postError := null + onFlush(() => { postError := null }) try { guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) diff --git a/eval/reference/chain1/rx/issues.rx b/eval/reference/chain1/rx/issues.rx index 2579778c..13838f04 100644 --- a/eval/reference/chain1/rx/issues.rx +++ b/eval/reference/chain1/rx/issues.rx @@ -16,12 +16,12 @@ export component IssuesPage() { source issues key id = await fetchIssues(filter) source detail = selected == null ? null : found(await fetchIssue(selected), selected) source titleInput: string := "" - source assignError: string = "" - source renameError: string = "" - source closeError: string = "" + source assignError: string := "" + source renameError: string := "" + source closeError: string := "" action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = issues.findIndex(t => t.id == id) guess issues[i].assignee := who @@ -33,7 +33,7 @@ export component IssuesPage() { } action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { @@ -51,7 +51,7 @@ export component IssuesPage() { } action closeOne(id: number) { - guess closeError := "" + onFlush(() => { closeError := "" }) try { guess issues := issues.filter(t => t.id != id) await closeIssue(id) diff --git a/eval/reference/chain1/rx/labels.rx b/eval/reference/chain1/rx/labels.rx index e37c0edc..1fb4ff95 100644 --- a/eval/reference/chain1/rx/labels.rx +++ b/eval/reference/chain1/rx/labels.rx @@ -12,14 +12,14 @@ export component Labels(props: { id: number }) { ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), ...adding.filter(a => a.id == props.id).map(a => a.label), ] - source labelError: string = "" + source labelError: string := "" source labelInput: string := "" // a label error belongs to the issue it was made on: selecting another clears it effect (props.id) { labelError := "" } action add(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) @@ -30,7 +30,7 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) diff --git a/eval/reference/chain1/rx/settings.rx b/eval/reference/chain1/rx/settings.rx index 6c45198c..5093aaca 100644 --- a/eval/reference/chain1/rx/settings.rx +++ b/eval/reference/chain1/rx/settings.rx @@ -13,11 +13,11 @@ fn validName(name: string): string => export component SettingsPage() { source settings: Settings = await loadSettings() - source saveError: string = "" + source saveError: string := "" action save(next: Settings) { guess settings := next - guess saveError := "" + onFlush(() => { saveError := "" }) affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/chain2/rx/comments.rx b/eval/reference/chain2/rx/comments.rx index 5476dd55..8517b69a 100644 --- a/eval/reference/chain2/rx/comments.rx +++ b/eval/reference/chain2/rx/comments.rx @@ -8,14 +8,14 @@ export component Comments(props: { id: number; user: string }) { source comments = await fetchComments(props.id) source sending: { id: number; author: string; text: string }[] = [] shownSending = sending.filter(s => s.id == props.id) - source postError: string | null = null + source postError: string | null := null source commentInput: string := "" // a post error belongs to the issue it was made on: selecting another clears it effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - guess postError := null + onFlush(() => { postError := null }) try { guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) diff --git a/eval/reference/chain2/rx/issues.rx b/eval/reference/chain2/rx/issues.rx index 300add69..a400f55d 100644 --- a/eval/reference/chain2/rx/issues.rx +++ b/eval/reference/chain2/rx/issues.rx @@ -24,13 +24,13 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { source issues key id = await fetchIssues(filter) source detail = selected == null ? null : found(await fetchIssue(selected), selected) source titleInput: string := "" - source assignError: string = "" - source renameError: string = "" - source closeError: string = "" + source assignError: string := "" + source renameError: string := "" + source closeError: string := "" action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = issues.findIndex(t => t.id == id) guess issues[i].assignee := who @@ -42,7 +42,7 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { } action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { @@ -60,7 +60,7 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { } action closeOne(id: number) { - guess closeError := "" + onFlush(() => { closeError := "" }) try { guess issues := issues.filter(t => t.id != id) await closeIssue(id) diff --git a/eval/reference/chain2/rx/labels.rx b/eval/reference/chain2/rx/labels.rx index e37c0edc..1fb4ff95 100644 --- a/eval/reference/chain2/rx/labels.rx +++ b/eval/reference/chain2/rx/labels.rx @@ -12,14 +12,14 @@ export component Labels(props: { id: number }) { ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), ...adding.filter(a => a.id == props.id).map(a => a.label), ] - source labelError: string = "" + source labelError: string := "" source labelInput: string := "" // a label error belongs to the issue it was made on: selecting another clears it effect (props.id) { labelError := "" } action add(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) @@ -30,7 +30,7 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) diff --git a/eval/reference/chain2/rx/settings.rx b/eval/reference/chain2/rx/settings.rx index 6c45198c..5093aaca 100644 --- a/eval/reference/chain2/rx/settings.rx +++ b/eval/reference/chain2/rx/settings.rx @@ -13,11 +13,11 @@ fn validName(name: string): string => export component SettingsPage() { source settings: Settings = await loadSettings() - source saveError: string = "" + source saveError: string := "" action save(next: Settings) { guess settings := next - guess saveError := "" + onFlush(() => { saveError := "" }) affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/chain3/rx/comments.rx b/eval/reference/chain3/rx/comments.rx index 5476dd55..8517b69a 100644 --- a/eval/reference/chain3/rx/comments.rx +++ b/eval/reference/chain3/rx/comments.rx @@ -8,14 +8,14 @@ export component Comments(props: { id: number; user: string }) { source comments = await fetchComments(props.id) source sending: { id: number; author: string; text: string }[] = [] shownSending = sending.filter(s => s.id == props.id) - source postError: string | null = null + source postError: string | null := null source commentInput: string := "" // a post error belongs to the issue it was made on: selecting another clears it effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - guess postError := null + onFlush(() => { postError := null }) try { guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) diff --git a/eval/reference/chain3/rx/issues.rx b/eval/reference/chain3/rx/issues.rx index 90d04f88..a19469ef 100644 --- a/eval/reference/chain3/rx/issues.rx +++ b/eval/reference/chain3/rx/issues.rx @@ -25,13 +25,13 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel source issues key id = await fetchIssues(filter) source detail = selected == null ? null : found(await fetchIssue(selected), selected) source titleInput: string := "" - source assignError: string = "" - source renameError: string = "" - source closeError: string = "" + source assignError: string := "" + source renameError: string := "" + source closeError: string := "" action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = issues.findIndex(t => t.id == id) guess issues[i].assignee := who @@ -43,7 +43,7 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel } action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { @@ -61,7 +61,7 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel } action closeOne(id: number) { - guess closeError := "" + onFlush(() => { closeError := "" }) try { guess issues := issues.filter(t => t.id != id) await closeIssue(id) diff --git a/eval/reference/chain3/rx/labels.rx b/eval/reference/chain3/rx/labels.rx index e37c0edc..1fb4ff95 100644 --- a/eval/reference/chain3/rx/labels.rx +++ b/eval/reference/chain3/rx/labels.rx @@ -12,14 +12,14 @@ export component Labels(props: { id: number }) { ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), ...adding.filter(a => a.id == props.id).map(a => a.label), ] - source labelError: string = "" + source labelError: string := "" source labelInput: string := "" // a label error belongs to the issue it was made on: selecting another clears it effect (props.id) { labelError := "" } action add(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) @@ -30,7 +30,7 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) diff --git a/eval/reference/chain3/rx/settings.rx b/eval/reference/chain3/rx/settings.rx index 6c45198c..5093aaca 100644 --- a/eval/reference/chain3/rx/settings.rx +++ b/eval/reference/chain3/rx/settings.rx @@ -13,11 +13,11 @@ fn validName(name: string): string => export component SettingsPage() { source settings: Settings = await loadSettings() - source saveError: string = "" + source saveError: string := "" action save(next: Settings) { guess settings := next - guess saveError := "" + onFlush(() => { saveError := "" }) affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/chain4/rx/comments.rx b/eval/reference/chain4/rx/comments.rx index 5476dd55..8517b69a 100644 --- a/eval/reference/chain4/rx/comments.rx +++ b/eval/reference/chain4/rx/comments.rx @@ -8,14 +8,14 @@ export component Comments(props: { id: number; user: string }) { source comments = await fetchComments(props.id) source sending: { id: number; author: string; text: string }[] = [] shownSending = sending.filter(s => s.id == props.id) - source postError: string | null = null + source postError: string | null := null source commentInput: string := "" // a post error belongs to the issue it was made on: selecting another clears it effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - guess postError := null + onFlush(() => { postError := null }) try { guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) diff --git a/eval/reference/chain4/rx/issues.rx b/eval/reference/chain4/rx/issues.rx index 488ee123..97122ac4 100644 --- a/eval/reference/chain4/rx/issues.rx +++ b/eval/reference/chain4/rx/issues.rx @@ -25,18 +25,18 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel source issues key id = await fetchIssues(filter) source detail = selected == null ? null : found(await fetchIssue(selected), selected) source titleInput: string := "" - source assignError: string = "" - source renameError: string = "" - source closeError: string = "" + source assignError: string := "" + source renameError: string := "" + source closeError: string := "" // the newest row action's failure; row actions and the detail's leave each other's errors alone - source rowError: string = "" + source rowError: string := "" action assignTo(id: number, who: string, fromRow: boolean) { if (fromRow) { - guess rowError := "" + onFlush(() => { rowError := "" }) } else { - guess assignError := "" + onFlush(() => { assignError := "" }) } try { let i = issues.findIndex(t => t.id == id) @@ -53,7 +53,7 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel } action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { @@ -72,9 +72,9 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHel action closeOne(id: number, fromRow: boolean) { if (fromRow) { - guess rowError := "" + onFlush(() => { rowError := "" }) } else { - guess closeError := "" + onFlush(() => { closeError := "" }) } try { guess issues := issues.filter(t => t.id != id) diff --git a/eval/reference/chain4/rx/labels.rx b/eval/reference/chain4/rx/labels.rx index e37c0edc..1fb4ff95 100644 --- a/eval/reference/chain4/rx/labels.rx +++ b/eval/reference/chain4/rx/labels.rx @@ -12,14 +12,14 @@ export component Labels(props: { id: number }) { ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), ...adding.filter(a => a.id == props.id).map(a => a.label), ] - source labelError: string = "" + source labelError: string := "" source labelInput: string := "" // a label error belongs to the issue it was made on: selecting another clears it effect (props.id) { labelError := "" } action add(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) @@ -30,7 +30,7 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) diff --git a/eval/reference/chain4/rx/settings.rx b/eval/reference/chain4/rx/settings.rx index 6c45198c..5093aaca 100644 --- a/eval/reference/chain4/rx/settings.rx +++ b/eval/reference/chain4/rx/settings.rx @@ -13,11 +13,11 @@ fn validName(name: string): string => export component SettingsPage() { source settings: Settings = await loadSettings() - source saveError: string = "" + source saveError: string := "" action save(next: Settings) { guess settings := next - guess saveError := "" + onFlush(() => { saveError := "" }) affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/close/rx/main.rx b/eval/reference/close/rx/main.rx index 1f04fbb0..738dc2b3 100644 --- a/eval/reference/close/rx/main.rx +++ b/eval/reference/close/rx/main.rx @@ -11,10 +11,10 @@ export default component App() { source filter: string := "open" source selected: number | null := null source issues key id = await fetchIssues(filter) - source assignError: string = "" - source closeError: string = "" + source assignError: string := "" + source closeError: string := "" action closeOne(id: number) { - guess closeError := "" + onFlush(() => { closeError := "" }) try { guess issues := keep(issues, t => t.id != id) await closeIssue(id) @@ -25,7 +25,7 @@ export default component App() { } action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = indexOf(issues, t => t.id == id) guess issues[i].assignee := who diff --git a/eval/reference/comments/rx/main.rx b/eval/reference/comments/rx/main.rx index 85d7afb3..d9f2f0d5 100644 --- a/eval/reference/comments/rx/main.rx +++ b/eval/reference/comments/rx/main.rx @@ -11,13 +11,13 @@ export default component App() { source filter: string := "open" source selected: number | null := null source issues key id = await fetchIssues(filter) - source assignError: string = "" - source postError: string = "" + source assignError: string := "" + source postError: string := "" source commentInput: string := "" source comments = selected == null ? [] : await fetchComments(selected) action postComment(id: number, author: string, text: string) { - guess postError := "" + onFlush(() => { postError := "" }) try { guess comments := [...comments, { author: author, text: `${text} (sending…)` }] await addComment(id, author, text) @@ -29,7 +29,7 @@ export default component App() { action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = indexOf(issues, t => t.id == id) guess issues[i].assignee := who diff --git a/eval/reference/count/rx/comments.rx b/eval/reference/count/rx/comments.rx index 5476dd55..8517b69a 100644 --- a/eval/reference/count/rx/comments.rx +++ b/eval/reference/count/rx/comments.rx @@ -8,14 +8,14 @@ export component Comments(props: { id: number; user: string }) { source comments = await fetchComments(props.id) source sending: { id: number; author: string; text: string }[] = [] shownSending = sending.filter(s => s.id == props.id) - source postError: string | null = null + source postError: string | null := null source commentInput: string := "" // a post error belongs to the issue it was made on: selecting another clears it effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - guess postError := null + onFlush(() => { postError := null }) try { guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) diff --git a/eval/reference/count/rx/issues.rx b/eval/reference/count/rx/issues.rx index 5825e67e..0bac8b0e 100644 --- a/eval/reference/count/rx/issues.rx +++ b/eval/reference/count/rx/issues.rx @@ -23,13 +23,13 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { source issues key id = await fetchIssues(filter) source detail = selected == null ? null : found(await fetchIssue(selected), selected) source titleInput: string := "" - source assignError: string = "" - source renameError: string = "" - source closeError: string = "" + source assignError: string := "" + source renameError: string := "" + source closeError: string := "" action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = issues.findIndex(t => t.id == id) guess issues[i].assignee := who @@ -41,7 +41,7 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { } action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { @@ -59,7 +59,7 @@ export component IssuesPage(props: { onOpenCount: (n: number) => void }) { } action closeOne(id: number) { - guess closeError := "" + onFlush(() => { closeError := "" }) try { guess issues := issues.filter(t => t.id != id) await closeIssue(id) diff --git a/eval/reference/count/rx/settings.rx b/eval/reference/count/rx/settings.rx index 6c45198c..5093aaca 100644 --- a/eval/reference/count/rx/settings.rx +++ b/eval/reference/count/rx/settings.rx @@ -13,11 +13,11 @@ fn validName(name: string): string => export component SettingsPage() { source settings: Settings = await loadSettings() - source saveError: string = "" + source saveError: string := "" action save(next: Settings) { guess settings := next - guess saveError := "" + onFlush(() => { saveError := "" }) affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/desk/rx/comments.rx b/eval/reference/desk/rx/comments.rx index 5476dd55..8517b69a 100644 --- a/eval/reference/desk/rx/comments.rx +++ b/eval/reference/desk/rx/comments.rx @@ -8,14 +8,14 @@ export component Comments(props: { id: number; user: string }) { source comments = await fetchComments(props.id) source sending: { id: number; author: string; text: string }[] = [] shownSending = sending.filter(s => s.id == props.id) - source postError: string | null = null + source postError: string | null := null source commentInput: string := "" // a post error belongs to the issue it was made on: selecting another clears it effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - guess postError := null + onFlush(() => { postError := null }) try { guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) diff --git a/eval/reference/desk/rx/issues.rx b/eval/reference/desk/rx/issues.rx index 36fc5323..7cd02652 100644 --- a/eval/reference/desk/rx/issues.rx +++ b/eval/reference/desk/rx/issues.rx @@ -15,12 +15,12 @@ export component IssuesPage() { source issues key id = await fetchIssues(filter) source detail = selected == null ? null : found(await fetchIssue(selected), selected) source titleInput: string := "" - source assignError: string = "" - source renameError: string = "" - source closeError: string = "" + source assignError: string := "" + source renameError: string := "" + source closeError: string := "" action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = issues.findIndex(t => t.id == id) guess issues[i].assignee := who @@ -32,7 +32,7 @@ export component IssuesPage() { } action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { @@ -50,7 +50,7 @@ export component IssuesPage() { } action closeOne(id: number) { - guess closeError := "" + onFlush(() => { closeError := "" }) try { guess issues := issues.filter(t => t.id != id) await closeIssue(id) diff --git a/eval/reference/desk/rx/settings.rx b/eval/reference/desk/rx/settings.rx index 6c45198c..5093aaca 100644 --- a/eval/reference/desk/rx/settings.rx +++ b/eval/reference/desk/rx/settings.rx @@ -13,11 +13,11 @@ fn validName(name: string): string => export component SettingsPage() { source settings: Settings = await loadSettings() - source saveError: string = "" + source saveError: string := "" action save(next: Settings) { guess settings := next - guess saveError := "" + onFlush(() => { saveError := "" }) affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/labels/rx/comments.rx b/eval/reference/labels/rx/comments.rx index 5476dd55..8517b69a 100644 --- a/eval/reference/labels/rx/comments.rx +++ b/eval/reference/labels/rx/comments.rx @@ -8,14 +8,14 @@ export component Comments(props: { id: number; user: string }) { source comments = await fetchComments(props.id) source sending: { id: number; author: string; text: string }[] = [] shownSending = sending.filter(s => s.id == props.id) - source postError: string | null = null + source postError: string | null := null source commentInput: string := "" // a post error belongs to the issue it was made on: selecting another clears it effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - guess postError := null + onFlush(() => { postError := null }) try { guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) diff --git a/eval/reference/labels/rx/issues.rx b/eval/reference/labels/rx/issues.rx index 2579778c..13838f04 100644 --- a/eval/reference/labels/rx/issues.rx +++ b/eval/reference/labels/rx/issues.rx @@ -16,12 +16,12 @@ export component IssuesPage() { source issues key id = await fetchIssues(filter) source detail = selected == null ? null : found(await fetchIssue(selected), selected) source titleInput: string := "" - source assignError: string = "" - source renameError: string = "" - source closeError: string = "" + source assignError: string := "" + source renameError: string := "" + source closeError: string := "" action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = issues.findIndex(t => t.id == id) guess issues[i].assignee := who @@ -33,7 +33,7 @@ export component IssuesPage() { } action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { @@ -51,7 +51,7 @@ export component IssuesPage() { } action closeOne(id: number) { - guess closeError := "" + onFlush(() => { closeError := "" }) try { guess issues := issues.filter(t => t.id != id) await closeIssue(id) diff --git a/eval/reference/labels/rx/labels.rx b/eval/reference/labels/rx/labels.rx index e37c0edc..1fb4ff95 100644 --- a/eval/reference/labels/rx/labels.rx +++ b/eval/reference/labels/rx/labels.rx @@ -12,14 +12,14 @@ export component Labels(props: { id: number }) { ...labels.filter(l => !removing.some(r => r.id == props.id && r.label == l)), ...adding.filter(a => a.id == props.id).map(a => a.label), ] - source labelError: string = "" + source labelError: string := "" source labelInput: string := "" // a label error belongs to the issue it was made on: selecting another clears it effect (props.id) { labelError := "" } action add(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess adding := [...adding, { id: id, label: label }] await addLabel(id, label) @@ -30,7 +30,7 @@ export component Labels(props: { id: number }) { } action remove(id: number, label: string) { - guess labelError := "" + onFlush(() => { labelError := "" }) try { guess removing := [...removing, { id: id, label: label }] await removeLabel(id, label) diff --git a/eval/reference/labels/rx/settings.rx b/eval/reference/labels/rx/settings.rx index 6c45198c..5093aaca 100644 --- a/eval/reference/labels/rx/settings.rx +++ b/eval/reference/labels/rx/settings.rx @@ -13,11 +13,11 @@ fn validName(name: string): string => export component SettingsPage() { source settings: Settings = await loadSettings() - source saveError: string = "" + source saveError: string := "" action save(next: Settings) { guess settings := next - guess saveError := "" + onFlush(() => { saveError := "" }) affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/related/rx/comments.rx b/eval/reference/related/rx/comments.rx index 5476dd55..8517b69a 100644 --- a/eval/reference/related/rx/comments.rx +++ b/eval/reference/related/rx/comments.rx @@ -8,14 +8,14 @@ export component Comments(props: { id: number; user: string }) { source comments = await fetchComments(props.id) source sending: { id: number; author: string; text: string }[] = [] shownSending = sending.filter(s => s.id == props.id) - source postError: string | null = null + source postError: string | null := null source commentInput: string := "" // a post error belongs to the issue it was made on: selecting another clears it effect (props.id) { postError := null } action postComment(id: number, author: string, text: string) { - guess postError := null + onFlush(() => { postError := null }) try { guess sending := [...sending, { id: id, author: author, text: text }] await addComment(id, author, text) diff --git a/eval/reference/related/rx/issues.rx b/eval/reference/related/rx/issues.rx index 878833c3..db6ad3f6 100644 --- a/eval/reference/related/rx/issues.rx +++ b/eval/reference/related/rx/issues.rx @@ -16,12 +16,12 @@ export component IssuesPage(props: { onOpenHelp: (query: string, id: number) => source issues key id = await fetchIssues(filter) source detail = selected == null ? null : found(await fetchIssue(selected), selected) source titleInput: string := "" - source assignError: string = "" - source renameError: string = "" - source closeError: string = "" + source assignError: string := "" + source renameError: string := "" + source closeError: string := "" action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = issues.findIndex(t => t.id == id) guess issues[i].assignee := who @@ -33,7 +33,7 @@ export component IssuesPage(props: { onOpenHelp: (query: string, id: number) => } action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = issues.findIndex(t => t.id == id) if (i >= 0) { @@ -51,7 +51,7 @@ export component IssuesPage(props: { onOpenHelp: (query: string, id: number) => } action closeOne(id: number) { - guess closeError := "" + onFlush(() => { closeError := "" }) try { guess issues := issues.filter(t => t.id != id) await closeIssue(id) diff --git a/eval/reference/related/rx/settings.rx b/eval/reference/related/rx/settings.rx index 6c45198c..5093aaca 100644 --- a/eval/reference/related/rx/settings.rx +++ b/eval/reference/related/rx/settings.rx @@ -13,11 +13,11 @@ fn validName(name: string): string => export component SettingsPage() { source settings: Settings = await loadSettings() - source saveError: string = "" + source saveError: string := "" action save(next: Settings) { guess settings := next - guess saveError := "" + onFlush(() => { saveError := "" }) affects settings try { let saved = await saveSettings(next) diff --git a/eval/reference/rename/rx/main.rx b/eval/reference/rename/rx/main.rx index 8bf3450f..55ce3ff3 100644 --- a/eval/reference/rename/rx/main.rx +++ b/eval/reference/rename/rx/main.rx @@ -11,13 +11,13 @@ export default component App() { source filter: string := "open" source selected: number | null := null source issues key id = await fetchIssues(filter) - source assignError: string = "" - source renameError: string = "" + source assignError: string := "" + source renameError: string := "" source titleInput: string := "" source detail = selected == null ? null : found(await fetchIssue(selected), selected) action renameTo(id: number, title: string) { - guess renameError := "" + onFlush(() => { renameError := "" }) try { let i = indexOf(issues, t => t.id == id) if (i >= 0) { @@ -35,7 +35,7 @@ export default component App() { } action assignTo(id: number, who: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) try { let i = indexOf(issues, t => t.id == id) guess issues[i].assignee := who diff --git a/eval/reference/settings/rx/settings.rx b/eval/reference/settings/rx/settings.rx index f57f9cab..7d93eab6 100644 --- a/eval/reference/settings/rx/settings.rx +++ b/eval/reference/settings/rx/settings.rx @@ -22,7 +22,7 @@ export source settings: Settings = await loadSettings() // What the last save failed with, or "". A question with a constant truth, so // a new save can clear it as a guess the moment it starts (ST-5a). -export source saveError: string = "" +export source saveError: string := "" export action retryLoad() { refresh settings @@ -30,7 +30,7 @@ export action retryLoad() { export action save(next: Settings) { guess settings := next // the page shows what is being saved, now (H-11) - guess saveError := "" // and stops showing the previous failure + onFlush(() => { saveError := "" }) // and stops showing the previous failure affects settings // `pending? settings` is true until the save ends (AC-5) try { let saved = await saveSettings(next) diff --git a/eval/reference/tracker/rx/issues.rx b/eval/reference/tracker/rx/issues.rx index 61cb3b84..43e64616 100644 --- a/eval/reference/tracker/rx/issues.rx +++ b/eval/reference/tracker/rx/issues.rx @@ -24,7 +24,7 @@ export source selected: number | null := null // The server's message from the last declined assignment ("" when none). A // question with a constant truth, so a new assignment can clear it as a guess // the moment it starts (ST-5a); a plain write would be held with the action. -export source assignError: string = "" +export source assignError: string := "" // Only asked while something is selected (E-11: the null arm opens no flight). export detail = selected == null ? null : found(await fetchIssue(selected), selected) @@ -42,7 +42,7 @@ export action select(id: number) { } export action assignToMe(id: number, user: string) { - guess assignError := "" + onFlush(() => { assignError := "" }) guess issues := list => withAssignee(list, id, user) try { await assign(id, user) diff --git a/examples/ai2/settings/settings.rx b/examples/ai2/settings/settings.rx index f57f9cab..2a60047a 100644 --- a/examples/ai2/settings/settings.rx +++ b/examples/ai2/settings/settings.rx @@ -20,9 +20,9 @@ export fn validate(s: Settings): Settings => // The truth. A source with a question (ST-5): `refresh` re-asks it, a save guesses it. export source settings: Settings = await loadSettings() -// What the last save failed with, or "". A question with a constant truth, so -// a new save can clear it as a guess the moment it starts (ST-5a). -export source saveError: string = "" +// What the last save failed with, or "". A new save clears it in an onFlush run +// of its own, so it goes the moment the save starts (EF-8). +export source saveError: string := "" export action retryLoad() { refresh settings @@ -30,7 +30,7 @@ export action retryLoad() { export action save(next: Settings) { guess settings := next // the page shows what is being saved, now (H-11) - guess saveError := "" // and stops showing the previous failure + onFlush(() => { saveError := "" }) // and stops showing the previous failure affects settings // `pending? settings` is true until the save ends (AC-5) try { let saved = await saveSettings(next) diff --git a/guide/async.md b/guide/async.md index 08f981e0..635abcd3 100644 --- a/guide/async.md +++ b/guide/async.md @@ -61,11 +61,11 @@ forecast = city == "" ? null : city.length < 3 ? throw TooShort(city) : await fe ```rx export source profile: Profile = await loadProfile() // a source with a question: it can take a guess -export source saveError: string = "" +export source saveError: string := "" export action save(next: Profile) { guess profile := next // shows now; reverts by itself if the save fails - guess saveError := "" // the old error goes now + onFlush(() => { saveError := "" }) // the old error goes now, in its own instant (EF-8) affects profile // `pending? profile` is true until the save ends try { let answer = await saveProfile(next) diff --git a/scenarios/S-ai2-settings.md b/scenarios/S-ai2-settings.md index 3cf087e3..764d27d6 100644 --- a/scenarios/S-ai2-settings.md +++ b/scenarios/S-ai2-settings.md @@ -22,8 +22,9 @@ file: ../examples/ai2/settings/main.rx | 7 | handler `save(draft)` | `Saved: Bea · dark · compact (saving…) Profile [compact] Display name 3/20 Appearance [compact] Theme: dark light dark Toggle density Preview: dark / compact Save` | `pending? settings` true (affects) | | 8 | reject "saveSettings:{"name":"Bea","theme":"dark","density":"compact"}" "quota exceeded" | `Saved: Ann · light · compact Save failed: quota exceeded Profile [compact] Display name 3/20 Appearance [compact] Theme: dark light dark Toggle density Preview: dark / compact Save` | `pending? settings` false | | 9 | handler `draftDensity := "comfortable"` | `Saved: Ann · light · compact Save failed: quota exceeded Profile [comfortable] Display name 3/20 Appearance [comfortable] Theme: dark light dark Toggle density Preview: dark / comfortable Save` | — | -| 10 | handler `save(draft)` | `Saved: Bea · dark · comfortable (saving…) Profile [comfortable] Display name 3/20 Appearance [comfortable] Theme: dark light dark Toggle density Preview: dark / comfortable Save` | `pending? settings` true | -| 11 | resolve "saveSettings:{"name":"Bea","theme":"dark","density":"comfortable"}" {"name":"Bea","theme":"dark","density":"comfortable"} | `Saved: Bea · dark · comfortable Profile [comfortable] Display name 3/20 Appearance [comfortable] Theme: dark light dark Toggle density Preview: dark / comfortable Save` | `pending? settings` false | +| 10 | handler `save(draft)` | `Saved: Bea · dark · comfortable (saving…) Save failed: quota exceeded Profile [comfortable] Display name 3/20 Appearance [comfortable] Theme: dark light dark Toggle density Preview: dark / comfortable Save` | `pending? settings` true | +| 11 | (flush) | `Saved: Bea · dark · comfortable (saving…) Profile [comfortable] Display name 3/20 Appearance [comfortable] Theme: dark light dark Toggle density Preview: dark / comfortable Save` | `pending? settings` true | +| 12 | resolve "saveSettings:{"name":"Bea","theme":"dark","density":"comfortable"}" {"name":"Bea","theme":"dark","density":"comfortable"} | `Saved: Bea · dark · comfortable Profile [comfortable] Display name 3/20 Appearance [comfortable] Theme: dark light dark Toggle density Preview: dark / comfortable Save` | `pending? settings` false | Why, one line per instant: @@ -34,8 +35,9 @@ Why, one line per instant: - **4.** An empty name makes `validate(draft)` fail with `NameRequired`. The `catch` turns that into its message, so `if nameError` opens the message branch (R-f-4, RG-1). The counter reads `0/20`. - **5.** 27 characters fails with `NameTooLong(27)`. `nameError` goes from one truthy string to another, so the branch is kept and the narrowed binding updates (RG-1b, RG-1c). - **6.** Two writes in one handler are one instant (W-3). The name is valid again, so `nameError` is "" and the branch closes. The ThemePicker label and the Preview both show the theme through the context. The saved line does not move. -- **7.** `save` guesses `settings := draft` and `saveError := ""`, then `affects settings` and awaits. The guess's readers form a lane and show at once (H-11, H-7). `pending? settings` is true because of `affects` (AC-5, H-9). The form is not recreated (the `if` goes truthy→truthy) and its draft does not change. +- **7.** `save` guesses `settings := draft`, clears `saveError` in an `onFlush` run of its own (EF-8; nothing to clear yet), then `affects settings` and awaits. The guess's readers form a lane and show at once (H-11, H-7). `pending? settings` is true because of `affects` (AC-5, H-9). The form is not recreated (the `if` goes truthy→truthy) and its draft does not change. - **8.** The rejection throws at the `await`. The `catch` writes `saveError` inside the still-open change (AC-7). At the end of the body the guess on `settings` is corrected to the unchanged truth (Ann), and that commits in one frame with the error message (H-11, AC-1). The draft is local to the form, so it keeps Bea/dark. - **9.** A plain local write changes the `density` provision, so both Panels and the Preview update. Nothing else moves. -- **10.** A new key (the density differs) means a new flight. The `settings` guess shows now. The `saveError` guess `""` closes the "Save failed" branch now (H-11; a lane condition's branch change attaches immediately, H-7). `pending? settings` is true. -- **11.** The answer is written as the truth (`settings := saved`), equal to the guess, so it confirms silently. The plain `saveError := ""` is the truth for that source too, so it does not revert to "quota exceeded". The change commits, and `pending? settings` is false. +- **10.** A new key (the density differs) means a new flight. The `settings` guess shows now (H-11). `pending? settings` is true. The old error is still up: its clear is registered with `onFlush`, not written by this run. +- **11.** The `onFlush` run is a change of its own and commits at once, while the save is still out (EF-8, S-flush-1): the "Save failed" branch closes. +- **12.** The answer is written as the truth (`settings := saved`), equal to the guess, so it confirms silently. The plain `saveError := ""` is the truth for that source too, so it does not revert to "quota exceeded". The change commits, and `pending? settings` is false. From b01a77b673c76dc520b8829183592f3e83a9fbec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 05:53:38 +0000 Subject: [PATCH 4/7] Allow a guess over an equation (Solid's createOptimistic(fn)) `x = e` then `guess x := v` in an action is legal (06 ST-5c, 07 decision 19): a guess is not a write of the truth, and Solid's derived createOptimistic(fn) has a setter that only guesses. A plain `x := v` on an equation stays WRITE_TO_NON_SOURCE; `props.x` and parameters stay refused (O-4). New code GUESS_ON_DEFERRED: a deferred equation lags its truth (V-16). Lowering: a guessed equation is createOptimistic(fn), or createOptimisticStore(fn, seed) when keyed or guessed by path, on the client, the server (matching owner trees for hydration) and in the oracle. Both checkers and both emitters agree (crate parity, lowered alike). Scenario S-guess-equation covers an equation of a source and a child's equation of a prop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWP7LmQhib6M9zGnmBH --- compiler/src/check.rs | 17 +++++- compiler/src/emit.rs | 22 +++++++- guide/async.md | 2 +- guide/diagnostics.md | 30 +++++++++- guide/reference.md | 2 +- ref/compile/emit.ts | 9 ++- ref/compile/rt-server.ts | 3 +- ref/compile/rt-web.ts | 7 ++- ref/compile/rt.ts | 7 ++- ref/oracle/lower.ts | 13 +++++ ref/src/check.ts | 7 ++- ref/src/runtime.ts | 2 +- scenarios/S-guess-equation.md | 101 ++++++++++++++++++++++++++++++++++ spec/06-stores.md | 17 +++++- spec/07-surface.md | 7 +++ spec/99-diagnostics.md | 3 +- 16 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 scenarios/S-guess-equation.md diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 7a26a9a5..8dcec25c 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -221,6 +221,8 @@ struct Sym<'a> { /// O-3: not a name but a prop's argument, stored — its captures are the caller's ("*": all of props) prop_of: Option<(RId, String)>, maps_records: bool, + /// a declared equation, and whether it is deferred: a guess may sit over one that is not (AC-3, ST-5c) + eq_decl: Option, rows: Option>, comp: Option<&'a Decl>, expr: Option<(&'a Expr, EId)>, @@ -253,6 +255,7 @@ impl<'a> Sym<'a> { ret_captures: None, prop_of: None, maps_records: false, + eq_decl: None, rows: None, comp: None, expr: None, @@ -1166,7 +1169,8 @@ impl<'a> Checker<'a> { let r = self.region(Some(region), "component", true, Some(name.clone())); sym.comp_region = Some(r); } - Decl::Eq { e, key, .. } => { + Decl::Eq { e, key, deferred, .. } => { + sym.eq_decl = Some(*deferred); sym.maps_records = key.is_none() && maps_into_records(e); if let ExprKind::Id(n) = &e.k { if let Some(t) = self.lookup(env, n) { @@ -2332,7 +2336,16 @@ impl<'a> Checker<'a> { self.model.write(tsym, op, !target.path.is_empty()); if let Some(t) = tsym { let k = self.syms[t].kind; - if k != SymKind::Source { + // AC-3: a guess may sit over an equation (Solid's createOptimistic(fn)); it is not a write of the truth + let guessable = op == "optimistic" && k == SymKind::Eq && self.syms[t].eq_decl.is_some() && !self.syms[t].param && self.syms[t].imported.is_none(); + if guessable && self.syms[t].eq_decl == Some(true) { + self.err( + "GUESS_ON_DEFERRED", + "AC-3", + format!("{} is deferred: its value is a lagging copy (V-16), so nothing would confirm a guess on it — guess the equation it lags", target.name), + ); + } else if guessable { + } else if k != SymKind::Source { let what = match k { SymKind::Eq => "an equation".to_string(), SymKind::Props => "props".to_string(), diff --git a/compiler/src/emit.rs b/compiler/src/emit.rs index dfc058d4..342a660e 100644 --- a/compiler/src/emit.rs +++ b/compiler/src/emit.rs @@ -626,8 +626,11 @@ impl<'p> Gen<'p> { } // SF-1d: a server fn, not an accessor Decl::Eq { name, e, .. } if unwrap_server(e).is_some() => self.set(env, name, val(mg(name))), - Decl::Eq { name, .. } => { + Decl::Eq { name, e, deferred, .. } => { let mut b = bind(Kind::Acc, mg(name)); + if self.guessed_eq(name, e, *deferred) { + b.w = Some(format!("w${name}")); + } b.prev = self.u.prev_of.contains(name).then(|| format!("p${name}")); self.set(env, name, b); } @@ -952,6 +955,19 @@ impl<'p> Gen<'p> { prev(&mut out); Ok(out) } + Decl::Eq { name, e, key, deferred, .. } if self.guessed_eq(name, e, *deferred) => { + // AC-3 over an equation: createOptimistic(fn) / createOptimisticStore(fn, seed) — derived, its setter only guesses + let lv = self.loading_value(e, env)?; + let mode = obj(vec![("optimistic", E::Bool(true)), ("structured", E::Bool(self.u.paths.contains(name) || key.is_some())), ("derived", E::Bool(true))]); + let x = self.expr(e, env, &Ctx { tail: true, self_: Some(name.clone()), ..Ctx::default() })?; + let mut args = vec![str(name.clone()), arrow(&["__prev"], x), lv, mode]; + if let Some(k) = key { + args.push(str(k.clone())); + } + let mut out = vec![S::Const(pair(), rtc("question", args)), self.reg(name, b, true)]; + prev(&mut out); + Ok(out) + } Decl::Eq { name, e, key, deferred, .. } => { let fin = |mut out: Vec, g: &Self| { out.push(g.reg(name, b, false)); @@ -1516,6 +1532,10 @@ impl<'p> Gen<'p> { Ok(out) } /// A region's body as a child: its declarations, then its view (a function body, so it is owned where it is called). + /// An equation a guess sits over (AC-3): not deferred, not a draft, not a wrapped server fn. + fn guessed_eq(&self, name: &str, e: &Expr, deferred: bool) -> bool { + self.u.optimistic.contains(name) && !deferred && !matches!(&e.k, ExprKind::Draft { .. }) && unwrap_server(e).is_none() + } fn region_fn(&mut self, decls: &[Decl], view: &[Markup], env: Env, param: Option, narrow: Option<(String, Bind)>, bind_name: Option) -> R { let inner = self.new_env(Some(env)); let mut pre = vec![]; diff --git a/guide/async.md b/guide/async.md index 635abcd3..c5525ccc 100644 --- a/guide/async.md +++ b/guide/async.md @@ -85,7 +85,7 @@ by what the screen should do: | You want | Write | | --- | --- | -| Show a value now, and take it back if the action fails | `guess x := v` (x is a source with a question: `source x = …`) | +| Show a value now, and take it back if the action fails | `guess x := v` (x is an equation, `x = …`, or a source with a question, `source x = …`) | | `pending? x` true while the action runs | `affects x` | | Land a UI write now and keep it (close a menu, clear a message) | `onFlush(() => { x := v })` (EF-8) | | Record the server's answer | `x := answer` after the `await` | diff --git a/guide/diagnostics.md b/guide/diagnostics.md index b634c891..2b4e9014 100644 --- a/guide/diagnostics.md +++ b/guide/diagnostics.md @@ -462,8 +462,9 @@ export default component App() { ### GUESS_NO_QUESTION -A guess is confirmed or replaced by the answer to a question, so `guess` writes a source declared with `=` (a -source with a question, ST-5c). A source seeded with `:=` has no question; write it plainly. +A guess is confirmed or replaced by the answer to a question, so `guess` writes an equation or a source declared +with `=` (a source with a question, ST-5c). A source seeded with `:=` has no question; write it plainly, or with +`onFlush` if it is a reset that should land now (EF-8). ```rx before export default component App() { @@ -481,6 +482,31 @@ export default component App() { } ``` +### GUESS_ON_DEFERRED + +A `deferred` equation is a lagging copy of another value (V-16), so nothing would confirm a guess on it. Guess the +value it lags. + +```rx before +export default component App() { + source name := "Ann" + shown = name + deferred later = shown + action fix() { guess later := "Zed"; await fetch("x") catch (e) => null } + +} +``` + +```rx after +export default component App() { + source name := "Ann" + shown = name + deferred later = shown + action fix() { guess shown := "Zed"; await fetch("x") catch (e) => null } + +} +``` + ### INVALID_REFRESH_TARGET `refresh x` re-asks `x`'s question (AC-4), so `x` is an equation that awaits or a source declared with `=`. A source diff --git a/guide/reference.md b/guide/reference.md index 5a0c36f9..e691484b 100644 --- a/guide/reference.md +++ b/guide/reference.md @@ -58,7 +58,7 @@ Expr ::= TypeScript's, without assignment, `this`, `function`, `new` on rea - A failure inside the body must be caught there: `try { … } catch (e) { … }` (R-f-6, AC-7). The catch runs inside the same change: its writes land with the body's end. A rejected body commits its plain writes and reverts its guesses (AC-1, DV-2). -- `guess x := e`: a guess on a source with a question (`source x = …`), shown now, kept until the action's change +- `guess x := e`: a guess on an equation (`x = …`) or a source with a question (`source x = …`), shown now, kept until the action's change commits or `x`'s own answer differs (AC-3, H-11). Guesses on one source hold together. - `refresh x`: asks `x` again, quietly (no `pending?`; the old value stays up until the new one lands). Inside an action, the answer lands with the action's commit (AC-4). diff --git a/ref/compile/emit.ts b/ref/compile/emit.ts index 8916109b..3caf7c3f 100644 --- a/ref/compile/emit.ts +++ b/ref/compile/emit.ts @@ -241,7 +241,7 @@ class Gen { if (d.k === "error" || d.k === "context") env.vars.set(d.name, { kind: "val", js: mg(d.name) }); if (d.k === "source") env.vars.set(d.name, { kind: "src", js: mg(d.name), w: `w$${d.name}`, prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined }); if (d.k === "eq" && unwrapServer(d.e)) { env.vars.set(d.name, { kind: "val", js: mg(d.name) }); continue; } // SF-1d: a server fn, not an accessor - if (d.k === "eq") env.vars.set(d.name, { kind: d.e.k === "call" && d.e.f.k === "id" && env.lookup(d.e.f.name)?.kind === "ifn" ? "acc" : "acc", js: mg(d.name), prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined }); + if (d.k === "eq") env.vars.set(d.name, { kind: d.e.k === "call" && d.e.f.k === "id" && env.lookup(d.e.f.name)?.kind === "ifn" ? "acc" : "acc", js: mg(d.name), w: this.guessedEq(d) ? `w$${d.name}` : undefined, prev: this.u.prevOf.has(d.name) ? `p$${d.name}` : undefined }); } for (const d of decls) if (d.k === "import" && !d.resolved) for (const { name, alias } of d.names) { const v = !d.from.startsWith(".") && !BUILTINS.has(name) && name !== "log" ? `w$${alias}` : name === "log" ? "__B.log" : d.declaredErrors?.includes(name) ? `__rt.jsError(${J(name)})` : BUILTINS.has(name) ? `__B.${name}` : d.from.endsWith("/api") ? `__rt.api(${J(name)})` : null; @@ -313,6 +313,8 @@ class Gen { }).join("\n"); } private hoistAll(es: Expr[], env: Env, pad: string): string[] { return es.map(x => this.hoist(null, x, env, pad, false)).filter(Boolean); } + /** An equation a guess sits over (AC-3): not deferred, not a draft, not a wrapped server fn. */ + private guessedEq(d: Extract): boolean { return this.u.optimistic.has(d.name) && !d.deferred && d.e.k !== "draft" && !unwrapServer(d.e); } private value1(d: Decl, env: Env, ind: number, b: Bind): string { const pad = " ".repeat(ind * 2); const prev = b.prev ? `\n${pad}const ${b.prev} = __rt.pre(${b.js});` : ""; @@ -323,6 +325,11 @@ class Gen { return `${pad}const [${b.js}, ${b.w}] = __rt.question(${J(d.name)}, (__prev) => ${this.expr(d.init, env, { imp: false, tail: true, self: d.name })}, ${lv}, ${mode}${d.key ? `, ${J(d.key)}` : ""});\n${pad}__rt.reg(${J(d.name)}, { get: ${b.js}, w: ${b.w} });${prev}`; } if (d.k !== "eq") throw new CompileError("unreachable"); + if (this.guessedEq(d)) { // AC-3 over an equation: createOptimistic(fn) / createOptimisticStore(fn, seed) — derived, its setter only guesses + const lv = this.loadingValue(d.e, env); + const mode = `{ optimistic: true, structured: ${this.u.paths.has(d.name) || !!d.key}, derived: true }`; + return `${pad}const [${b.js}, ${b.w}] = __rt.question(${J(d.name)}, (__prev) => ${this.expr(d.e, env, { imp: false, tail: true, self: d.name })}, ${lv}, ${mode}${d.key ? `, ${J(d.key)}` : ""});\n${pad}__rt.reg(${J(d.name)}, { get: ${b.js}, w: ${b.w} });${prev}`; + } const reg = `\n${pad}__rt.reg(${J(d.name)}, { get: ${b.js} });${prev}`; if (d.e.k === "draft") { // ST-3 const seed = d.e.seed; if (seed.k !== "pre" || seed.name !== d.name || !seed.init) throw new CompileError("a draft's seed must be `pre self -> seed`"); diff --git a/ref/compile/rt-server.ts b/ref/compile/rt-server.ts index 8d19c4d6..48f629e7 100644 --- a/ref/compile/rt-server.ts +++ b/ref/compile/rt-server.ts @@ -131,9 +131,10 @@ export function createApp(opts: { api?: Record unknown> if (typeof init === "object" && init !== null) { const [store] = createStore(init as any); const [get] = createSignal(store); return ro(get as Acc); } const [get] = createSignal(init); return ro(get as Acc); }, - question(name: string, compute: (prev: unknown) => unknown, o: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean }, key?: string): [Acc, Writer] { + question(name: string, compute: (prev: unknown) => unknown, o: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); if (mode.optimistic && mode.structured) { const [store] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined); return ro(() => store); } + if (mode.optimistic && mode.derived) { const [get] = createOptimistic(fn as any, o as any); return ro(get as Acc); } // AC-3 over an equation: the client's createOptimistic(fn), so the owner trees (and hydration keys) match if (mode.optimistic) { const [truth] = createSignal(fn as any, o as any); const [get] = createOptimistic(() => (truth as Acc)()); return ro(get as Acc); } if (mode.structured) { const [store] = (createStore as any)(async (draft: Record) => { const r = fn(draft); const v = r instanceof Flight ? await r.first : await r; if (v && typeof v === "object") { for (const k of Object.keys(draft)) if (!(k in (v as object))) delete draft[k]; Object.assign(draft, v); } }, {}); diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index 3b26a40b..0dbffeb9 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -171,7 +171,7 @@ export function createApp(opts: { root: any; api?: Record unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean }, key?: string): [Acc, Writer] { + question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); if (mode.optimistic && mode.structured) { // AC-3 path targets: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined); @@ -179,6 +179,11 @@ export function createApp(opts: { root: any; api?: Record { throw new Error("a plain write to an optimistic store is not lowered yet"); }; return [get, { set: plain, path: plain, guess: (v, keys = []) => setStore((d: any) => { if (keys.length === 0) { assignDraft(d, typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v); return; } let o = d; for (const k of keys.slice(0, -1)) o = o[k]; const last = keys[keys.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }) }]; } + if (mode.optimistic && mode.derived) { // AC-3 over an equation: createOptimistic(fn), whose setter only guesses + const plain = () => { throw new Error("a plain write to an equation"); }; + const [get, setGuess] = createOptimistic(fn as any, opts as any); + return [get as Acc, { set: plain, path: plain, guess: setGuess as any }]; + } if (mode.optimistic) { const [truth, setTruth] = createSignal(fn as any, opts as any); const [get, setGuess] = createOptimistic(() => truth()); diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index 342753ac..a9a98efe 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -181,7 +181,7 @@ export function createApp(opts: { api?: Record unknown> }, /** `source x = e` (ST-5): createSignal(fn); with a guess written to it, a createOptimistic layer over that truth (AC-3); * written or probed by path, createStore(fn, {}) whose derive writes its draft. */ - question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean }, key?: string): [Acc, Writer] { + question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); if (mode.optimistic && mode.structured) { // AC-3 path targets: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined); @@ -189,6 +189,11 @@ export function createApp(opts: { api?: Record unknown> const plain = () => { throw new Error("a plain write to an optimistic store is not lowered yet"); }; return [get, { set: plain, path: plain, guess: (v, keys = []) => setStore((d: any) => { if (keys.length === 0) { assignDraft(d, typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v); return; } let o = d; for (const k of keys.slice(0, -1)) o = o[k]; const last = keys[keys.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }) }]; } + if (mode.optimistic && mode.derived) { // AC-3 over an equation: createOptimistic(fn), whose setter only guesses + const plain = () => { throw new Error("a plain write to an equation"); }; + const [get, setGuess] = createOptimistic(fn as any, opts as any); + return [get as Acc, { set: plain, path: plain, guess: setGuess as any }]; + } if (mode.optimistic) { const [truth, setTruth] = createSignal(fn as any, opts as any); const [get, setGuess] = createOptimistic(() => truth()); diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index 331f66ab..f9eee299 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -282,6 +282,19 @@ export class Oracle { scope.vars.set(d.name, { k: "memo", get: () => proj }); break; } + if (this.optimisticTargets.has(d.name) && !d.deferred) { // AC-3 over an equation: Solid's createOptimistic(fn) / createOptimisticStore(fn, seed) — derived, its setter only guesses + const compute = this.computeOf(d.name, d.e, scope); + const plain = () => { throw new Unsupported("a plain write to an equation"); }; + if (this.pathTargets.has(d.name) || d.key) { + const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; assignDraft(draft, v); }; + const [store, setStore] = (createOptimisticStore as any)(derive, d.key ? [] : {}, d.key ? { key: d.key } : undefined); + scope.vars.set(d.name, { k: "store", get: () => store, set: plain, setPath: plain, question: true, setGuess: setStore }); + } else { + const [get, setGuess] = createOptimistic(compute as any, this.loadingValue(d.e, scope) as any); + scope.vars.set(d.name, { k: "sig", get, set: plain as any, setGuess: setGuess as any, question: true, optimistic: true }); + } + break; + } if (d.key) { // ST-4: a keyed projection const compute = this.computeOf(d.name, d.e, scope); const src = createMemo(() => compute(undefined) as any, this.loadingValue(d.e, scope) as any); // the answer (sync, or the tail await's) is a memo; the projection reconciles it by key (found by S-ai2-search) diff --git a/ref/src/check.ts b/ref/src/check.ts index 7b0113be..c0ff8894 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -73,6 +73,7 @@ type SymKind = "source" | "eq" | "props" | "value" | "fn" | "action" | "comp" | type Sym = { kind: SymKind; name: string; region: Region; line: number; question?: boolean; // source x = e + eqDecl?: Extract; // a declared equation: a guess may sit over it (AC-3, ST-5c) io?: boolean; // calling it is io (R-io) param?: boolean; // a fn's parameter: never a write target (O-4) instantiating?: boolean; // fn with ref params or declarations (E-9) @@ -377,6 +378,7 @@ class Checker { if (d.k === "action") sym.io = true; // R-io-2: an action is io if (d.k === "fn") { sym.params = d.params; sym.instantiating = d.params.some(p => p.ref) || d.decls.length > 0; sym.fnDecl = d; sym.declEnv = env; } if (d.k === "component") { sym.comp = d; sym.compRegion = this.region(env.region, "component", { component: true, name: d.name }); this.compSyms.set(sym.compRegion, sym); } + if (d.k === "eq") sym.eqDecl = d; if (d.k === "eq") { sym.mapsRecords = !d.key && mapsIntoRecords(d.e); if (d.e.k === "id") { const t = env.lookup(d.e.name); if (t?.io) sym.io = true; } } if (d.k === "eq") { const sf = unwrapServer(d.e); if (sf) { sym.kind = "server"; sym.params = sf.params.map(name => ({ name, ref: false })); this.serverExprs.set(sf, sym); } } // SF-1d: a wrapped server fn is one, its rows the function's this.bind(env, d.name, sym); @@ -837,7 +839,10 @@ class Checker { } else { target = this.resolve(s.target.name, env, ctx); this.model.write(target, s.k, s.target.path.length > 0); - 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)" : ""}`); + 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?.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") { diff --git a/ref/src/runtime.ts b/ref/src/runtime.ts index d60894d6..42dc8218 100644 --- a/ref/src/runtime.ts +++ b/ref/src/runtime.ts @@ -1102,7 +1102,7 @@ export class Runtime { else if (s.k === "optimistic") { if (!run.action) throw new RuleError("GUESS_OUTSIDE_HOLD", "a guess outside an action body"); const n = this.nodeNamed(s.target.name, region); - if (!n || !n.question) throw new RuleError("GUESS_NO_QUESTION", `${s.target.name} is not a source with a question (source x = e)`); + if (!n || !(n.question || (n.kind === "eq" && !n.deferred && n.compute))) throw new RuleError("GUESS_NO_QUESTION", `${s.target.name} is not a source with a question (source x = e)`); const path = s.target.path.map(p => typeof p === "string" ? p : String(this.ev(p, region, env))); this.pendingGuesses.push({ node: n, path, value: this.ev(s.e, region, env) }); } else if (s.k === "write") { diff --git a/scenarios/S-guess-equation.md b/scenarios/S-guess-equation.md new file mode 100644 index 00000000..c997dd8e --- /dev/null +++ b/scenarios/S-guess-equation.md @@ -0,0 +1,101 @@ +# S-guess-equation — a guess over an equation, and over a prop through one (Solid `createOptimistic(fn)`) + +Rules: AC-3, ST-5c, H-11, D-34, W-1, O-4, RG-4b. + +## Program + +```rx +component Title(props) { + shown = props.title + action rename(t: string) { + guess shown := t + try { let r = await fetch("rename") catch (e) => props.title; props.done(r) } catch {} + } +

{shown}

+ +} + +export default component App() { + source name := "Ann" + upper = name.toUpperCase() + action shout() { try { guess upper := "ANN!"; await fetch("shout") } catch {} } + action setName(v: string) { name := v } + + + <p>{name} {upper}</p> + <button onClick={() => { shout() }}>shout</button> +} +``` + +`shown` and `upper` are equations, never written: a guess is not a write of +the truth, so it may sit over one (AC-3, ST-5c). Each lowers to Solid's +`createOptimistic(fn)`, whose setter only guesses. A guess over a prop is a +guess over the child's own equation of it: it shows where the child reads +`shown`, and the parent's `name` does not move until the answer is written. + +## Frames + +| instant | action | frame | +| ------- | ------------------------------ | ---------------------------------- | +| 0 | mount | `AnnrenameAnn ANNshout` | +| 1 | handler `rename("Bob")` | `BobrenameAnn ANNshout` | +| 2 | resolve "rename" "Bob" | `BobrenameBob BOBshout` | +| 3 | handler `shout()` | `BobrenameBob ANN!shout` | +| 4 | resolve "shout" 0 | `BobrenameBob BOBshout` | + +Instant 1: the guess shows in the child only; the parent's `name` and +`upper` are unchanged (H-11). Instant 2: the answer is written as the +truth through the action the parent passed (O-4), `shown` re-derives to the +same value, the guess confirms silently. Instant 3: a guess over an +equation of a source. Instant 4: the body ends with nothing answering +`upper`, so the guess gives way to the truth at hand (D-34). + +## Variant: the rename fails + +| instant | action | frame | +| ------- | ------------------------------ | ---------------------------------- | +| 2 | reject "rename" "boom" | `AnnrenameAnn ANNshout` | + +The catch writes the prop's own value back; the guess reverts with the +body's end (AC-1, D-34). + +## Rejected variants + +```rx +-- reject: WRITE_TO_NON_SOURCE +export default component App() { + source name := "Ann" + upper = name.toUpperCase() + <button onClick={() => { upper := "X" }}>{upper}</button> +} +``` + +A plain write to an equation stays an error (W-1): only the guess may sit +over one. + +```rx +-- reject: WRITE_TO_NON_SOURCE +component Title(props) { + action rename(t: string) { guess props.title := t; await fetch("x") catch (e) => null } + <button onClick={() => { rename("Bob") }}>{props.title}</button> +} +export default component App() { + <Title title="Ann" /> +} +``` + +A guess on `props` itself stays an error (O-4): it would read as the +owner's value changing. Guess over the child's own equation of it. + +```rx +-- reject: GUESS_ON_DEFERRED +export default component App() { + source name := "Ann" + deferred later = name + action fix() { guess later := "Zed"; await fetch("x") catch (e) => null } + <button onClick={() => { fix() }}>{later}</button> +} +``` + +A deferred equation's value is a lagging copy (V-16); a guess has nothing +to confirm it there. diff --git a/spec/06-stores.md b/spec/06-stores.md index 32a83a90..661a145c 100644 --- a/spec/06-stores.md +++ b/spec/06-stores.md @@ -84,8 +84,18 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } a `createOptimistic(() => truth())` layer. - **ST-5b.** Its question (V-9) is `e`'s inputs. `refresh x` re-asks it quietly; `affects x` declares that in-flight work will change it (H-9). -- **ST-5c.** It is the only legal target of `guess` (AC-3): a guess - needs a truth that will confirm or correct it. +- **ST-5c.** It and an equation (V-2) are the legal targets of `guess` + (AC-3): a guess needs a truth that will confirm or correct it, and an + equation's truth is its expression. A guess is not a write of the truth, + so an equation still takes no `:=` (W-1). A guessed equation lowers to + Solid's `createOptimistic(fn)` / `createOptimisticStore(fn, seed)`, whose + setter only guesses; a guessed source with a question to a + `createSignal(fn)` truth under a `createOptimistic` layer, because it is + also written plainly. Not a deferred equation (`GUESS_ON_DEFERRED`): its + value lags the one a guess would be confirmed by (V-16). Not `props` or a + parameter (O-4): guess over the child's own equation of a prop + (`shown = props.title`), which shows the guess where the child reads it + (S-guess-equation). ## ST-6 — Streams @@ -220,7 +230,7 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } - **AC-3 (guess).** `guess x := e`, legal only inside an action body, declared or inferred (AC-1b); in an `effect`'s apply block, which is never an action, it is `GUESS_OUTSIDE_HOLD`, and only on a source with a - question (ST-5; `GUESS_NO_QUESTION`): writes a guess that is the + question (ST-5) or on an equation (ST-5c; else `GUESS_NO_QUESTION`): writes a guess that is the graph's and the screen's value for `x` from the instant's settle, display-ahead and verdict-inert, until the hold it was made over commits or `x`'s own answer differs — the full rule is H-11. `x`'s truth is @@ -304,6 +314,7 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } | `STALE_OWN_READ` (new) | S | AC-1d: a read of the change's own write | | `STALE_WRITE_BACK` (new) | S | AC-1e: a pre-`await` read written back after it | | `GUESS_NO_QUESTION` (new) | S | AC-3 / ST-5c | +| `GUESS_ON_DEFERRED` (new) | S | AC-3 / ST-5c: a deferred equation lags its truth | ## Scenarios diff --git a/spec/07-surface.md b/spec/07-surface.md index 28d150b7..ded33402 100644 --- a/spec/07-surface.md +++ b/spec/07-surface.md @@ -535,3 +535,10 @@ Expr ::= TSExpr with §3's removals and additions keyword need not. `source` was weighed against `state` and `signal` the same day and kept (decision 2): `signal` reads as atomic beside stores, and an equation is state too. +19. **A guess may sit over an equation** (ruled 2026-11-11): `x = e` then + `guess x := v` in an action, as Solid's `createOptimistic(fn)`, whose + setter only guesses (06 ST-5c). Requiring `source x = e` to guess was + stricter than Solid and guarded nothing: a guess is not a write of the + truth, and a plain `x := v` on an equation stays `WRITE_TO_NON_SOURCE`. + `source x = e` is for state that is also written as truth. A prop is + guessed through the child's own equation of it, never `props.x` (O-4). diff --git a/spec/99-diagnostics.md b/spec/99-diagnostics.md index 54543cfd..22c18877 100644 --- a/spec/99-diagnostics.md +++ b/spec/99-diagnostics.md @@ -104,6 +104,7 @@ named. | `STALE_OWN_READ` | S | AC-1d | S-infer-5 | | `STALE_WRITE_BACK` | S | AC-1e | S-infer-6 | | `GUESS_NO_QUESTION` | S | AC-3 / ST-5c | S-check-1, S-solid-32 | +| `GUESS_ON_DEFERRED` | S | AC-3 / ST-5c (a deferred equation lags its truth, V-16) | S-guess-equation | | `INVALID_REFRESH_TARGET`, `INVALID_AFFECTS_TARGET` | S | AC-4, AC-5 | S-check-1 | | `REGION_ESCAPE` | S | O-3, O-3a, O-3b | S-check-1 (seven shapes), S-module-1 | | `UNSTABLE_LIST_IDENTITY` | S | ST-4b (narrow form: `map` into fresh records, unkeyed) | S-store-3 | @@ -171,7 +172,7 @@ section with a fix in `guide/diagnostics.md`, and the compiler's tests check its | bin | codes | | --- | ----- | | U | 21 | -| S | 12 | +| S | 13 | | S/R | 5 | | R | 17 (+ the SSR family) | From a49ef15787da528529c77e33efa3c42da87837b3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Nov 2026 06:21:43 +0000 Subject: [PATCH 5/7] =?UTF-8?q?Destructuring=20(RG-4b,=2007=20=C2=A75):=20?= =?UTF-8?q?`{=20a,=20b=20}=20=3D=20e`,=20`[x,=20,=20y=20=3D=20d]=20=3D=20e?= =?UTF-8?q?`,=20component=20signatures,=20`let=20{=20=E2=80=A6=20}=20=3D?= =?UTF-8?q?=20e`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A destructured equation is one equation per name, each a projection of the right side that keeps updating; a destructuring `let` is one snapshot local per name, as in JS. Defaults apply on undefined. Rest is not yet supported. `[n, setN] = createSignal(0)` keeps its JS_API hint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWP7LmQhib6M9zGnmBH --- compiler/src/parse.rs | 249 +++++++++++++++++++++++++++++++++-- compiler/src/spans.rs | 8 +- ref/src/parse.ts | 99 ++++++++++++-- scenarios/S-destructuring.md | 66 ++++++++++ 4 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 scenarios/S-destructuring.md diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index b0a4e017..2382ce84 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -72,10 +72,14 @@ pub struct Cursor<'a> { /// on with an empty one, so that what is being typed still projects pub recover: bool, pub errors: Vec<ParseError>, + /// a destructuring `let` (07 §5) is several statements: the rest of them, after the first + pub pending: Vec<Stmt>, + /// `$$n`: the bound value of a destructuring whose right side is not a name (RG-4b) + pub temps: usize, } impl<'a> Cursor<'a> { pub fn new(src: &'a str) -> Self { - Cursor { src, i: 0, recover: false, errors: vec![] } + Cursor { src, i: 0, recover: false, errors: vec![], pending: vec![], temps: 0 } } /// What `fail` would return, recorded instead (`recover`). fn note(&mut self, msg: &str) { @@ -304,7 +308,7 @@ pub fn parse_program(src: &str) -> R<Program> { let mut decls = vec![]; c.ws(true); while !c.eof() && looks_like_decl(&mut c) { - decls.push(parse_decl(&mut c)?); + decls.extend(parse_decls1(&mut c)?); c.ws(true); c.eat(";"); c.ws(true); @@ -357,8 +361,8 @@ pub fn parse_program_recovering(src: &str) -> (Program, Vec<ParseError>) { } } else { let (at, n) = (c.i, c.errors.len()); - match parse_decl(&mut c) { - Ok(d) => decls.push(d), + match parse_decls1(&mut c) { + Ok(d) => decls.extend(d), Err(e) => { // what the failed attempt noted on the way is moot: the declaration is out c.errors.truncate(n); @@ -532,6 +536,192 @@ fn params_typed(c: &mut Cursor) -> R<(Vec<String>, Vec<Option<String>>)> { Ok((params, ptypes)) } +/// RG-4b, 07 §5: a destructuring pattern, `{ a, b: c, d = 1, e: { f } }` or `[a, , b]`. Rest (`...r`) is not yet. +enum Pat { + Name { name: String, line: usize, span: Span }, + Obj(Vec<(String, Pat, Option<Expr>)>), + Arr(Vec<Option<(Pat, Option<Expr>)>>), +} +fn pat_default(c: &mut Cursor) -> R<Option<Expr>> { + if c.peek() == '=' && c.peek_at(1) != '=' && c.peek_at(1) != '>' { + c.bump(); + c.ws(true); + return Ok(Some(parse_expr(c)?)); + } + Ok(None) +} +fn parse_pattern(c: &mut Cursor) -> R<Pat> { + c.ws(true); + if c.eat("{") { + let mut props = vec![]; + loop { + c.ws(true); + if c.starts_with("}") { + break; + } + if c.starts_with("...") { + return c.fail("RG-4b: a rest pattern (`...rest`) is not supported yet: name the fields, or read them from the value"); + } + let (kline, kstart) = (c.line(), c.i); + let key = c.ident_or("a field name")?; + let kspan = Span::new(kstart, c.i); + c.ws(true); + let pat = if c.eat(":") { parse_pattern(c)? } else { Pat::Name { name: key.clone(), line: kline, span: kspan } }; + c.ws(true); + let dflt = pat_default(c)?; + props.push((key, pat, dflt)); + c.ws(true); + if !c.eat(",") { + break; + } + } + c.ws(true); + c.expect("}")?; + return Ok(Pat::Obj(props)); + } + if c.eat("[") { + let mut items = vec![]; + loop { + c.ws(true); + if c.starts_with("]") { + break; + } + if c.starts_with(",") { + c.bump(); + items.push(None); + continue; + } + if c.starts_with("...") { + return c.fail("RG-4b: a rest pattern (`...rest`) is not supported yet: name the items, or read them from the value"); + } + let pat = parse_pattern(c)?; + c.ws(true); + let dflt = pat_default(c)?; + items.push(Some((pat, dflt))); + c.ws(true); + if !c.eat(",") { + break; + } + } + c.ws(true); + c.expect("]")?; + return Ok(Pat::Arr(items)); + } + let (line, start) = (c.line(), c.i); + let name = c.ident(); + match name { + Some(n) if !is_kw(&n) => Ok(Pat::Name { name: n, line, span: Span::new(start, c.i) }), + _ => c.fail("a name to bind"), + } +} +/// Each name a pattern binds, with its projection of `base` (a default applies when the field is `undefined`, as in JS). +/// The projections are the parser's own: they have no span (CP-8). +fn expand_pattern(pat: &Pat, base: Expr, out: &mut Vec<(String, Expr, usize, Span)>) { + let syn = |k: ExprKind| Expr::new(k, Span::NONE); + let dfl = |v: Expr, d: &Option<Expr>| match d { + Some(d) => syn(ExprKind::If { + c: Box::new(syn(ExprKind::Bin { op: "==".into(), l: Box::new(v.clone()), r: Box::new(syn(ExprKind::Lit(Lit::Undefined))) })), + t: Box::new(d.clone()), + f: Box::new(v), + }), + None => v, + }; + match pat { + Pat::Name { name, line, span } => out.push((name.clone(), base, *line, *span)), + Pat::Obj(props) => { + for (key, p, d) in props { + let v = syn(ExprKind::Member { o: Box::new(base.clone()), p: key.clone(), opt: false }); + expand_pattern(p, dfl(v, d), out); + } + } + Pat::Arr(items) => { + for (i, it) in items.iter().enumerate() { + if let Some((p, d)) = it { + let v = syn(ExprKind::Index { o: Box::new(base.clone()), i: Box::new(syn(ExprKind::Lit(Lit::Num(i as f64)))), opt: false }); + expand_pattern(p, dfl(v, d), out); + } + } + } + } +} +/// A right side that is read again per name without asking twice: a name or a path of names. +fn is_path(e: &Expr) -> bool { + match &e.k { + ExprKind::Id(_) => true, + ExprKind::Member { o, opt: false, .. } => is_path(o), + _ => false, + } +} +/// At a block's `{ … } = e` or `[ … ] = e`: a destructuring declaration, not a read in the view. +fn destructuring_ahead(c: &Cursor) -> bool { + let open = c.peek(); + if open != '{' && open != '[' { + return false; + } + let b = c.src.as_bytes(); + let (mut depth, mut j) = (0i64, c.i); + while j < b.len() { + let ch = b[j]; + if ch == b'{' || ch == b'[' { + depth += 1; + } else if ch == b'}' || ch == b']' { + depth -= 1; + if depth == 0 { + break; + } + } else if ch == b'"' || ch == b'\'' { + let q = ch; + j += 1; + while j < b.len() && b[j] != q && b[j] != b'\n' { + if b[j] == b'\\' { + j += 1; + } + j += 1; + } + } else if ch == b'\n' || ch == b'<' || ch == b'`' { + return false; + } + j += 1; + } + let rest = &c.src[(j + 1).min(c.src.len())..]; + let t = rest.trim_start_matches([' ', '\t']); + t.starts_with('=') && !t.starts_with("==") && !t.starts_with("=>") +} +/// RG-4b: `{ a, b } = e` is one equation per name, each a projection of `e` that keeps updating. +fn parse_destructuring_decl(c: &mut Cursor) -> R<Vec<Decl>> { + let (line, start) = (c.line(), c.i); + let pat = parse_pattern(c)?; + c.ws(true); + c.expect("=")?; + c.ws(true); + // `[n, setN] = createSignal(0)`: JS_API's hint + if JS_APIS.find(c.src[c.i..].split('\n').next().unwrap_or("")).is_some_and(|m| m.start() == 0) { + return c.fail("a hook's pair is not .rx"); + } + let mut base = parse_expr(c)?; + let mut out = vec![]; + if !is_path(&base) { + c.temps += 1; + let t = format!("$${}", c.temps); + out.push(Decl::Eq { name: t.clone(), e: base, line, span: Span::new(start, c.i), deferred: false, key: None, ty: None, exported: false }); + base = Expr::new(ExprKind::Id(t), Span::NONE); + } + let mut names = vec![]; + expand_pattern(&pat, base, &mut names); + for (name, e, line, span) in names { + out.push(Decl::Eq { name, e, line, span, deferred: false, key: None, ty: None, exported: false }); + } + Ok(out) +} +/// One declaration, or the several a destructuring declares. +fn parse_decls1(c: &mut Cursor) -> R<Vec<Decl>> { + c.ws(true); + if destructuring_ahead(c) { + return parse_destructuring_decl(c); + } + Ok(vec![parse_decl(c)?]) +} + fn parse_decl(c: &mut Cursor) -> R<Decl> { c.ws(true); let start = c.i; @@ -821,7 +1011,7 @@ fn parse_decl_at(c: &mut Cursor) -> R<Decl> { if !looks_like_decl(c) { break; } - decls.push(parse_decl(c)?); + decls.extend(parse_decls1(c)?); c.ws(true); c.eat(";"); } @@ -837,13 +1027,21 @@ fn parse_decl_at(c: &mut Cursor) -> R<Decl> { c.ws(true); c.expect("(")?; c.ws(true); - let props = if c.peek() == ')' { "props".to_string() } else { c.ident_or("props name")? }; + // RG-4b: `component C({ a, b })` — each name a projection of props + let sig = if c.peek() == '{' { Some(parse_pattern(c)?) } else { None }; + let props = if sig.is_some() || c.peek() == ')' { "props".to_string() } else { c.ident_or("props name")? }; let props_type = opt_type(c, false)?; c.ws(true); c.expect(")")?; c.ws(true); c.expect("{")?; - let (decls, view) = parse_block_body(c)?; + let (mut decls, view) = parse_block_body(c)?; + if let Some(sig) = &sig { + let mut names = vec![]; + expand_pattern(sig, Expr::new(ExprKind::Id(props.clone()), Span::NONE), &mut names); + let pre: Vec<Decl> = names.into_iter().map(|(name, e, line, span)| Decl::Eq { name, e, line, span, deferred: false, key: None, ty: None, exported: false }).collect(); + decls.splice(0..0, pre); + } return Ok(Decl::Component { name, props, decls, view, line, span: Span::NONE, props_type, exported: false, is_default: false }); } let save = c.i; @@ -884,6 +1082,7 @@ pub fn parse_block(c: &mut Cursor) -> R<Vec<Stmt>> { let mut out = vec![]; while !c.starts_with("}") { out.push(parse_stmt(c)?); + out.append(&mut c.pending); c.ws(true); c.eat(";"); c.ws(true); @@ -898,6 +1097,7 @@ pub fn parse_stmts(src: &str) -> R<Vec<Stmt>> { c.ws(true); while !c.eof() { out.push(parse_stmt(&mut c)?); + out.append(&mut c.pending); c.ws(true); c.eat(";"); c.ws(true); @@ -949,6 +1149,32 @@ fn parse_stmt(c: &mut Cursor) -> R<Stmt> { } if c.keyword("let") || c.keyword("const") { c.ws(true); + if c.peek() == '{' || c.peek() == '[' { + // 07 §5: a destructuring local — each name a snapshot, as in JS + let pat = parse_pattern(c)?; + c.ws(true); + c.expect("=")?; + c.ws(true); + let mut base = parse_expr(c)?; + let mut out = vec![]; + if !is_path(&base) { + c.temps += 1; + let t = format!("$${}", c.temps); + out.push(st(start, StmtKind::Let { name: t.clone(), e: base, ty: None }, c)); + base = Expr::new(ExprKind::Id(t), Span::NONE); + } + let mut names = vec![]; + expand_pattern(&pat, base, &mut names); + for (name, e, _, span) in names { + out.push(Stmt { k: StmtKind::Let { name, e, ty: None }, span }); + } + if out.is_empty() { + return c.fail("a destructuring that binds nothing"); + } + let first = out.remove(0); + c.pending.extend(out); + return Ok(first); + } let name = c.ident_or("let name")?; let ty = opt_type(c, false)?; c.ws(true); @@ -1009,12 +1235,12 @@ fn parse_stmt(c: &mut Cursor) -> R<Stmt> { c.ws(true); c.expect(")")?; c.ws(true); - let t = if c.peek() == '{' { parse_block(c)? } else { vec![parse_stmt(c)?] }; + let t = if c.peek() == '{' { parse_block(c)? } else { let mut v = vec![parse_stmt(c)?]; v.append(&mut c.pending); v }; c.ws(true); let mut f = vec![]; if c.keyword("else") { c.ws(true); - f = if c.peek() == '{' { parse_block(c)? } else { vec![parse_stmt(c)?] }; + f = if c.peek() == '{' { parse_block(c)? } else { let mut v = vec![parse_stmt(c)?]; v.append(&mut c.pending); v }; } return Ok(st(start, StmtKind::Sif { c: cond, t, f }, c)); } @@ -1745,7 +1971,7 @@ fn parse_block_body(c: &mut Cursor) -> R<(Vec<Decl>, Vec<Markup>)> { if !looks_like_decl(c) { break; } - decls.push(parse_decl(c)?); + decls.extend(parse_decls1(c)?); c.ws(true); c.eat(";"); } @@ -1754,7 +1980,8 @@ fn parse_block_body(c: &mut Cursor) -> R<(Vec<Decl>, Vec<Markup>)> { fn looks_like_decl(c: &mut Cursor) -> bool { let save = c.i; c.ws(true); - let r = c.starts_with("source ") + let r = destructuring_ahead(c) + || c.starts_with("source ") || c.starts_with("effect") || c.starts_with("fn ") || c.starts_with("component ") diff --git a/compiler/src/spans.rs b/compiler/src/spans.rs index 6c6db4f4..11e32377 100644 --- a/compiler/src/spans.rs +++ b/compiler/src/spans.rs @@ -30,6 +30,9 @@ impl Spans<'_> { true } fn word_in(&mut self, at: Span, name: &str, what: &str) { + if name.starts_with("$$") { + return; // a destructuring's temp (RG-4b): the parser's own name for its right side + } let t = at.text(self.src); let found = t.match_indices(name).any(|(i, _)| { let before = t[..i].chars().next_back(); @@ -124,8 +127,9 @@ impl Spans<'_> { fn expr(&mut self, e: &Expr, parent: Span) { let at = e.span; if !self.inside(at, parent, e.kind()) { - // the one expression the parser makes: a statement-bodied arrow's `body` - if !matches!(e.k, ExprKind::Lit(Lit::Undefined)) { + // the expressions the parser makes: a statement-bodied arrow's `body`, and a destructured name's projection + // (RG-4b: `.field`, `[i]`, the default's `=== undefined ? d : v`, the right side's temp or `props`) + if !matches!(e.k, ExprKind::Lit(Lit::Undefined) | ExprKind::Lit(Lit::Num(_)) | ExprKind::Member { .. } | ExprKind::Index { .. } | ExprKind::If { .. } | ExprKind::Bin { .. } | ExprKind::Id(_)) { self.bad(parent, format!("a {} expression has no span", e.kind())); } return; diff --git a/ref/src/parse.ts b/ref/src/parse.ts index 946d9182..2e669795 100644 --- a/ref/src/parse.ts +++ b/ref/src/parse.ts @@ -37,6 +37,8 @@ export function viewStart(src: string): number { parseProgram(src); return viewA class Cursor { i = 0; + pendingStmts: Stmt[] = []; // a destructuring `let` (07 §5) is several statements: the rest of them, after the first + temps = 0; // `$$n`: the bound value of a destructuring whose right side is not a name (RG-4b) src: string; constructor(src: string) { this.src = src; } get line() { return this.src.slice(0, this.i).split("\n").length; } @@ -116,7 +118,7 @@ export function parseProgram(src: string): Program { const c = new Cursor(src); const decls: Decl[] = []; c.ws(); - while (!c.eof() && looksLikeDecl(c)) { decls.push(parseDecl(c)); c.ws(); c.eat(";"); c.ws(); } + while (!c.eof() && looksLikeDecl(c)) { decls.push(...parseDecls1(c)); c.ws(); c.eat(";"); c.ws(); } viewAt = c.i; const view = parseMarkup(c, null); c.ws(); @@ -157,6 +159,71 @@ export function parseDeclarations(src: string): JsDecl[] { } } +/** RG-4b, 07 §5: a destructuring pattern, `{ a, b: c, d = 1, e: { f } }` or `[a, , b]`. Rest (`...r`) is not yet. */ +type Pat = { k: "name"; name: string; line: number } | { k: "obj"; props: { key: string; pat: Pat; dflt?: Expr }[] } | { k: "arr"; items: ({ pat: Pat; dflt?: Expr } | null)[] }; +function parsePattern(c: Cursor): Pat { + c.ws(); + if (c.eat("{")) { + const props: { key: string; pat: Pat; dflt?: Expr }[] = []; + for (;;) { + c.ws(); if (c.startsWith("}")) break; + if (c.startsWith("...")) c.fail("RG-4b: a rest pattern (`...rest`) is not supported yet: name the fields, or read them from the value"); + const kline = c.line; const key = c.ident() ?? c.fail("a field name"); c.ws(); + const pat: Pat = c.eat(":") ? parsePattern(c) : { k: "name", name: key, line: kline }; c.ws(); + const dflt = c.peek() === "=" && c.peek(1) !== "=" && c.peek(1) !== ">" ? (c.i++, c.ws(), parseExpr(c)) : undefined; + props.push({ key, pat, dflt }); c.ws(); + if (!c.eat(",")) break; + } + c.ws(); c.expect("}"); return { k: "obj", props }; + } + if (c.eat("[")) { + const items: ({ pat: Pat; dflt?: Expr } | null)[] = []; + for (;;) { + c.ws(); if (c.startsWith("]")) break; + if (c.startsWith(",")) { c.i++; items.push(null); continue; } + if (c.startsWith("...")) c.fail("RG-4b: a rest pattern (`...rest`) is not supported yet: name the items, or read them from the value"); + const pat = parsePattern(c); c.ws(); + const dflt = c.peek() === "=" && c.peek(1) !== "=" && c.peek(1) !== ">" ? (c.i++, c.ws(), parseExpr(c)) : undefined; + items.push({ pat, dflt }); c.ws(); + if (!c.eat(",")) break; + } + c.ws(); c.expect("]"); return { k: "arr", items }; + } + const line = c.line; const name = c.ident(); if (!name || KEYWORDS.has(name)) c.fail("a name to bind"); + return { k: "name", name: name!, line }; +} +/** Each name a pattern binds, with its projection of `base` (a default applies when the field is `undefined`, as in JS). */ +function expandPattern(pat: Pat, base: Expr, out: { name: string; e: Expr; line: number }[]) { + const dfl = (v: Expr, d?: Expr): Expr => d ? { k: "if", c: { k: "bin", op: "==", l: v, r: { k: "lit", v: undefined } }, t: d, f: v } : v; + if (pat.k === "name") out.push({ name: pat.name, e: base, line: pat.line }); + else if (pat.k === "obj") for (const p of pat.props) expandPattern(p.pat, dfl({ k: "member", o: base, p: p.key }, p.dflt), out); + else pat.items.forEach((it, i) => { if (it) expandPattern(it.pat, dfl({ k: "index", o: base, i: { k: "lit", v: i } }, it.dflt), out); }); +} +/** A right side that is read again per name without asking twice: a name or a path of names. */ +const isPath = (e: Expr): boolean => e.k === "id" || (e.k === "member" && !e.opt && isPath(e.o)); +/** At a block's `{ … } = e` or `[ … ] = e`: a destructuring declaration, not a read in the view. */ +function destructuringAhead(c: Cursor): boolean { + const open = c.peek(); if (open !== "{" && open !== "[") return false; + let depth = 0, j = c.i; + for (; j < c.src.length; j++) { const ch = c.src[j]; if (ch === "{" || ch === "[") depth++; else if (ch === "}" || ch === "]") { depth--; if (depth === 0) break; } else if (ch === "\"" || ch === "'") { const q = ch; for (j++; j < c.src.length && c.src[j] !== q && c.src[j] !== "\n"; j++) if (c.src[j] === "\\") j++; } else if (ch === "\n" || ch === "<" || ch === "`") return false; } + const rest = c.src.slice(j + 1); + return /^[ \t]*=(?![=>])/.test(rest); +} +/** RG-4b: `{ a, b } = e` is one equation per name, each a projection of `e` that keeps updating. */ +function parseDestructuringDecl(c: Cursor): Decl[] { + const line = c.line; + const pat = parsePattern(c); c.ws(); c.expect("="); c.ws(); + if (JS_APIS.exec(c.src.slice(c.i).split("\n", 1)[0])?.index === 0) c.fail("a hook's pair is not .rx"); // `[n, setN] = createSignal(0)`: JS_API's hint + let base = parseExpr(c); + const out: Decl[] = []; + if (!isPath(base)) { const t = `$$${++c.temps}`; out.push({ k: "eq", name: t, e: base, line }); base = { k: "id", name: t }; } + const names: { name: string; e: Expr; line: number }[] = []; expandPattern(pat, base, names); + for (const n of names) out.push({ k: "eq", name: n.name, e: n.e, line: n.line }); + return out; +} +/** One declaration, or the several a destructuring declares. */ +function parseDecls1(c: Cursor): Decl[] { c.ws(); return destructuringAhead(c) ? parseDestructuringDecl(c) : [parseDecl(c)]; } + function parseDecl(c: Cursor): Decl { c.ws(); const line = c.line; @@ -244,14 +311,16 @@ function parseDecl(c: Cursor): Decl { if (c.eat("=>")) { c.ws(); return { k: "fn", name, params, decls: [], tail: parseExpr(c), line, ret }; } c.expect("{"); const decls: Decl[] = []; - for (;;) { c.ws(); if (!looksLikeDecl(c)) break; decls.push(parseDecl(c)); c.ws(); c.eat(";"); } + for (;;) { c.ws(); if (!looksLikeDecl(c)) break; decls.push(...parseDecls1(c)); c.ws(); c.eat(";"); } c.ws(); const tail = parseExpr(c); c.ws(); c.expect("}"); return { k: "fn", name, params, decls, tail, line, ret }; } if (c.keyword("component")) { c.ws(); const name = c.ident() ?? c.fail("component name"); c.ws(); c.expect("("); c.ws(); - const props = c.peek() === ")" ? "props" : (c.ident() ?? c.fail("props name")); const propsType = optType(c); c.ws(); c.expect(")"); c.ws(); c.expect("{"); // `component C()`: no props (found by S-ai2-tracker) + const sig = c.peek() === "{" ? parsePattern(c) : null; // RG-4b: `component C({ a, b })` — each name a projection of props + const props = sig || c.peek() === ")" ? "props" : (c.ident() ?? c.fail("props name")); const propsType = optType(c); c.ws(); c.expect(")"); c.ws(); c.expect("{"); // `component C()`: no props (found by S-ai2-tracker) const { decls, body } = parseBlockBody(c); + if (sig) { const names: { name: string; e: Expr; line: number }[] = []; expandPattern(sig, { k: "id", name: props }, names); decls.unshift(...names.map(n => ({ k: "eq" as const, name: n.name, e: n.e, line: n.line }))); } return { k: "component", name, props, decls, view: body, line, propsType }; } const save = c.i; @@ -273,7 +342,7 @@ function parseDecl(c: Cursor): Decl { export function parseBlock(c: Cursor): Stmt[] { c.ws(); c.expect("{"); c.ws(); const out: Stmt[] = []; - while (!c.startsWith("}")) { out.push(parseStmt(c)); c.ws(); c.eat(";"); c.ws(); } + while (!c.startsWith("}")) { out.push(parseStmt(c), ...c.pendingStmts.splice(0)); c.ws(); c.eat(";"); c.ws(); } c.expect("}"); return out; } @@ -282,7 +351,7 @@ export function parseStmts(src: string): Stmt[] { const c = new Cursor(src); const out: Stmt[] = []; c.ws(); - while (!c.eof()) { out.push(parseStmt(c)); c.ws(); c.eat(";"); c.ws(); } + while (!c.eof()) { out.push(parseStmt(c), ...c.pendingStmts.splice(0)); c.ws(); c.eat(";"); c.ws(); } return out; } @@ -296,7 +365,17 @@ function parseStmt(c: Cursor): Stmt { if (c.keyword("refresh")) { c.ws(); return { k: "refresh", name: c.ident() ?? c.fail("name") }; } if (c.keyword("affects")) { c.ws(); return { k: "affects", name: c.ident() ?? c.fail("name") }; } if (c.keyword("action")) { c.ws(); return { k: "action", body: parseBlock(c) }; } - if (c.keyword("let") || c.keyword("const")) { c.ws(); const name = c.ident() ?? c.fail("let name"); const type = optType(c); c.ws(); c.expect("="); c.ws(); return { k: "let", name, e: parseExpr(c), type }; } + if (c.keyword("let") || c.keyword("const")) { c.ws(); + if (c.peek() === "{" || c.peek() === "[") { // 07 §5: a destructuring local — each name a snapshot, as in JS + const pat = parsePattern(c); c.ws(); c.expect("="); c.ws(); + let base = parseExpr(c); const out: Stmt[] = []; + if (!isPath(base)) { const t = `$$${++c.temps}`; out.push({ k: "let", name: t, e: base }); base = { k: "id", name: t }; } + const names: { name: string; e: Expr; line: number }[] = []; expandPattern(pat, base, names); + for (const n of names) out.push({ k: "let", name: n.name, e: n.e }); + if (!out.length) c.fail("a destructuring that binds nothing"); + c.pendingStmts.push(...out.slice(1)); return out[0]; + } + const name = c.ident() ?? c.fail("let name"); const type = optType(c); c.ws(); c.expect("="); c.ws(); return { k: "let", name, e: parseExpr(c), type }; } if (c.keyword("try")) { // AC-7 c.ws(); const body = parseBlock(c); c.ws(); let catchName: string | undefined, handler: Stmt[] | undefined, fin: Stmt[] | undefined; @@ -310,9 +389,9 @@ function parseStmt(c: Cursor): Stmt { if (c.keyword("return")) { c.ws(false); if (c.peek() === "}" || c.peek() === ";" || c.peek() === "\n" || c.eof()) return { k: "return" }; return { k: "return", e: parseExpr(c) }; } if (c.keyword("if")) { // imperative `if (c) { … } else { … }` c.ws(); c.expect("("); c.ws(); const cond = parseExpr(c); c.ws(); c.expect(")"); c.ws(); - const t = c.peek() === "{" ? parseBlock(c) : [parseStmt(c)]; c.ws(); + const t = c.peek() === "{" ? parseBlock(c) : [parseStmt(c), ...c.pendingStmts.splice(0)]; c.ws(); let f: Stmt[] = []; - if (c.keyword("else")) { c.ws(); f = c.peek() === "{" ? parseBlock(c) : [parseStmt(c)]; } + if (c.keyword("else")) { c.ws(); f = c.peek() === "{" ? parseBlock(c) : [parseStmt(c), ...c.pendingStmts.splice(0)]; } return { k: "sif", c: cond, t, f }; } // write? target := expr @@ -579,13 +658,13 @@ function parseBlockBody(c: Cursor): { decls: Decl[]; body: Markup[] } { for (;;) { c.ws(); if (!looksLikeDecl(c)) break; - decls.push(parseDecl(c)); c.ws(); c.eat(";"); + decls.push(...parseDecls1(c)); c.ws(); c.eat(";"); } return { decls, body: parseMarkup(c, "}") }; } function looksLikeDecl(c: Cursor) { const save = c.i; c.ws(); - const r = c.startsWith("source ") || c.startsWith("effect") || c.startsWith("fn ") || c.startsWith("component ") || c.startsWith("deferred ") || c.startsWith("action ") || c.startsWith("provide ") || c.startsWith("context ") || c.startsWith("import ") || c.startsWith("export ") || c.startsWith("type ") || c.startsWith("interface ") || /^error\s+[A-Z]/.test(c.src.slice(c.i)) || /^server\s+(fn|action)\b/.test(c.src.slice(c.i)) || + const r = destructuringAhead(c) || c.startsWith("source ") || c.startsWith("effect") || c.startsWith("fn ") || c.startsWith("component ") || c.startsWith("deferred ") || c.startsWith("action ") || c.startsWith("provide ") || c.startsWith("context ") || c.startsWith("import ") || c.startsWith("export ") || c.startsWith("type ") || c.startsWith("interface ") || /^error\s+[A-Z]/.test(c.src.slice(c.i)) || /^server\s+(fn|action)\b/.test(c.src.slice(c.i)) || (() => { const id = c.ident(); c.ws(false); if (!id || KEYWORDS.has(id)) return false; if (c.peek() === ":" && c.peek(1) !== "=") return true; const ok = (c.peek() === "=" && c.peek(1) !== "=") || /^key\s+[A-Za-z_$][\w$]*\s*=[^=>]/.test(c.src.slice(c.i)); return ok; })(); c.i = save; return r; } diff --git a/scenarios/S-destructuring.md b/scenarios/S-destructuring.md new file mode 100644 index 00000000..46b2db24 --- /dev/null +++ b/scenarios/S-destructuring.md @@ -0,0 +1,66 @@ +# S-destructuring — a destructured name keeps updating; a destructured local is a snapshot + +Rules: RG-4b, E-1, 07 §5, W-4. + +## Program + +```rx +component Card({ title, size = "m", owner: { name } }) { + { a, b: bee } = props.pair + [first, , third = "z"] = props.list + <p>{title} {size} {name} {a} {bee} {first} {third}</p> +} + +export default component App() { + source pair := { a: 1, b: 2 } + source list := ["x", "y"] + { a } = pair + [head] = list.map(s => s.toUpperCase()) + + <Card title="T" owner={{ name: "Ann" }} pair={pair} list={list} /> + <p>{a} {head}</p> +} +``` + +Each name a reactive pattern binds is an equation, a projection of the +right side that keeps updating (RG-4b): `{ a } = pair` is `a = pair.a`, +and `component Card({ title })` is `title = props.title`. A default +applies when the field is `undefined`, as in JS. A right side that is not a +name (`list.map(…)`) is computed once and read per name. In a handler, +`let { … } = e` is JavaScript's: each name a snapshot (07 §5). + +## Frames + +| instant | action | frame | +| ------- | --------------- | ---------------------------- | +| 0 | mount | `T m Ann 1 2 x z1 X` | +| 1 | handler `let { a: q, b } = pair; let [x = 0] = [q + b]; pair := { a: x, b: q }` | `T m Ann 3 1 x z3 X` | +| 2 | handler `list := ["p", "q", "r"]` | `T m Ann 3 1 p r3 P` | + +Instant 1: the handler's locals are the snapshot `q = 1, b = 2`, so the +write is `{ a: 3, b: 1 }`; every projection of `pair` updates. Instant 2: +the list's third item now exists, so the default gives way. + +## Rejected variants + +```rx +-- reject: PARSE +export default component App() { + source pair := { a: 1, b: 2 } + { a, ...rest } = pair + <p>{a}</p> +} +``` + +A rest pattern is not supported yet (`omit` is not in the prelude). + +```rx +-- reject: WRITE_TO_NON_SOURCE +export default component App() { + source pair := { a: 1, b: 2 } + { a } = pair + <button onClick={() => { a := 5 }}>{a}</button> +} +``` + +A destructured name is an equation: write the source it projects. From f66861a1b8cc008774930ff9ed90c1990f92484a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Nov 2026 06:26:11 +0000 Subject: [PATCH 6/7] Document destructuring: 07 grammar + decision 20, guide/language.md Bare `x = e` is always an equation, `let x = e` always a snapshot, and only `:=` and `guess` mutate; destructuring keeps each side's meaning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWP7LmQhib6M9zGnmBH --- guide/language.md | 7 +++++-- spec/07-surface.md | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/guide/language.md b/guide/language.md index 0b0771b1..ff64e86f 100644 --- a/guide/language.md +++ b/guide/language.md @@ -27,6 +27,7 @@ source user = await fetchUser(id) // state with a question: re-asked wh double = count * 2 // an equation: always equal to its right side, never written label = `${user.name} (${count})` rows key id = items // a keyed collection (rows keep identity by `id`) +{ name, age = 0 } = user // destructuring: one equation per name, each keeps updating fn area(w: number, h: number): number => w * h // a pure function action save(n: string) { … } // writes and awaits (async.md) component Row(props: { item: Item }) { … } @@ -35,7 +36,8 @@ type Item = { id: number; name: string } ``` There is no `let` at the top level or in a component: a value that changes is a `source`, and one computed from -others is an equation. Equations are lazy and exact: an arm of `c ? a : b` that isn't taken isn't evaluated. +others is an equation. Bare `x = e` is always an equation and `let x = e` (in imperative code) always a snapshot, as +in JS; that holds for destructuring too. Mutation is only `:=` and `guess`. Destructuring has no rest (`...r`) yet. Equations are lazy and exact: an arm of `c ? a : b` that isn't taken isn't evaluated. ## Expressions @@ -95,7 +97,7 @@ effect (count) { log($0) } // runs after `count` changes; `$0` is its value (a - A write is `x := v`, `x.field := v`, `xs[i].done := v`, or functional `x := old => …` (applied to the slot as it stands at the write). -- `let`/`const`, `if`, `try`/`catch`/`finally`, `return`, `throw` work as in TypeScript (no `for`/`while` loops: +- `let`/`const` (destructuring too: `let { a, b } = pair` takes snapshots), `if`, `try`/`catch`/`finally`, `return`, `throw` work as in TypeScript (no `for`/`while` loops: use array methods). - Browser and JS globals (`console`, `document`, `window`) are not in scope. `log(…)` prints; anything else from JS comes in through an import from a `.js` module. @@ -117,6 +119,7 @@ component Card(props: { title: string; children: View }) { ``` `props.x` stays live: don't copy it into a source unless you mean a one-time seed (`source draft := peek(props.x)`). +Destructured props stay live too: `component Card({ title, open = false })` or `{ title } = props` (RG-4b). A prop that takes a handler is named `on…` and typed as a function: `props: { onPick: (id: number) => void }`, passed as `<Row onPick={id => { selected := id }} />` and called from the component's own handlers (`props.onPick(id)`). diff --git a/spec/07-surface.md b/spec/07-surface.md index ded33402..7543c69b 100644 --- a/spec/07-surface.md +++ b/spec/07-surface.md @@ -433,11 +433,15 @@ Decl' ::= "source" Name Type? ("key" Name)? (":=" | "=") Expr | "context" Name (":" "source"? Type)? ("=" Expr)? -- RG-7a; `source`: SOURCE_CONTEXT (RG-7c withdrawn) | "provide" Name "=" Expr -- Name: a context | "deferred"? Name Type? ("key" Name)? "=" Expr + | Pattern "=" Expr -- RG-4b: one equation per name, a projection of Expr | "effect" "(" (Expr ("," Expr)*)? ")" Block -- no inputs: EF-4 | "fn" Name "(" Params ")" Type? ("=>" Expr | "{" Decl* Expr "}") | "action" Name "(" Params ")" Block - | "component" Name "(" Name Type? ")" "{" RBlock "}" + | "component" Name "(" (Name | ObjPattern) Type? ")" "{" RBlock "}" -- a pattern projects props (RG-4b) | "error" Name ("(" Params ")")? ("=>" Expr)? -- R-f-1 +Pattern ::= ObjPattern | "[" (Bind? ",")* Bind? "]" -- no rest (`...r`) yet +ObjPattern ::= "{" (Name (":" Bind | ("=" Expr)?)) ,* "}" +Bind ::= (Name | Pattern) ("=" Expr)? -- a default applies when the field is `undefined` RBlock ::= Decl* Markup* Markup ::= JSXElement | "{" Expr "}" | Control Control ::= "if" Expr "{" RBlock "}" ("else" "{" RBlock "}")? @@ -447,7 +451,7 @@ Control ::= "if" Expr "{" RBlock "}" ("else" "{" RBlock "}")? Block ::= "{" Stmt* "}" Stmt ::= Target ":=" Expr | "guess" Target ":=" Expr -- Target `use` Name Path: WRITE_TO_NON_SOURCE (O-4) | "refresh" Name | "affects" Name | "action" Block - | ("let" | "const") Name Type? "=" Expr + | ("let" | "const") (Name Type? | Pattern) "=" Expr -- a pattern: one snapshot local per name, as in JS | "if" "(" Expr ")" (Block | Stmt) ("else" (Block | Stmt))? | "try" Block ("catch" ("(" Name (":" Classes)? ")")? Block)? ("finally" Block)? | "throw" Expr | "return" Expr? | Expr @@ -542,3 +546,12 @@ Expr ::= TSExpr with §3's removals and additions truth, and a plain `x := v` on an equation stays `WRITE_TO_NON_SOURCE`. `source x = e` is for state that is also written as truth. A prop is guessed through the child's own equation of it, never `props.x` (O-4). +20. **Destructuring keeps each side's meaning** (ruled 2026-11-11): + `{ a, b } = e` in a component or module is one equation per name, each a + projection of `e` that keeps updating (RG-4b), and `component C({ a, b })` + is the same over `props`; `let { a, b } = e` in imperative code is one + snapshot local per name, as in JS. So bare `x = e` is always an equation + and `let x = e` always a snapshot, and only `:=` and `guess` mutate: a + destructured name is an equation, so `a := v` is `WRITE_TO_NON_SOURCE`. + Defaults apply on `undefined`, as in JS. Rest patterns (`...r`) are not + yet supported: they need `omit` on a live value (06). From 5615b7c77d340355c65d3cfc706f13a72a0ea418 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 11 Nov 2026 06:53:29 +0000 Subject: [PATCH 7/7] Destructuring: rest patterns, `{ a, ...rest }` and `[x, ...rest]` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object rest is Solid's `omit` of the named keys, a live view (an internal `omit` expression in every runner and the crate); an array rest is `.slice(n)`. In a `let`, an object rest is a copy (`{ ...omit(…) }`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117BoWP7LmQhib6M9zGnmBH --- compiler/src/ast.rs | 4 +++ compiler/src/check.rs | 6 ++-- compiler/src/emit.rs | 2 ++ compiler/src/infer.rs | 4 +-- compiler/src/parse.rs | 61 ++++++++++++++++++++++++++---------- compiler/src/prelude.ts | 2 ++ compiler/src/project.rs | 4 +++ compiler/src/spans.rs | 8 ++--- guide/language.md | 3 +- ref/compile/emit.ts | 1 + ref/compile/rt-server.ts | 4 +-- ref/compile/rt-web.ts | 4 +-- ref/compile/rt.ts | 4 +-- ref/oracle/lower.ts | 3 +- ref/src/ast.ts | 1 + ref/src/check.ts | 4 +-- ref/src/parse.ts | 29 +++++++++++------ ref/src/runtime.ts | 3 +- ref/types/project.ts | 3 ++ scenarios/S-destructuring.md | 49 +++++++++++++++++++---------- spec/07-surface.md | 10 +++--- 21 files changed, 143 insertions(+), 66 deletions(-) diff --git a/compiler/src/ast.rs b/compiler/src/ast.rs index 46bcbe77..66bdff89 100644 --- a/compiler/src/ast.rs +++ b/compiler/src/ast.rs @@ -90,6 +90,8 @@ pub enum ExprKind { /// RG-7b Use(String), Spread(Box<Expr>), + /// RG-4b: an object rest, `{ a, ...rest } = e` (the parser's own; Solid's `omit`) + Omit { e: Box<Expr>, keys: Vec<String> }, /// R-f-3 Throw(Box<Expr>), /// R-f-4: `e catch (x: A | B) => alt`; classes absent: every failure @@ -422,6 +424,7 @@ impl Expr { ExprKind::Draft { .. } => "draft", ExprKind::Use(_) => "use", ExprKind::Spread(_) => "spread", + ExprKind::Omit { .. } => "omit", ExprKind::Throw(_) => "throw", ExprKind::Catch { .. } => "catch", ExprKind::StyleMap { .. } => "stylemap", @@ -439,6 +442,7 @@ impl Expr { ExprKind::Un { op, e } => obj(vec![("k", k), ("op", s(op)), ("e", b(e))]), ExprKind::Pre { name, init } => obj(vec![("k", k), ("name", s(name)), ("init", ob(init))]), ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Throw(e) => obj(vec![("k", k), ("e", b(e))]), + ExprKind::Omit { e, keys } => obj(vec![("k", k), ("e", b(e)), ("keys", Some(strs(keys)))]), ExprKind::Await { e, init, stream } => obj(vec![("k", k), ("e", b(e)), ("init", ob(init)), ("stream", Some(Value::Bool(*stream)))]), ExprKind::If { c, t, f } => obj(vec![("k", k), ("c", b(c)), ("t", b(t)), ("f", b(f))]), ExprKind::Tpl(parts) => obj(vec![ diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 8dcec25c..780b01aa 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -353,7 +353,7 @@ pub(crate) fn children<'a>(n: Node<'a>, paths: bool) -> Vec<Node<'a>> { Node::E(e) => match &e.k { ExprKind::Lit(_) | ExprKind::Id(_) | ExprKind::Use(_) | ExprKind::StyleMap { .. } => {} ExprKind::Bin { l, r, .. } => out.extend([Node::E(l), Node::E(r)]), - ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Throw(e) => out.push(Node::E(e)), + ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Omit { e, .. } | ExprKind::Throw(e) => out.push(Node::E(e)), ExprKind::Pre { init, .. } => out.extend(init.iter().map(|x| Node::E(x))), ExprKind::Await { e, init, .. } => { out.push(Node::E(e)); @@ -438,7 +438,7 @@ fn free_names(e: &Expr, bound: &HashSet<String>, out: &mut IndexSet<String>) { go(i, out); } } - ExprKind::Latest(x) | ExprKind::Pending(x) | ExprKind::Un { e: x, .. } | ExprKind::Spread(x) => go(x, out), + ExprKind::Latest(x) | ExprKind::Pending(x) | ExprKind::Un { e: x, .. } | ExprKind::Spread(x) | ExprKind::Omit { e: x, .. } => go(x, out), ExprKind::Bin { l, r, .. } => { go(l, out); go(r, out); @@ -2051,7 +2051,7 @@ impl<'a> Checker<'a> { self.expr(x, env, ctx, None); self.model.probe = save; } - ExprKind::Un { e: x, .. } | ExprKind::Spread(x) => self.expr(x, env, ctx, None), + ExprKind::Un { e: x, .. } | ExprKind::Spread(x) | ExprKind::Omit { e: x, .. } => self.expr(x, env, ctx, None), ExprKind::Await { e: inner, init, .. } => { if ctx.imperative && !self.stmt_awaits.contains(&(e as *const Expr)) { let sh = show(e); diff --git a/compiler/src/emit.rs b/compiler/src/emit.rs index 342a660e..0ed92891 100644 --- a/compiler/src/emit.rs +++ b/compiler/src/emit.rs @@ -1178,6 +1178,8 @@ impl<'p> Gen<'p> { rtc("preOr", vec![call(id(p), vec![]), thunk(i)]) } ExprKind::Peek(x) => rtc("untrack", vec![thunk(self.expr(x, env, &sub)?)]), + // RG-4b: Solid's live view + ExprKind::Omit { e: x, keys } => rtc("omit", std::iter::once(self.expr(x, env, &sub)).chain(keys.iter().map(|k| Ok(str(k.clone())))).collect::<Result<Vec<_>, _>>()?), ExprKind::Await { e: x, stream, .. } => { if let Some(v) = self.hoisted.get(&(e as *const Expr)) { return Ok(call(id(v.clone()), vec![])); diff --git a/compiler/src/infer.rs b/compiler/src/infer.rs index 02413af7..a4056e28 100644 --- a/compiler/src/infer.rs +++ b/compiler/src/infer.rs @@ -271,7 +271,7 @@ impl Walker { self.read(l, st, sh, local, depth); self.read(r, st, sh, local, depth); } - ExprKind::Un { e, .. } | ExprKind::Spread(e) | ExprKind::Throw(e) => self.read(e, st, sh, local, depth), + ExprKind::Un { e, .. } | ExprKind::Spread(e) | ExprKind::Omit { e, .. } | ExprKind::Throw(e) => self.read(e, st, sh, local, depth), ExprKind::Await { e, init, .. } => { self.read(e, st, sh, local, depth); if let Some(i) = init { @@ -362,7 +362,7 @@ impl Walker { self.deps(l, st, sh, out); self.deps(r, st, sh, out); } - ExprKind::Un { e, .. } | ExprKind::Spread(e) | ExprKind::Throw(e) => self.deps(e, st, sh, out), + ExprKind::Un { e, .. } | ExprKind::Spread(e) | ExprKind::Omit { e, .. } | ExprKind::Throw(e) => self.deps(e, st, sh, out), ExprKind::If { c, t, f } => { self.deps(c, st, sh, out); self.deps(t, st, sh, out); diff --git a/compiler/src/parse.rs b/compiler/src/parse.rs index 2382ce84..e31d4ae1 100644 --- a/compiler/src/parse.rs +++ b/compiler/src/parse.rs @@ -536,11 +536,27 @@ fn params_typed(c: &mut Cursor) -> R<(Vec<String>, Vec<Option<String>>)> { Ok((params, ptypes)) } -/// RG-4b, 07 §5: a destructuring pattern, `{ a, b: c, d = 1, e: { f } }` or `[a, , b]`. Rest (`...r`) is not yet. +/// RG-4b, 07 §5: a destructuring pattern, `{ a, b: c, d = 1, e: { f }, ...rest }` or `[a, , b, ...rest]`. enum Pat { Name { name: String, line: usize, span: Span }, - Obj(Vec<(String, Pat, Option<Expr>)>), - Arr(Vec<Option<(Pat, Option<Expr>)>>), + Obj(Vec<(String, Pat, Option<Expr>)>, Option<Box<Pat>>), + Arr(Vec<Option<(Pat, Option<Expr>)>>, Option<Box<Pat>>), +} +/// `...name`, the last element of a pattern closed by `close`. +fn parse_rest(c: &mut Cursor, close: &str) -> R<Pat> { + c.i += 3; + c.ws(true); + let (line, start) = (c.line(), c.i); + let name = match c.ident() { + Some(n) if !is_kw(&n) => n, + _ => return c.fail("a name for the rest"), + }; + let span = Span::new(start, c.i); + c.ws(true); + if !c.starts_with(close) { + return c.fail(&format!("a rest element is last, with no default: `...{name}{close}`")); + } + Ok(Pat::Name { name, line, span }) } fn pat_default(c: &mut Cursor) -> R<Option<Expr>> { if c.peek() == '=' && c.peek_at(1) != '=' && c.peek_at(1) != '>' { @@ -560,7 +576,9 @@ fn parse_pattern(c: &mut Cursor) -> R<Pat> { break; } if c.starts_with("...") { - return c.fail("RG-4b: a rest pattern (`...rest`) is not supported yet: name the fields, or read them from the value"); + let rest = parse_rest(c, "}")?; + c.expect("}")?; + return Ok(Pat::Obj(props, Some(Box::new(rest)))); } let (kline, kstart) = (c.line(), c.i); let key = c.ident_or("a field name")?; @@ -577,7 +595,7 @@ fn parse_pattern(c: &mut Cursor) -> R<Pat> { } c.ws(true); c.expect("}")?; - return Ok(Pat::Obj(props)); + return Ok(Pat::Obj(props, None)); } if c.eat("[") { let mut items = vec![]; @@ -592,7 +610,9 @@ fn parse_pattern(c: &mut Cursor) -> R<Pat> { continue; } if c.starts_with("...") { - return c.fail("RG-4b: a rest pattern (`...rest`) is not supported yet: name the items, or read them from the value"); + let rest = parse_rest(c, "]")?; + c.expect("]")?; + return Ok(Pat::Arr(items, Some(Box::new(rest)))); } let pat = parse_pattern(c)?; c.ws(true); @@ -605,7 +625,7 @@ fn parse_pattern(c: &mut Cursor) -> R<Pat> { } c.ws(true); c.expect("]")?; - return Ok(Pat::Arr(items)); + return Ok(Pat::Arr(items, None)); } let (line, start) = (c.line(), c.i); let name = c.ident(); @@ -615,8 +635,9 @@ fn parse_pattern(c: &mut Cursor) -> R<Pat> { } } /// Each name a pattern binds, with its projection of `base` (a default applies when the field is `undefined`, as in JS). -/// The projections are the parser's own: they have no span (CP-8). -fn expand_pattern(pat: &Pat, base: Expr, out: &mut Vec<(String, Expr, usize, Span)>) { +/// The projections are the parser's own: they have no span (CP-8). An object rest is `omit` of the named keys: Solid's +/// live view in reactive scope, a copy of it when `snap` (a `let`). +fn expand_pattern(pat: &Pat, base: Expr, out: &mut Vec<(String, Expr, usize, Span)>, snap: bool) { let syn = |k: ExprKind| Expr::new(k, Span::NONE); let dfl = |v: Expr, d: &Option<Expr>| match d { Some(d) => syn(ExprKind::If { @@ -628,19 +649,27 @@ fn expand_pattern(pat: &Pat, base: Expr, out: &mut Vec<(String, Expr, usize, Spa }; match pat { Pat::Name { name, line, span } => out.push((name.clone(), base, *line, *span)), - Pat::Obj(props) => { + Pat::Obj(props, rest) => { for (key, p, d) in props { let v = syn(ExprKind::Member { o: Box::new(base.clone()), p: key.clone(), opt: false }); - expand_pattern(p, dfl(v, d), out); + expand_pattern(p, dfl(v, d), out, snap); + } + if let Some(r) = rest { + let o = syn(ExprKind::Omit { e: Box::new(base.clone()), keys: props.iter().map(|(k, _, _)| k.clone()).collect() }); + expand_pattern(r, if snap { syn(ExprKind::Rec(vec![Field::Spread(o)])) } else { o }, out, snap); } } - Pat::Arr(items) => { + Pat::Arr(items, rest) => { for (i, it) in items.iter().enumerate() { if let Some((p, d)) = it { let v = syn(ExprKind::Index { o: Box::new(base.clone()), i: Box::new(syn(ExprKind::Lit(Lit::Num(i as f64)))), opt: false }); - expand_pattern(p, dfl(v, d), out); + expand_pattern(p, dfl(v, d), out, snap); } } + if let Some(r) = rest { + let f = syn(ExprKind::Member { o: Box::new(base.clone()), p: "slice".into(), opt: false }); + expand_pattern(r, syn(ExprKind::Call { f: Box::new(f), args: vec![syn(ExprKind::Lit(Lit::Num(items.len() as f64)))] }), out, snap); + } } } } @@ -707,7 +736,7 @@ fn parse_destructuring_decl(c: &mut Cursor) -> R<Vec<Decl>> { base = Expr::new(ExprKind::Id(t), Span::NONE); } let mut names = vec![]; - expand_pattern(&pat, base, &mut names); + expand_pattern(&pat, base, &mut names, false); for (name, e, line, span) in names { out.push(Decl::Eq { name, e, line, span, deferred: false, key: None, ty: None, exported: false }); } @@ -1038,7 +1067,7 @@ fn parse_decl_at(c: &mut Cursor) -> R<Decl> { let (mut decls, view) = parse_block_body(c)?; if let Some(sig) = &sig { let mut names = vec![]; - expand_pattern(sig, Expr::new(ExprKind::Id(props.clone()), Span::NONE), &mut names); + expand_pattern(sig, Expr::new(ExprKind::Id(props.clone()), Span::NONE), &mut names, false); let pre: Vec<Decl> = names.into_iter().map(|(name, e, line, span)| Decl::Eq { name, e, line, span, deferred: false, key: None, ty: None, exported: false }).collect(); decls.splice(0..0, pre); } @@ -1164,7 +1193,7 @@ fn parse_stmt(c: &mut Cursor) -> R<Stmt> { base = Expr::new(ExprKind::Id(t), Span::NONE); } let mut names = vec![]; - expand_pattern(&pat, base, &mut names); + expand_pattern(&pat, base, &mut names, true); for (name, e, _, span) in names { out.push(Stmt { k: StmtKind::Let { name, e, ty: None }, span }); } diff --git a/compiler/src/prelude.ts b/compiler/src/prelude.ts index f7867a87..f31a76ae 100644 --- a/compiler/src/prelude.ts +++ b/compiler/src/prelude.ts @@ -9,6 +9,8 @@ export declare function eq<T>(v: T, ok: Async<T> extends true ? "MISSING_AWAIT: export declare function first<T, I>(v: T, init: I): T | I; export declare function pre<T>(init?: T): T; export declare function pending(v: unknown): boolean; +/** RG-4b: an object rest, \`{ a, ...rest } = e\` (Solid's \`omit\`). */ +export declare function omit<T, K extends string>(v: T, ...keys: K[]): Omit<T, K>; export declare function caught<T, R>(v: T, alt: (e: any) => R): T | R; export declare function raise(e: unknown): never; /** W-1/W-6: a write checks its value (or its function of the slot) against the target — whose type alone decides it. */ diff --git a/compiler/src/project.rs b/compiler/src/project.rs index 889603c2..537eb0c2 100644 --- a/compiler/src/project.rs +++ b/compiler/src/project.rs @@ -803,6 +803,10 @@ impl Proj<'_> { Code::of("({ ").c(Code::join(fs, ", ")).t(" })") } ExprKind::Spread(x) => Code::of("...").c(go!(x)), + ExprKind::Omit { e: x, keys } => { + let ks: String = keys.iter().map(|k| format!(", {}", js_str(k))).collect(); + Code::of("__.omit(").c(go!(x)).t(&ks).t(")") + } ExprKind::Fn { params, body, stmts } => { let mut from = sp.start as usize; let ps = Code::join(params.iter().map(|p| self.name(p, sp, &mut from)).collect(), ", "); diff --git a/compiler/src/spans.rs b/compiler/src/spans.rs index 11e32377..9eac036b 100644 --- a/compiler/src/spans.rs +++ b/compiler/src/spans.rs @@ -128,8 +128,8 @@ impl Spans<'_> { let at = e.span; if !self.inside(at, parent, e.kind()) { // the expressions the parser makes: a statement-bodied arrow's `body`, and a destructured name's projection - // (RG-4b: `.field`, `[i]`, the default's `=== undefined ? d : v`, the right side's temp or `props`) - if !matches!(e.k, ExprKind::Lit(Lit::Undefined) | ExprKind::Lit(Lit::Num(_)) | ExprKind::Member { .. } | ExprKind::Index { .. } | ExprKind::If { .. } | ExprKind::Bin { .. } | ExprKind::Id(_)) { + // (RG-4b: `.field`, `[i]`, the default's `=== undefined ? d : v`, the right side's temp or `props`, a rest's `omit`, `.slice(n)` or `{ ...omit }`) + if !matches!(e.k, ExprKind::Lit(Lit::Undefined) | ExprKind::Lit(Lit::Num(_)) | ExprKind::Member { .. } | ExprKind::Index { .. } | ExprKind::If { .. } | ExprKind::Bin { .. } | ExprKind::Id(_) | ExprKind::Omit { .. } | ExprKind::Call { .. } | ExprKind::Rec(_)) { self.bad(parent, format!("a {} expression has no span", e.kind())); } return; @@ -150,7 +150,7 @@ impl Spans<'_> { sub(l); sub(r); } - ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Throw(e) => sub(e), + ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Omit { e, .. } | ExprKind::Throw(e) => sub(e), ExprKind::Pre { init, .. } => init.iter().for_each(|x| sub(x)), ExprKind::Await { e, init, .. } => { sub(e); @@ -353,7 +353,7 @@ pub fn walk(p: &Program, f: &mut dyn FnMut(Span)) { match &e.k { ExprKind::Lit(_) | ExprKind::Id(_) | ExprKind::Use(_) | ExprKind::StyleMap { .. } => {} ExprKind::Bin { l, r, .. } => sub.extend([&**l, &**r]), - ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Throw(e) => sub.push(e), + ExprKind::Un { e, .. } | ExprKind::Peek(e) | ExprKind::Latest(e) | ExprKind::Pending(e) | ExprKind::Spread(e) | ExprKind::Omit { e, .. } | ExprKind::Throw(e) => sub.push(e), ExprKind::Pre { init, .. } => sub.extend(init.iter().map(|x| &**x)), ExprKind::Await { e, init, .. } => sub.extend(std::iter::once(&**e).chain(init.iter().map(|x| &**x))), ExprKind::If { c, t, f } => sub.extend([&**c, &**t, &**f]), diff --git a/guide/language.md b/guide/language.md index ff64e86f..991dce62 100644 --- a/guide/language.md +++ b/guide/language.md @@ -37,7 +37,8 @@ type Item = { id: number; name: string } There is no `let` at the top level or in a component: a value that changes is a `source`, and one computed from others is an equation. Bare `x = e` is always an equation and `let x = e` (in imperative code) always a snapshot, as -in JS; that holds for destructuring too. Mutation is only `:=` and `guess`. Destructuring has no rest (`...r`) yet. Equations are lazy and exact: an arm of `c ? a : b` that isn't taken isn't evaluated. +in JS; that holds for destructuring too. Mutation is only `:=` and `guess`. A rest, `{ id, ...rest } = props`, is the other fields +(Solid's `omit`) and keeps updating too. Equations are lazy and exact: an arm of `c ? a : b` that isn't taken isn't evaluated. ## Expressions diff --git a/ref/compile/emit.ts b/ref/compile/emit.ts index 3caf7c3f..ccebfbf7 100644 --- a/ref/compile/emit.ts +++ b/ref/compile/emit.ts @@ -452,6 +452,7 @@ class Gen { case "un": return `(${e.op}${go(e.e)})`; case "draft": throw new CompileError("a draft is an equation's whole expression"); case "spread": throw new CompileError("spread outside an array literal"); + case "omit": return `__rt.omit(${[go(e.e), ...e.keys.map(k => J(k))].join(", ")})`; // RG-4b: Solid's live view } } private ref(name: string, env: Env, ctx: Ctx): string { diff --git a/ref/compile/rt-server.ts b/ref/compile/rt-server.ts index 48f629e7..b3c7bec5 100644 --- a/ref/compile/rt-server.ts +++ b/ref/compile/rt-server.ts @@ -8,7 +8,7 @@ import { Portal as WebPortal } from "@solidjs/web"; import { getObserver } from "solid-js"; import { provideRequestEvent } from "@solidjs/web/storage"; -import { Show, For, Loading, Errored, createComponent, createSignal, createMemo, createEffect, createOptimistic, createOptimisticStore, createStore, createProjection, createDeferred, untrack, latest, isPending, deep, NotReadyError, getOwner, runWithOwner, getNextChildId, runInServerComponentScope } from "solid-js"; +import { Show, For, Loading, Errored, createComponent, createSignal, createMemo, createEffect, createOptimistic, createOptimisticStore, createStore, createProjection, createDeferred, untrack, latest, isPending, deep, NotReadyError, getOwner, runWithOwner, getNextChildId, runInServerComponentScope, omit } from "solid-js"; import { escape, renderToStream, markSafeError, generateHydrationScript } from "@solidjs/web"; import { $TARGET } from "@solidjs/signals"; @@ -218,7 +218,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown> isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead: (f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown) => catchAcc(f, match, alt)(), throw: (e: unknown): never => { throw e; }, eq: (a: unknown, b: unknown) => a === b, - untrack, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), + untrack, omit, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), fmt, // ---- views: compiled JSX (compile/jsx.ts, generate "ssr", hydratable) does the rest ---- diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index 0b49df82..fb3b009a 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -7,7 +7,7 @@ import { getObserver } from "solid-js"; import { createSignal, createMemo, createEffect, createOptimistic, createOptimisticStore, createStore, createProjection, createDeferred, createErrorBoundary, createContext, Show, For, Loading, Errored, createComponent, - flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, getOwner, runWithOwner, sharedConfig, + flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, getOwner, runWithOwner, sharedConfig, omit, } from "solid-js"; import { getContext, setContext, $TARGET } from "@solidjs/signals"; import { hydrate, render } from "@solidjs/web"; @@ -290,7 +290,7 @@ export function createApp(opts: { root: any; api?: Record<string, (...a: any[]) isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead: (f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown) => catchAcc(f, match, alt)(), throw: (e: unknown): never => { throw e; }, eq: (a: unknown, b: unknown) => raw(a) === raw(b), - untrack, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), + untrack, omit, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), fmt, // ---- views: compiled JSX (compile/jsx.ts, generate "dom") does the rest ---- diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index e97a1136..6e7a4299 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -8,7 +8,7 @@ import { getObserver } from "@solidjs/signals"; import { Show, For, Loading, Errored } from "solid-js"; import { createSignal, createMemo, createEffect, createRenderEffect, createRoot, createOptimistic, createOptimisticStore, createStore, createProjection, createDeferred, - flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, createContext, getContext, setContext, getOwner, runWithOwner, $TARGET, createErrorBoundary, + flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, createContext, getContext, setContext, getOwner, runWithOwner, $TARGET, createErrorBoundary, omit, } from "@solidjs/signals"; // 05 H-9 / Solid D-23: `isPending(fn)` judges the reads fn makes, never its return value, so `pending? x` over a structured @@ -297,7 +297,7 @@ export function createApp(opts: { api?: Record<string, (...a: any[]) => unknown> errorClass, isA, PANIC, catchMemo: catchAcc, tryCatch, catchRead: (f: () => unknown, match: (err: unknown) => boolean, alt: (err: unknown) => unknown) => catchAcc(f, match, alt)(), throw: (e: unknown): never => { throw e; }, eq: (a: unknown, b: unknown) => raw(a) === raw(b), - untrack, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), + untrack, omit, latest: (f: () => unknown) => latest(() => latestWhole(f())), isPending: (f: () => unknown) => isPending(() => readWhole(f())), fmt, // ---- views: compiled JSX (compile/jsx.ts) calls the renderer; these are the two helpers it calls on __rt ---- diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index b434412d..e42d47eb 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -10,7 +10,7 @@ import { Show, For, Loading, Errored } from "solid-js"; import { getObserver } from "@solidjs/signals"; import { createSignal, createMemo, createEffect, createOptimistic, createOptimisticStore, createStore, createProjection, createDeferred, - createRoot, createRenderEffect, flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, resetErrorHalt, createContext, getContext, setContext, getOwner, runWithOwner, $TARGET, createErrorBoundary + createRoot, createRenderEffect, flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, resetErrorHalt, createContext, getContext, setContext, getOwner, runWithOwner, $TARGET, createErrorBoundary, omit } from "@solidjs/signals"; const noFlight = (v: unknown) => { if (v instanceof Flight || v instanceof Promise) throw new Unsupported("`await` not in tail position (the oracle lowers a tail `await` to the memo's result)"); }; @@ -453,6 +453,7 @@ export class Oracle { case "draft": throw new Unsupported("draft outside an equation"); case "use": { const b = this.provision(e.name, scope); return b.k === "const" ? b.value : (b as { get: () => unknown }).get(); } case "spread": throw new Error("spread outside an array literal"); + case "omit": return omit(this.ev(e.e, scope, env) as Record<string, unknown>, ...e.keys); } } diff --git a/ref/src/ast.ts b/ref/src/ast.ts index eebf0ee4..37ce9a1a 100644 --- a/ref/src/ast.ts +++ b/ref/src/ast.ts @@ -22,6 +22,7 @@ export type Expr = | { k: "draft"; seed: Expr; body: Stmt[] } // ST-3: the only writes in reactive scope, to `d` | { k: "use"; name: string } // RG-7b: the nearest provision above | { k: "spread"; e: Expr } // only inside an array literal + | { k: "omit"; e: Expr; keys: string[] } // RG-4b: an object rest, `{ a, ...rest } = e` (the parser's own; Solid's `omit`) | { k: "throw"; e: Expr } // R-f-3: a failure; an expression in reactive scope too | { k: "catch"; e: Expr; name?: string; classes?: string[]; alt: Expr } | { k: "stylemap"; css: string; line?: number } // 07 CSS-5: `styles = <style>…</style>` — a class map, a constant diff --git a/ref/src/check.ts b/ref/src/check.ts index c0ff8894..1528b85d 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -115,7 +115,7 @@ function freeNames(e: Expr, bound: Set<string>, out: Set<string>): void { case "pre": if (!bound.has(e.name)) out.add(`pre:${e.name}`); return; case "peek": go(e.e); return; case "await": go(e.e); if (e.init) go(e.init); return; - case "latest": case "pending": case "un": case "spread": go(e.e); return; + case "latest": case "pending": case "un": case "spread": case "omit": go(e.e); return; case "bin": go(e.l); go(e.r); return; case "if": go(e.c); go(e.t); go(e.f); return; case "tpl": for (const p of e.parts) if (typeof p !== "string") go(p); return; @@ -721,7 +721,7 @@ class Checker { case "use": { if (ctx.server) this.err("SERVER_CAPTURE", "SF-3", `a server function's body reads \`use ${e.name}\`: a provision is the client's — pass it as an argument`); const c = this.ctxOf(e.name, env); if (c) this.model.read(c, ctx.scope === "imperative"); if (c && !c.ctx!.dflt) this.uses.push({ region: env.region, name: c.ctx!.key, line: this.line, display: e.name }); return; } case "peek": this.model.snap++; go(e.e); this.model.snap--; return; // E-5: a snapshot case "latest": case "pending": { const save = this.model.probe; this.model.probe = e.k; go(e.e); this.model.probe = save; return; } // R-9, R-12: probes - case "un": case "spread": go(e.e); return; + case "un": case "spread": case "omit": go(e.e); return; case "await": if (ctx.scope === "imperative" && !this.stmtAwaits.has(e)) this.err("AWAIT_IN_EXPRESSION", "AC-1", `\`${show(e)}\` inside an expression of imperative scope: a run suspends only at a statement — bind it first, \`let v = ${show(e)}\` (a \`catch (e) => alt\` may follow it)`); this.awaitedCalls.add(e.e); go(e.e); if (e.init) go(e.init); return; diff --git a/ref/src/parse.ts b/ref/src/parse.ts index 2e669795..0ab607bf 100644 --- a/ref/src/parse.ts +++ b/ref/src/parse.ts @@ -159,15 +159,20 @@ export function parseDeclarations(src: string): JsDecl[] { } } -/** RG-4b, 07 §5: a destructuring pattern, `{ a, b: c, d = 1, e: { f } }` or `[a, , b]`. Rest (`...r`) is not yet. */ -type Pat = { k: "name"; name: string; line: number } | { k: "obj"; props: { key: string; pat: Pat; dflt?: Expr }[] } | { k: "arr"; items: ({ pat: Pat; dflt?: Expr } | null)[] }; +/** RG-4b, 07 §5: a destructuring pattern, `{ a, b: c, d = 1, e: { f }, ...rest }` or `[a, , b, ...rest]`. */ +type Pat = { k: "name"; name: string; line: number } | { k: "obj"; props: { key: string; pat: Pat; dflt?: Expr }[]; rest?: Pat } | { k: "arr"; items: ({ pat: Pat; dflt?: Expr } | null)[]; rest?: Pat }; +function parseRest(c: Cursor, close: string): Pat { + c.i += 3; c.ws(); const line = c.line; const name = c.ident(); if (!name || KEYWORDS.has(name)) c.fail("a name for the rest"); c.ws(); + if (!c.startsWith(close)) c.fail(`a rest element is last, with no default: \`...${name}${close}\``); + return { k: "name", name: name!, line }; +} function parsePattern(c: Cursor): Pat { c.ws(); if (c.eat("{")) { const props: { key: string; pat: Pat; dflt?: Expr }[] = []; for (;;) { c.ws(); if (c.startsWith("}")) break; - if (c.startsWith("...")) c.fail("RG-4b: a rest pattern (`...rest`) is not supported yet: name the fields, or read them from the value"); + if (c.startsWith("...")) { const rest = parseRest(c, "}"); c.expect("}"); return { k: "obj", props, rest }; } const kline = c.line; const key = c.ident() ?? c.fail("a field name"); c.ws(); const pat: Pat = c.eat(":") ? parsePattern(c) : { k: "name", name: key, line: kline }; c.ws(); const dflt = c.peek() === "=" && c.peek(1) !== "=" && c.peek(1) !== ">" ? (c.i++, c.ws(), parseExpr(c)) : undefined; @@ -181,7 +186,7 @@ function parsePattern(c: Cursor): Pat { for (;;) { c.ws(); if (c.startsWith("]")) break; if (c.startsWith(",")) { c.i++; items.push(null); continue; } - if (c.startsWith("...")) c.fail("RG-4b: a rest pattern (`...rest`) is not supported yet: name the items, or read them from the value"); + if (c.startsWith("...")) { const rest = parseRest(c, "]"); c.expect("]"); return { k: "arr", items, rest }; } const pat = parsePattern(c); c.ws(); const dflt = c.peek() === "=" && c.peek(1) !== "=" && c.peek(1) !== ">" ? (c.i++, c.ws(), parseExpr(c)) : undefined; items.push({ pat, dflt }); c.ws(); @@ -192,12 +197,18 @@ function parsePattern(c: Cursor): Pat { const line = c.line; const name = c.ident(); if (!name || KEYWORDS.has(name)) c.fail("a name to bind"); return { k: "name", name: name!, line }; } -/** Each name a pattern binds, with its projection of `base` (a default applies when the field is `undefined`, as in JS). */ -function expandPattern(pat: Pat, base: Expr, out: { name: string; e: Expr; line: number }[]) { +/** Each name a pattern binds, with its projection of `base` (a default applies when the field is `undefined`, as in JS). + * An object rest is `omit` of the named keys: Solid's live view in reactive scope, a copy of it when `snap` (a `let`). */ +function expandPattern(pat: Pat, base: Expr, out: { name: string; e: Expr; line: number }[], snap = false) { const dfl = (v: Expr, d?: Expr): Expr => d ? { k: "if", c: { k: "bin", op: "==", l: v, r: { k: "lit", v: undefined } }, t: d, f: v } : v; if (pat.k === "name") out.push({ name: pat.name, e: base, line: pat.line }); - else if (pat.k === "obj") for (const p of pat.props) expandPattern(p.pat, dfl({ k: "member", o: base, p: p.key }, p.dflt), out); - else pat.items.forEach((it, i) => { if (it) expandPattern(it.pat, dfl({ k: "index", o: base, i: { k: "lit", v: i } }, it.dflt), out); }); + else if (pat.k === "obj") { + for (const p of pat.props) expandPattern(p.pat, dfl({ k: "member", o: base, p: p.key }, p.dflt), out, snap); + if (pat.rest) { const o: Expr = { k: "omit", e: base, keys: pat.props.map(p => p.key) }; expandPattern(pat.rest, snap ? { k: "rec", fields: [{ spread: o }] } : o, out, snap); } + } else { + pat.items.forEach((it, i) => { if (it) expandPattern(it.pat, dfl({ k: "index", o: base, i: { k: "lit", v: i } }, it.dflt), out, snap); }); + if (pat.rest) expandPattern(pat.rest, { k: "call", f: { k: "member", o: base, p: "slice" }, args: [{ k: "lit", v: pat.items.length }] }, out, snap); + } } /** A right side that is read again per name without asking twice: a name or a path of names. */ const isPath = (e: Expr): boolean => e.k === "id" || (e.k === "member" && !e.opt && isPath(e.o)); @@ -370,7 +381,7 @@ function parseStmt(c: Cursor): Stmt { const pat = parsePattern(c); c.ws(); c.expect("="); c.ws(); let base = parseExpr(c); const out: Stmt[] = []; if (!isPath(base)) { const t = `$$${++c.temps}`; out.push({ k: "let", name: t, e: base }); base = { k: "id", name: t }; } - const names: { name: string; e: Expr; line: number }[] = []; expandPattern(pat, base, names); + const names: { name: string; e: Expr; line: number }[] = []; expandPattern(pat, base, names, true); for (const n of names) out.push({ k: "let", name: n.name, e: n.e }); if (!out.length) c.fail("a destructuring that binds nothing"); c.pendingStmts.push(...out.slice(1)); return out[0]; diff --git a/ref/src/runtime.ts b/ref/src/runtime.ts index 42dc8218..9f5a474b 100644 --- a/ref/src/runtime.ts +++ b/ref/src/runtime.ts @@ -883,6 +883,7 @@ export class Runtime { } case "arr": { const items: unknown[] = []; let bad: unknown; for (const i of e.items) { if (i.k === "spread") { const v = this.ev(i.e, region, env); if (isAbsent(v)) bad ??= v; else items.push(...(v as unknown[])); } else { const v = this.ev(i, region, env); if (isAbsent(v)) bad ??= v; items.push(v); } } return bad ?? items; } case "spread": throw new RuleError("SYNTAX", "spread outside an array literal"); + case "omit": { const v = this.ev(e.e, region, env); if (isAbsent(v)) return v; const o: Record<string, unknown> = {}; if (isObj(v)) for (const k of Object.keys(v)) if (!e.keys.includes(k)) { this.track(v, k); o[k] = v[k]; } return o; } // RG-4b: an object rest case "rec": { const o: Record<string, unknown> = {}; let bad: unknown; for (const f of e.fields) { if ("spread" in f) { const v = this.ev(f.spread, region, env); if (isAbsent(v)) bad ??= v; else if (isObj(v)) { for (const k of Object.keys(v)) { this.track(v, k); o[k] = v[k]; } } continue; } const v = this.ev(f.v, region, env); if (isAbsent(v)) bad ??= v; o[f.key] = v; } return bad ?? o; } case "draft": { // ST-3: a copy of the seed, written only along `d`, is the value const seed = this.ev(e.seed, region, env); if (isAbsent(seed)) return seed; @@ -1251,7 +1252,7 @@ function freeVars(e: Expr, out: Set<string>) { case "rec": e.fields.forEach(f => freeVars("spread" in f ? f.spread : f.v, out)); break; case "tpl": e.parts.forEach(p => { if (typeof p !== "string") freeVars(p, out); }); break; case "fn": { const o = new Set<string>(); freeVars(e.body, o); for (const st of e.stmts ?? []) if ("e" in st) freeVars(st.e, o); for (const n of o) if (!e.params.includes(n)) out.add(n); break; } - case "spread": freeVars(e.e, out); break; + case "spread": case "omit": freeVars(e.e, out); break; case "use": break; case "draft": { freeVars(e.seed, out); for (const st of e.body) { if (st.k === "write" || st.k === "let" || st.k === "expr" || st.k === "optimistic") freeVars(st.e, out); if (st.k === "write") for (const p of st.target.path) if (typeof p !== "string") freeVars(p, out); } out.delete("d"); break; } } diff --git a/ref/types/project.ts b/ref/types/project.ts index 57cce21e..927756b1 100644 --- a/ref/types/project.ts +++ b/ref/types/project.ts @@ -32,6 +32,8 @@ export declare function eq<T>(v: T, ok: Async<T> extends true ? "MISSING_AWAIT: export declare function first<T, I>(v: T, init: I): T | I; export declare function pre<T>(init?: T): T; export declare function pending(v: unknown): boolean; +/** RG-4b: an object rest, \`{ a, ...rest } = e\` (Solid's \`omit\`). */ +export declare function omit<T, K extends string>(v: T, ...keys: K[]): Omit<T, K>; export declare function caught<T, R>(v: T, alt: (e: any) => R): T | R; export declare function raise(e: unknown): never; /** W-1/W-6: a write checks its value (or its function of the slot) against the target — whose type alone decides it. */ @@ -259,6 +261,7 @@ class Proj { case "arr": return `[${e.items.map(go).join(", ")}]`; case "rec": return `({ ${e.fields.map(f => "spread" in f ? `...${go(f.spread)}` : `${J(f.key)}: ${go(f.v)}`).join(", ")} })`; case "spread": return `...${go(e.e)}`; + case "omit": return `__.omit(${[go(e.e), ...e.keys.map(k => J(k))].join(", ")})`; case "fn": { const ps = e.params.join(", "); if (!e.stmts) return `((${ps}) => ${this.expr(e.body, imp, self)})`; diff --git a/scenarios/S-destructuring.md b/scenarios/S-destructuring.md index 46b2db24..3c32f516 100644 --- a/scenarios/S-destructuring.md +++ b/scenarios/S-destructuring.md @@ -5,20 +5,20 @@ Rules: RG-4b, E-1, 07 §5, W-4. ## Program ```rx -component Card({ title, size = "m", owner: { name } }) { - { a, b: bee } = props.pair - [first, , third = "z"] = props.list - <p>{title} {size} {name} {a} {bee} {first} {third}</p> +component Card({ title, size = "m", owner: { name }, ...rest }) { + { a, b: bee } = rest.pair + [first, , third = "z", ...more] = rest.list + <p>{title} {size} {name} {a} {bee} {first} {third} {more.length}</p> } export default component App() { source pair := { a: 1, b: 2 } source list := ["x", "y"] - { a } = pair - [head] = list.map(s => s.toUpperCase()) + { a, ...others } = pair + [head, ...tail] = list.map(s => s.toUpperCase()) <Card title="T" owner={{ name: "Ann" }} pair={pair} list={list} /> - <p>{a} {head}</p> + <p>{a} {others.b} {head} {tail.length}</p> } ``` @@ -26,20 +26,24 @@ Each name a reactive pattern binds is an equation, a projection of the right side that keeps updating (RG-4b): `{ a } = pair` is `a = pair.a`, and `component Card({ title })` is `title = props.title`. A default applies when the field is `undefined`, as in JS. A right side that is not a -name (`list.map(…)`) is computed once and read per name. In a handler, -`let { … } = e` is JavaScript's: each name a snapshot (07 §5). +name (`list.map(…)`) is computed once and read per name. An object rest is +the right side without the named fields, as Solid's `omit` (a live view: +`rest.pair` is `props.pair`); an array rest is the items after the named +ones. In a handler, `let { … } = e` is JavaScript's: each name a snapshot, +and a rest a copy (07 §5). ## Frames | instant | action | frame | | ------- | --------------- | ---------------------------- | -| 0 | mount | `T m Ann 1 2 x z1 X` | -| 1 | handler `let { a: q, b } = pair; let [x = 0] = [q + b]; pair := { a: x, b: q }` | `T m Ann 3 1 x z3 X` | -| 2 | handler `list := ["p", "q", "r"]` | `T m Ann 3 1 p r3 P` | +| 0 | mount | `T m Ann 1 2 x z 01 2 X 1` | +| 1 | handler `let { a: q, ...r } = pair; let [x = 0] = [q + r.b]; pair := { a: x, b: q }` | `T m Ann 3 1 x z 03 1 X 1` | +| 2 | handler `list := ["p", "q", "r", "s"]` | `T m Ann 3 1 p r 13 1 P 3` | -Instant 1: the handler's locals are the snapshot `q = 1, b = 2`, so the -write is `{ a: 3, b: 1 }`; every projection of `pair` updates. Instant 2: -the list's third item now exists, so the default gives way. +Instant 1: the handler's locals are the snapshot `q = 1, r = { b: 2 }`, so +the write is `{ a: 3, b: 1 }`; every projection of `pair` updates, through +the rests too. Instant 2: the list's third item now exists, so the default +gives way, and each rest takes the items after its pattern's. ## Rejected variants @@ -47,12 +51,12 @@ the list's third item now exists, so the default gives way. -- reject: PARSE export default component App() { source pair := { a: 1, b: 2 } - { a, ...rest } = pair + { ...rest, a } = pair <p>{a}</p> } ``` -A rest pattern is not supported yet (`omit` is not in the prelude). +A rest element is last, as in JS. ```rx -- reject: WRITE_TO_NON_SOURCE @@ -64,3 +68,14 @@ export default component App() { ``` A destructured name is an equation: write the source it projects. + +```rx +-- reject: WRITE_TO_NON_SOURCE +export default component App() { + source pair := { a: 1, b: 2 } + { a, ...rest } = pair + <button onClick={() => { rest.b := 5 }}>{a}</button> +} +``` + +A rest is an equation too: `pair.b := 5` writes the field it shows. diff --git a/spec/07-surface.md b/spec/07-surface.md index 7543c69b..185007f4 100644 --- a/spec/07-surface.md +++ b/spec/07-surface.md @@ -439,8 +439,8 @@ Decl' ::= "source" Name Type? ("key" Name)? (":=" | "=") Expr | "action" Name "(" Params ")" Block | "component" Name "(" (Name | ObjPattern) Type? ")" "{" RBlock "}" -- a pattern projects props (RG-4b) | "error" Name ("(" Params ")")? ("=>" Expr)? -- R-f-1 -Pattern ::= ObjPattern | "[" (Bind? ",")* Bind? "]" -- no rest (`...r`) yet -ObjPattern ::= "{" (Name (":" Bind | ("=" Expr)?)) ,* "}" +Pattern ::= ObjPattern | "[" (Bind? ",")* (Bind | "..." Name)? "]" -- an array rest: the items after +ObjPattern ::= "{" (Name (":" Bind | ("=" Expr)?)) ,* ("..." Name)? "}" -- an object rest: Solid's `omit` of the named keys Bind ::= (Name | Pattern) ("=" Expr)? -- a default applies when the field is `undefined` RBlock ::= Decl* Markup* Markup ::= JSXElement | "{" Expr "}" | Control @@ -553,5 +553,7 @@ Expr ::= TSExpr with §3's removals and additions snapshot local per name, as in JS. So bare `x = e` is always an equation and `let x = e` always a snapshot, and only `:=` and `guess` mutate: a destructured name is an equation, so `a := v` is `WRITE_TO_NON_SOURCE`. - Defaults apply on `undefined`, as in JS. Rest patterns (`...r`) are not - yet supported: they need `omit` on a live value (06). + Defaults apply on `undefined`, as in JS. A rest is last, as in JS: an + object rest `{ a, ...rest } = e` is Solid's `omit(e, "a")`, a live view + of the other fields (`rest.b` stays `e.b`), and an array rest + `[x, ...rest] = e` is `e.slice(1)`; in a `let`, each is a copy.