diff --git a/compiler/src/check.rs b/compiler/src/check.rs index 7697f96..8d0c37e 100644 --- a/compiler/src/check.rs +++ b/compiler/src/check.rs @@ -3058,11 +3058,11 @@ impl<'a> Checker<'a> { continue; } seen.insert(s); - if attrs.iter().any(|a| a.name == "disabled" && self.depends(&a.e, env, s, &mut HashSet::new())) { - continue; - } - let need_hold = absent && !self.region_reads(region, s, |r| r.provides.contains(HOLD)); - let need_catch = failing && !self.region_reads(region, s, |r| r.catches.is_some()); + // a `disabled` that reads `s` covers the handler; for a failure it must read the value, since + // `pending? s` of a failed `s` is false and leaves the element enabled + let disabled_by = |probes: bool| attrs.iter().any(|a| a.name == "disabled" && self.depends_in(&a.e, env, s, &mut HashSet::new(), probes)); + let need_hold = absent && !disabled_by(true) && !self.region_reads(region, s, |r| r.provides.contains(HOLD)); + let need_catch = failing && !disabled_by(false) && !self.region_reads_in(region, s, |r| r.catches.is_some(), false); if !need_hold && !need_catch { continue; } @@ -3408,18 +3408,24 @@ impl<'a> Checker<'a> { } /// R-8: `e` reads `s`, directly or through the equations it reads. fn depends(&self, e: &Expr, env: EId, s: SId, busy: &mut HashSet) -> bool { + self.depends_in(e, env, s, busy, true) + } + /// `probes: false`: only reads of `s`'s value count, not of its verdict (`pending? s`), which takes no failure + /// (R-8, ruled 2026-11-13). + fn depends_in(&self, e: &Expr, env: EId, s: SId, busy: &mut HashSet, probes: bool) -> bool { let mut stack = vec![Node::E(e)]; while let Some(n) = stack.pop() { if let Node::E(x) = n { match &x.k { ExprKind::Fn { .. } | ExprKind::ServerFn { .. } => continue, + ExprKind::Pending(_) if !probes => continue, ExprKind::Id(name) => { if let Some(t) = self.lookup(env, name) { if t == s { return true; } if let Some((te, tenv)) = self.syms[t].expr { - if busy.insert(t) && self.depends(te, tenv, s, busy) { + if busy.insert(t) && self.depends_in(te, tenv, s, busy, probes) { return true; } } @@ -3435,11 +3441,16 @@ impl<'a> Checker<'a> { } /// R-8: some region around `r` that is `kind` has a reader of `s` in it. fn region_reads(&self, r: RId, s: SId, kind: impl Fn(&Region) -> bool) -> bool { + self.region_reads_in(r, s, kind, true) + } + /// `probes`: whether a read under `pending?` counts. For the failure half it does not: the region catches only + /// what its content reads (ruled 2026-11-13, found by the desk9 reference's fuzz seed 11). + fn region_reads_in(&self, r: RId, s: SId, kind: impl Fn(&Region) -> bool, probes: bool) -> bool { let mut cur = Some(r); while let Some(c) = cur { if kind(&self.regions[c]) { for rd in &self.readers { - if self.inside(rd.region, c) && self.depends(rd.e, rd.env, s, &mut HashSet::new()) { + if self.inside(rd.region, c) && self.depends_in(rd.e, rd.env, s, &mut HashSet::new(), probes) { return true; } } @@ -4271,6 +4282,35 @@ mod tests { check_modules(&set, spa).diags.iter().map(|d| (d.line, d.code.clone())).collect() } + /// 04 R-8 (ruled 2026-11-13): an `errored` region covers a handler's read of a failing value only when its content + /// reads the value; `pending? x` takes no failure (the desk9 reference's fuzz seed 11). + #[test] + fn errored_region_covers_only_a_value_read() { + let src = r#"export default component App() { + source id := 1 + labels = await fetch("labels:" + id) + errored fallback(e) {

failed

} { + loading fallback {

loading

} { + + } + } + errored fallback(e) {

failed

} { + loading fallback {

loading

} { +

{labels.length}

+ + } + } + loading fallback {

loading

} { + + } +} +"#; + let got: Vec = codes(src, false).iter().filter(|(_, c)| c == "UNGUARDED_SNAPSHOT").map(|(l, _)| *l).collect(); + // `probe`: its region reads only `pending? labels`, so a failure leaves it live; `read` sits beside a value + // read; `disabled` reads the value, so a failure disables it + assert_eq!(got, vec![6], "{:?}", codes(src, false)); + } + /// 06 AC-3a: a guess a click can make while its value loads a new question, unless the element is covered. #[test] fn guess_while_loading() { diff --git a/eval/reference/chain1/rx/labels.rx b/eval/reference/chain1/rx/labels.rx index 1fb4ff9..43c048a 100644 --- a/eval/reference/chain1/rx/labels.rx +++ b/eval/reference/chain1/rx/labels.rx @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) { { labelInput := e.target.value }} /> {loading on(props.id) fallback { } { {errored fallback(e) { } { - + // the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click + {for have in [shown] { + + }} }} }} {if labelError != "" { diff --git a/eval/reference/chain2/rx/labels.rx b/eval/reference/chain2/rx/labels.rx index 1fb4ff9..43c048a 100644 --- a/eval/reference/chain2/rx/labels.rx +++ b/eval/reference/chain2/rx/labels.rx @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) { { labelInput := e.target.value }} /> {loading on(props.id) fallback { } { {errored fallback(e) { } { - + // the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click + {for have in [shown] { + + }} }} }} {if labelError != "" { diff --git a/eval/reference/chain3/rx/labels.rx b/eval/reference/chain3/rx/labels.rx index 1fb4ff9..43c048a 100644 --- a/eval/reference/chain3/rx/labels.rx +++ b/eval/reference/chain3/rx/labels.rx @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) { { labelInput := e.target.value }} /> {loading on(props.id) fallback { } { {errored fallback(e) { } { - + // the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click + {for have in [shown] { + + }} }} }} {if labelError != "" { diff --git a/eval/reference/chain4/rx/labels.rx b/eval/reference/chain4/rx/labels.rx index 1fb4ff9..43c048a 100644 --- a/eval/reference/chain4/rx/labels.rx +++ b/eval/reference/chain4/rx/labels.rx @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) { { labelInput := e.target.value }} /> {loading on(props.id) fallback { } { {errored fallback(e) { } { - + // the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click + {for have in [shown] { + + }} }} }} {if labelError != "" { diff --git a/eval/reference/desk9/rx/comments.rx b/eval/reference/desk9/rx/comments.rx new file mode 100644 index 0000000..8517b69 --- /dev/null +++ b/eval/reference/desk9/rx/comments.rx @@ -0,0 +1,55 @@ +// The selected issue's comments, and posting one. Posts in flight live in their own source, so changing the +// selection never waits for them, and each shows only under its own issue. + +import pure { fetchComments } from "./api" +import { addComment } from "./api" + +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 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) { + onFlush(() => { postError := null }) + try { + guess sending := [...sending, { id: id, author: author, text: text }] + await addComment(id, author, text) + refresh comments + } catch (e) { + postError := e.message + } + } + +
+
+ {loading on(props.id) fallback {

loading comments

} { + {errored fallback(e) {

comments error: {e.message}

} { +
    + {for c in comments { +
  • {c.author}: {c.text}
  • + }} + {for s in shownSending { +
  • {s.author}: {s.text} (sending…)
  • + }} +
+ }} + }} +
+ { commentInput := e.target.value }} /> + + {if postError != null { +

post error: {postError}

+ }} +
+} diff --git a/eval/reference/desk9/rx/desk.rx b/eval/reference/desk9/rx/desk.rx new file mode 100644 index 0000000..69a1d5a --- /dev/null +++ b/eval/reference/desk9/rx/desk.rx @@ -0,0 +1,120 @@ +// The issues the desk shows, and every write on them. This is app state, not the issues page's: a write the user +// made outlives the page it was made on, so its refresh reaches the list and detail on screen when it lands, even +// after the page was left and opened again. +// +// Three layers (guide/async.md): `issues` and `detail` are the server's answers; `renames`, `assigns` and `closing` +// hold the writes still out, as guesses; `rows` and `heading` lay those writes over whatever the answers are. A row +// that arrives from a reload while a write on it is out shows the write too, and the list row and the detail heading +// read one rename, so they cannot disagree. + +import pure { fetchIssues, fetchIssue } from "./api" +import { assign, renameIssue, closeIssue } from "./api" + +export error NotFound(id: number) => `issue #${id} not found` + +fn found(it, id: number) => it == null ? throw NotFound(id) : it + +type Rename = { id: number; title: string } +type Assign = { id: number; who: string } + +fn titleOf(rs: Rename[], id: number, title: string): string => rs.reduce((t, r) => r.id == id ? r.title : t, title) +fn assigneeOf(xs: Assign[], id: number, who: string | null): string | null => xs.reduce((w, x) => x.id == id ? x.who : w, who) + +// opening the issues page starts it as the app starts: a new visit asks for the list again +export source visit: number := 0 +export source filter: string := "open" +export source selected: number | null := null + +issues key id = visit >= 0 ? await fetchIssues(filter) : [] +export detail = selected == null ? null : found(await fetchIssue(selected), selected) + +// the writes still out: each is a guess its action makes. An accepted write ends with the list's re-ask, and its +// guess stays until that answer lands (D-34); a declined one reverts with its message, in one frame (AC-7) +source renames: Rename[] = [] +source assigns: Assign[] = [] +source closing: number[] = [] + +shown = issues.filter(r => !(filter == "open" && closing.includes(r.id))) +export rows key id = shown.map(r => ({ id: r.id, title: titleOf(renames, r.id, r.title), assignee: assigneeOf(assigns, r.id, r.assignee) })) +export heading = detail == null ? "" : titleOf(renames, detail.id, detail.title) + +export source assignError: string := "" +export source renameError: string := "" +export source closeError: string := "" +// the newest row action's failure; row actions and the detail's leave each other's errors alone +export source rowError: string := "" + +export action openPage() { + visit := visit + 1 + filter := "open" + selected := null + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + rowError := "" + }) +} + +export action showFilter(f: string) { + filter := f +} + +export action select(id: number) { + selected := id + onFlush(() => { + assignError := "" + renameError := "" + closeError := "" + }) +} + +export action assignTo(id: number, who: string, fromRow: boolean) { + if (fromRow) { + onFlush(() => { rowError := "" }) + } else { + onFlush(() => { assignError := "" }) + } + try { + guess assigns := xs => [...xs, { id: id, who: who }] + await assign(id, who) + refresh issues + } catch (e) { + if (fromRow) { + rowError := e.message + } else { + assignError := e.message + } + } +} + +export action renameTo(id: number, title: string) { + onFlush(() => { renameError := "" }) + try { + guess renames := rs => [...rs, { id: id, title: title }] + await renameIssue(id, title) + refresh issues + refresh detail + } catch (e) { + renameError := e.message + } +} + +export action closeOne(id: number, fromRow: boolean) { + if (fromRow) { + onFlush(() => { rowError := "" }) + } else { + onFlush(() => { closeError := "" }) + } + try { + guess closing := cs => [...cs, id] + await closeIssue(id) + refresh issues + } catch (e) { + if (fromRow) { + rowError := e.message + } else { + closeError := e.message + } + } +} diff --git a/eval/reference/desk9/rx/help.rx b/eval/reference/desk9/rx/help.rx new file mode 100644 index 0000000..267e779 --- /dev/null +++ b/eval/reference/desk9/rx/help.rx @@ -0,0 +1,53 @@ +// The help page: search the help catalogue and open one result. + +import pure { search, fetchItem } from "./api" + +export error TooShort(q: string) => `too short: ${q}` +export error ItemNotFound(id: number) => `item ${id} not found` + +fn foundItem(it, id: number) => it == null ? throw ItemNotFound(id) : it + +// Opens on `query` with `selected` chosen (both empty from the tab; a related hit opens it on its issue's title). +export component HelpPage(props: { query: string; selected: number | null }) { + source query: string := peek(props.query) + source selected: number | null := peek(props.selected) + results = query == "" ? [] : query.length < 2 ? throw TooShort(query) : await search(query) + item = selected == null ? null : foundItem(await fetchItem(selected), selected) + +
+ { query := e.target.value }} /> + {if query != "" { +
+ {errored fallback(e) {

search failed: {e.message}

} { + errored fallback(e: TooShort) {

{e.message}

} { + loading fallback {

searching…

} { +

{pending? results ? "searching…" : ""}

+

{results.length} results for {latest query}

+
    + {for r in results { +
  • { selected := r.id }}>{r.id == selected ? "> " : ""}{r.name}
  • + }} +
+ } + } + }} +
+ }} + {if selected == null { +

pick a result

+ } else { +
+ {loading on(selected) fallback {

loading…

} { + {for id in [selected] { + {errored fallback(e) {

failed: {e.message}

} { + {errored fallback(e: ItemNotFound) {

{e.message}

} { +

{item.name}

+

{item.blurb}

+ }} + }} + }} + }} +
+ }} +
+} diff --git a/eval/reference/desk9/rx/issues.rx b/eval/reference/desk9/rx/issues.rx new file mode 100644 index 0000000..b22a170 --- /dev/null +++ b/eval/reference/desk9/rx/issues.rx @@ -0,0 +1,88 @@ +// The issues page: the filtered list, the selected issue's detail, and the controls for the writes in desk.rx. + +import { filter, selected, rows, detail, heading, assignError, renameError, closeError, rowError } from "./desk.rx" +import { showFilter, select, assignTo, renameTo, closeOne } from "./desk.rx" +import { Comments } from "./comments.rx" +import { Labels } from "./labels.rx" +import { Related } from "./related.rx" + +// the open list as shown (closes in flight already out of it) is the tab's count: it is read where the list is read, +// under the list's boundary, so it holds and reveals with the rows; other lists, loads and failures leave it as it was +component OpenCount(props: { n: number; onCount: (n: number) => void }) { + effect (props.n) { + props.onCount($0) + } +} + +export component IssuesPage(props: { onOpenCount: (n: number) => void; onOpenHelp: (query: string, id: number) => void }) { + source user: string := "guest" + source titleInput: string := "" + +
+
+ +
+
+ + +
+ {if rowError != "" { +

row error: {rowError}

+ }} +
+ {loading on(filter) fallback {

loading {latest filter} issues

} { + {errored fallback(e) {

list error: {e.message}

} { + {if filter == "open" { + { props.onOpenCount(n) }} /> + }} +
    + {for it in rows { +
  • + { + titleInput := "" + select(it.id) + }}>#{it.id} {it.title} ({it.assignee ?? "unassigned"}){" "} + + {if filter == "open" { + {" "} + }} +
  • + }} +
+ }} + }} +
+
+ {if selected != null { + {loading on(selected) fallback {

loading #{latest selected}

} { + {errored fallback(e) {

detail error: {e.message}

} { +

#{detail.id} {heading}

+

{detail.body}

+ { props.onOpenHelp(q, id) }} /> + }} + + }} + + {if assignError != "" { +

{assignError}

+ }} + { titleInput := e.target.value }} /> + + {if renameError != "" { +

rename error: {renameError}

+ }} + + {if closeError != "" { +

close error: {closeError}

+ }} + + + } else { +

select an issue

+ }} +
+
+} diff --git a/eval/reference/desk9/rx/labels.rx b/eval/reference/desk9/rx/labels.rx new file mode 100644 index 0000000..43c048a --- /dev/null +++ b/eval/reference/desk9/rx/labels.rx @@ -0,0 +1,74 @@ +// The selected issue's labels, adding and removing them. Changes in flight live in their own sources, so changing +// the selection never waits for them, and each shows only under its own issue. + +import pure { fetchLabels } from "./api" +import { addLabel, removeLabel } from "./api" + +export component Labels(props: { id: number }) { + source labels = await fetchLabels(props.id) + source adding: { id: number; label: string }[] = [] + source removing: { id: number; label: string }[] = [] + shown = [ + ...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 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) { + onFlush(() => { labelError := "" }) + try { + guess adding := [...adding, { id: id, label: label }] + await addLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + + action remove(id: number, label: string) { + onFlush(() => { labelError := "" }) + try { + guess removing := [...removing, { id: id, label: label }] + await removeLabel(id, label) + refresh labels + } catch (e) { + labelError := e.message + } + } + +
+
+ {loading on(props.id) fallback {

loading labels

} { + {errored fallback(e) {

labels error: {e.message}

} { +
    + {for l in shown { +
  • {l}{" "}
  • + }} +
+ }} + }} +
+ { labelInput := e.target.value }} /> + {loading on(props.id) fallback { } { + {errored fallback(e) { } { + // the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click + {for have in [shown] { + + }} + }} + }} + {if labelError != "" { +

label error: {labelError}

+ }} +
+} diff --git a/eval/reference/desk9/rx/main.rx b/eval/reference/desk9/rx/main.rx new file mode 100644 index 0000000..09403ee --- /dev/null +++ b/eval/reference/desk9/rx/main.rx @@ -0,0 +1,43 @@ +// The desk: three pages behind tabs. Only the open page is mounted, so leaving one discards its state. + +import { IssuesPage } from "./issues.rx" +import { HelpPage } from "./help.rx" +import { SettingsPage } from "./settings.rx" +import { openPage } from "./desk.rx" + +export default component App() { + source page: string := "issues" + source openCount: number | null := null + source helpQuery: string := "" + source helpSelected: number | null := null + +
+ + {if page == "issues" { + { openCount := n }} onOpenHelp={(q, id) => { + helpQuery := q + helpSelected := id + page := "help" + }} /> + } else { + {if page == "help" { + + } else { + + }} + }} +
+} diff --git a/eval/reference/desk9/rx/related.rx b/eval/reference/desk9/rx/related.rx new file mode 100644 index 0000000..aea6011 --- /dev/null +++ b/eval/reference/desk9/rx/related.rx @@ -0,0 +1,23 @@ +// Help related to an issue: the first hits of a search for its title. + +import pure { search } from "./api" + +export component Related(props: { title: string; onOpen: (query: string, id: number) => void }) { + hits = await search(props.title) + +
+ {loading on(props.title) fallback {

finding related help

} { + {errored fallback(e) {

related help failed: {e.message}

} { + {if hits.length == 0 { +

no related help

+ } else { +
    + {for h in hits.slice(0, 3) { +
  • + }} +
+ }} + }} + }} +
+} diff --git a/eval/reference/desk9/rx/settings.rx b/eval/reference/desk9/rx/settings.rx new file mode 100644 index 0000000..5093aac --- /dev/null +++ b/eval/reference/desk9/rx/settings.rx @@ -0,0 +1,61 @@ +// The settings page: the saved settings, a draft form seeded from them, and saving. + +import pure { loadSettings } from "./api" +import { saveSettings, Settings } from "./api" + +export error NameRequired() => "Display name is required" +export error NameTooLong(count: number) => `Display name must be at most 20 characters, not ${count}` + +fn validName(name: string): string => + name.length == 0 ? throw NameRequired() + : name.length > 20 ? throw NameTooLong(name.length) + : name + +export component SettingsPage() { + source settings: Settings = await loadSettings() + source saveError: string := "" + + action save(next: Settings) { + guess settings := next + onFlush(() => { saveError := "" }) + affects settings + try { + let saved = await saveSettings(next) + settings := saved + } catch (e) { + saveError := e.message + } + } + + errored fallback(e) { +

Could not load settings: {e.message}

+ + } { + loading fallback {

Loading settings…

} { + source draftName: string := peek(settings.name) + source draftTheme: string := peek(settings.theme) + source draftDensity: string := peek(settings.density) + nameError: string = (validName(draftName) && "") catch (e: NameRequired | NameTooLong) => e.message +

Saved: {settings.name} · {settings.theme} · {settings.density}{pending? settings ? " (saving…)" : ""}

+ {if saveError != "" { +

Save failed: {saveError}

+ }} + {" "} + {draftName.length}/20 + {if nameError != "" { +

{nameError}

+ }} +
+ {" "} + {" "} + +
+

Preview: {draftTheme} / {draftDensity}

+ + } + } +} diff --git a/eval/reference/labels/rx/labels.rx b/eval/reference/labels/rx/labels.rx index 1fb4ff9..43c048a 100644 --- a/eval/reference/labels/rx/labels.rx +++ b/eval/reference/labels/rx/labels.rx @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) { { labelInput := e.target.value }} /> {loading on(props.id) fallback { } { {errored fallback(e) { } { - + // the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click + {for have in [shown] { + + }} }} }} {if labelError != "" { diff --git a/eval/results/desk9-reference-2026-11-13/README.md b/eval/results/desk9-reference-2026-11-13/README.md new file mode 100644 index 0000000..acf018a --- /dev/null +++ b/eval/results/desk9-reference-2026-11-13/README.md @@ -0,0 +1,59 @@ +# A hand-written .rx desk9 reference, graded (2026-11-13) + +Not a preregistered round, and no agent ran. The question: can .rx, on current Solid, reach **zero defects** on +desk9 when the code is written by hand, and if not, is the floor the language, Solid, or the guide? Prompted by +Exact's `bench/reactivescript` (expo/exact), which grades a hand-written Contract reference with this harness at 0 +after three language changes (their LLP 1161). + +**Setup:** Solid `next` at cd4de287 (holds the fixes for #4821, #4824, #4825; #4828 is still open), built from source +and linked over `eval/node_modules`. `rxc` from this branch. Graded with `harness/grade.sh` (hidden 77, overlap 38, +20 fuzz seeds with `--r9`). + +## Results + +| app | hidden | overlap | fuzz seeds with a hole | defects | +| --- | ---: | ---: | ---: | ---: | +| `reference/chain4/rx` (round 7's hand-written app, desk9's features) | 77/77 | 38/38 | 3 of 20 | 3 | +| `reference/desk9/rx` (this branch) | 77/77 | 38/38 | 0 of 20 | **0** | + +## What the three holes in the chain4 app were + +- **Seed 8, row title (the app).** A rename made while its row was not in the list on screen guessed nothing + (`findIndex` gave -1). The row then arrived from another write's re-ask with the old title, and after the rename was + accepted it showed that title until the list was asked again. +- **Seed 12, two views (the app).** A rename made before the issues tab was left and opened again: the new page's list + had been asked before the rename landed, and the rename's `refresh` re-asked the old page's list, which was gone. + The detail, asked after, showed the new title beside the stale row. +- **Seed 11, page error (a gap in R-8).** The `add-label` button sat inside an `errored` region whose markup read + only `pending? shown`. When the labels failed, nothing in that region read the failure, so the button stayed live, + and its handler's `shown.includes(…)` threw as an uncaught error. The checker accepts a handler read under an + `errored` region (R-8), but the region catches only failures its content reads. **Ruled by Ryan (2026-11-13): + tighten R-8.** An `errored` region, or a `disabled`, covers the failure half only when it reads the value, not only + `pending?` of it. `rxc` now refuses seed 11's shape (`UNGUARDED_SNAPSHOT`); the five references with that + `labels.rx` (chain1-4, labels) are fixed the same way as desk9's. + +## What the reference does differently + +- **Issue data lives as long as its writes** (`desk.rx`; Ryan: data belongs with its lifetime), as in Exact's reference ("every resource is the root's"): the list, the + detail and the writes live in one module, so a write's re-ask reaches the list and detail on screen when it lands, + whatever was left and reopened in between. Opening the issues tab bumps `visit`, which the list's question reads, + so the page still starts loading again. +- **Writes are laid over every answer.** Each kind of write guesses a list of its own with a constant question + (`renames`, `assigns`, `closing`), and `rows` and `heading` apply them to whatever the answers are. This is + `guide/async.md`'s "three layers" used for every write, and it is Exact's `overlay` written as equations: a row + that arrives from a reload while a write on it is out shows the write, and the row and the heading read one rename, + so they cannot disagree. Guessing a constant-question source also never holds a filter switch or a selection. +- **An accepted write ends with the list's re-ask; a declined one does not**, so a revert and its message show in + one frame, while an accepted guess stays until the server's list lands (D-34). +- **A control reads what it guards**: `add-label` renders from `shown` (`for have in [shown]`), so a failure puts + it on its region's fallback. + +442 non-blank, non-comment lines (chain4's: 411; Exact's reference: 476). + +## A runtime bug found on the way + +`refresh x` of a keyed equation (`rows key id = await …`) re-asked nothing in the compiled runtime and the oracle: +`keyed` returned the projection's accessor without the memo under it. An action ending in such a refresh ended at +once and took its guesses with it. Fixed in `ref/compile/rt.ts`, `ref/compile/rt-web.ts` and `ref/oracle/lower.ts`; +`scenarios/S-keyed-refresh.md` pins it on all three runners. Suites on Solid cd4de287: oracle and compiled 123 pass, +4 fail, the same 4 as without the fix; the interpreter passes. diff --git a/eval/results/desk9-reference-2026-11-13/chain4-rx.grade.txt b/eval/results/desk9-reference-2026-11-13/chain4-rx.grade.txt new file mode 100644 index 0000000..866078b --- /dev/null +++ b/eval/results/desk9-reference-2026-11-13/chain4-rx.grade.txt @@ -0,0 +1,12 @@ +== hidden +77/77 passed +== overlap +38/38 passed +== fuzz +ref0: 3/20 seeds found a hole + seed 8 step 40: row title: #1 shows "Login fails", server "Delta" + land fetchIssues:open ; row-close 1 ; filter closed ; fail fetchIssues:closed ; select #4 ; land closeIssue:3 ; select #1 ; land fetchIssues:closed ; add-label bug ; (move failed: locator.click: Timeout 2000ms exceeded.) ; rename Bravo ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchIssue:1 ; rename Delta ; close ; add-label ui ; (move failed: locator.click: Timeout 2000ms + seed 11 step 39: page error: declined + tab help ; tab issues ; tab help ; land fetchIssues:open ; tab issues ; focus ; tab help ; tab issues ; land fetchIssues:open ; row-assign 1 ; land assign:3,guest ; filter closed ; user ann ; row-assign 0 ; focus ; land fetchIssues:open ; land assign:1,ann ; select #4 ; close ; land fetchIssues:closed ; close ; land fetchComments:4 ; fail fetchLabels:4 ; row-assign 0 ; close ; land fetchIssue: + seed 12 step 39: two views: row "#4 Typo in footer", detail "#4 Alphafooter says 2019saving filescras" + land fetchIssues:open ; focus ; select #1 ; row-close 1 ; select #4 ; land closeIssue:3 ; row-assign 1 ; rename Alpha ; (move failed: locator.click: Timeout 2000ms exceeded.) ; close ; rename Alpha ; (move failed: locator.click: Timeout 2000ms exceeded.) ; land fetchIssue:1 ; land fetchIssue:4 ; row-close 0 ; rename Alpha ; fail closeIssue:1 ; land assign:4,guest ; land fetchLabels:1 ; land fe diff --git a/eval/results/desk9-reference-2026-11-13/desk9-rx.grade.txt b/eval/results/desk9-reference-2026-11-13/desk9-rx.grade.txt new file mode 100644 index 0000000..0e6e2a6 --- /dev/null +++ b/eval/results/desk9-reference-2026-11-13/desk9-rx.grade.txt @@ -0,0 +1,6 @@ +== hidden +77/77 passed +== overlap +38/38 passed +== fuzz +ref1: 0/20 seeds found a hole diff --git a/guide/async.md b/guide/async.md index 6760b42..e04289a 100644 --- a/guide/async.md +++ b/guide/async.md @@ -96,7 +96,8 @@ To guess a value a component gets as a prop, guess its own equation of it: `show ### Writing one row of a list -When several actions can each change a different row of one list, guess the row, not the list: +When several actions can each change a different row of one list, guess the row, not the list. (When the write also +shows somewhere else, or its row may not be on screen when it is made, use "Every view of a write" below instead.) ```rx export source todos: Todo[] key id = await loadTodos() @@ -157,6 +158,60 @@ export action addTag(p: number, tag: string) { Changing `photo` while a tag is saving shows the new photo's tags at once, and the pending tag only under its own photo. +### Every view of a write: lay it over the answer + +A row guess (`guess todos[i].done := true`) changes one slot of one answer. It misses a write whose item is not on +screen when the click happens (a filtered-out row, a list still loading): `findIndex` gives -1 and nothing shows, even +when the item arrives from a reload while the write is out. And it shows in only one place: a title shown in a list +row and in a heading needs a guess on each, and missing one makes the two disagree. + +When a write can show in more than one place, or on an item that may not be on screen yet, keep the writes in flight +in a list of their own and lay them over every answer in an equation. Every view reads that equation: + +```rx +server key id = await loadProjects(group) // the server's answer, whatever it is now +source renaming: { id: string; name: string }[] = [] // writes in flight: a guess list, constant question + +fn nameOf(rs, id: string, name: string) => rs.reduce((n, r) => r.id == id ? r.name : n, name) +projects key id = server.map(p => ({ ...p, name: nameOf(renaming, p.id, p.name) })) // the list reads this +heading = open == null ? "" : nameOf(renaming, open.id, open.name) // so does the header + +export action rename(id: string, name: string) { + try { + guess renaming := rs => [...rs, { id: id, name: name }] + await renameProject(id, name) + refresh server // accepted: the guess stays until the new answer lands (D-34) + refresh openProject + } catch (e) { + renameError := e.message // declined: the guess reverts with the message, in one frame + } +} +``` + +- The write shows on any answer that holds the item: one that arrives from another write's re-ask, a reload, a filter + switched back. It shows the same in every view, because every view reads it through one equation. +- `renaming` has a constant question, so guessing it never holds a selection or a filter switch (H-11). +- Two writes of one kind are one change on `renaming`; a declined one shows until the other ends (H-11). That's the + rule, not a bug. +- Re-ask only when the write was accepted. A declined write has nothing new on the server, and a re-ask would keep + its guess up until the answer lands. +- A removal is the same: `source removing: string[] = []`, and the list equation filters those ids out. + +### Data belongs with its lifetime + +In .rx, state is not tied to components: a `source` can be declared in a module, a component or a region, and a +component only reads and calls what it is given. So declare data where it lives as long as it is needed. A page's list usually belongs to the page: it loads when the page +opens and goes when it closes. A write in flight lives until the server answers, which can be after its page closed; +the data it refreshes must live at least as long. If it lives in the page, the write's `refresh` re-asks the closed +page's question, and the page opened again keeps the answer it asked for before the write landed, beside views asked +after it that show the write. + +So for the data such a write changes (the list and the item it refreshes), declare it, its writes in flight and its +actions in a module (top-level `source`s and `action`s), and let the page read it and call the actions. The page's own +inputs (a draft, a text field) live as long as the page and stay in it. To make the page start fresh when it opens, +have opening it call an action that resets the module's inputs and moves a counter the question reads +(`source visit := 0`, `items = visit >= 0 ? await loadItems(filter) : []`). + ### Data the server owns: three layers Every screen over server data is the same stack. Each layer is derived from the one below and has its own lifetime, diff --git a/guide/diagnostics.md b/guide/diagnostics.md index 06f9d71..97a6863 100644 --- a/guide/diagnostics.md +++ b/guide/diagnostics.md @@ -801,7 +801,9 @@ export default component App() { A handler reads a value as a snapshot when it can have no value yet (a first load) or can fail, so the click sees no value or throws (R-8). Put the element inside the `loading` / `errored` regions whose content reads the value, with -`disabled={pending? x}` while a new question is out; or catch the failure in the handler. +`disabled={pending? x}` while a new question is out; or catch the failure in the handler. The region must read the +value itself, not only `pending? x`: a probe takes no failure, so a region that reads `x` only through `pending?` stays +on its content when `x` fails, and the click throws. ```rx export default component App() { diff --git a/guide/reference.md b/guide/reference.md index 8dc645c..8ce22b7 100644 --- a/guide/reference.md +++ b/guide/reference.md @@ -68,7 +68,7 @@ Expr ::= TypeScript's, without assignment, `this`, `function`, `new` on rea - Reading in a handler gives the committed value (what the screen shows). A handler that reads an equation that may have no value yet (first load) or may have failed must handle that: a `try`, a first value on the equation (`await e -> v`), the element inside that value's `loading` and `errored` regions, or `disabled={…}` reading it - (R-8). + (R-8). For a failure, the region or the `disabled` must read the value itself: `pending? x` takes no failure. ## Holds (what the screen shows while work is out) diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index fb3b009..b1c5d82 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -201,7 +201,7 @@ export function createApp(opts: { root: any; api?: Record 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, /** `x key k = e` (ST-4): a keyed projection. */ - keyed: (name: string, key: string, fn: () => unknown): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, [] as any, { key } as any); return () => p; }, // the answer is a memo (sync, or the tail await's); the projection reconciles it by key + keyed: (name: string, key: string, fn: () => unknown): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, [] as any, { key } as any); const acc = () => p; truthOf.set(acc, src as Acc); return acc; }, // AC-4: `refresh rows` re-asks the memo under the projection // the answer is a memo (sync, or the tail await's); the projection reconciles it by key /** `x = draft(pre x -> seed) { … }` (ST-3). */ draft: (seed: unknown, body: (d: any) => void): Acc => { const p = createProjection(body as any, seed as any); return () => p; }, /** `pre y` of another node (C-2): a companion memo carrying (current, previous), owned where `y` is declared. */ diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index 6e7a429..de4e6be 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -211,7 +211,7 @@ export function createApp(opts: { api?: Record unknown> 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, /** `x key k = e` (ST-4): a keyed projection. */ - keyed: (name: string, key: string, fn: () => unknown): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, [] as any, { key } as any); return () => p; }, // the answer is a memo (sync, or the tail await's); the projection reconciles it by key + keyed: (name: string, key: string, fn: () => unknown): Acc => { const src = createMemo(named(name, () => fn()) as any); const p = createProjection(() => (src as Acc)() as any, [] as any, { key } as any); const acc = () => p; truthOf.set(acc, src as Acc); return acc; }, // AC-4: `refresh rows` re-asks the memo under the projection // the answer is a memo (sync, or the tail await's); the projection reconciles it by key /** `x = draft(pre x -> seed) { … }` (ST-3). */ draft: (seed: unknown, body: (d: any) => void): Acc => { const p = createProjection(body as any, seed as any); return () => p; }, /** `pre y` of another node (C-2): a companion memo carrying (current, previous), owned where `y` is declared. */ diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index e42d47e..dec99cf 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -299,7 +299,7 @@ export class Oracle { 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) const proj = createProjection(() => src() as any, [] as any, { key: d.key } as any); - scope.vars.set(d.name, { k: "memo", get: () => proj }); + scope.vars.set(d.name, { k: "memo", get: () => proj, truth: src } as any); // AC-4: `refresh` re-asks the memo under the projection (S-keyed-refresh) break; } const fnDecl = this.fnOf(d.e, scope); @@ -475,7 +475,7 @@ export class Oracle { } case "throw": throw untrack(() => this.ev(s.e, scope, env)); case "action": this.lowerAction([], s.body, scope, env)(); return; - case "refresh": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_REFRESH_TARGET"); const key = this.lastKey.get(s.name); if (key) this.forget(key); const d = Scope.all.find(x => x.vars.get(s.name) === b); for (const h of (d && this.hoistedByName.get(d)?.get(s.name)) ?? []) refresh(h as any); refresh((b.k === "store" ? b.get() : (b.k === "sig" && b.truth) || b.get) as any); return; } + case "refresh": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_REFRESH_TARGET"); const key = this.lastKey.get(s.name); if (key) this.forget(key); const d = Scope.all.find(x => x.vars.get(s.name) === b); for (const h of (d && this.hoistedByName.get(d)?.get(s.name)) ?? []) refresh(h as any); refresh((b.k === "store" ? b.get() : ((b.k === "sig" || b.k === "memo") && (b as any).truth) || b.get) as any); return; } case "affects": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_AFFECTS_TARGET"); affects(b.get as any); return; } case "optimistic": case "write": { const b = s.target.ctx ? this.provision(s.target.name, scope) : scope.lookup(s.target.name); diff --git a/scenarios/S-keyed-refresh.md b/scenarios/S-keyed-refresh.md new file mode 100644 index 0000000..90af7ce --- /dev/null +++ b/scenarios/S-keyed-refresh.md @@ -0,0 +1,42 @@ +# S-keyed-refresh — `refresh` re-asks a keyed equation, and a guess beside it waits for that answer + +Rules exercised: AC-4, ST-4, H-11, D-34. + +## Program + +```rx +export default component App() { + rows key id = await fetch("l") + source marks: number[] = [] + action mark(id: number) { + try { + guess marks := ms => [...ms, id] + await fetch("save") + } catch (e) {} + refresh rows + } + errored fallback(e) {

error

} { + loading fallback {

loading

} { + {for r in rows { {r.title}{marks.includes(r.id) ? "*" : ""} }} + } + } +} +``` + +The guess is on `marks`, a list of its own with a constant question; the action ends by re-asking `rows`, a keyed +equation. The re-ask is the action's (AC-4), so its change, and the guess with it, stays up until the list lands +(D-34): the mark never blinks out before the server's answer shows. + +## Frames + +| instant | action | frame | +| ------- | ------ | ----- | +| 0 | mount | `loading` | +| 1 | resolve "l" [{"id":1,"title":"A"}] | `A` | +| 2 | handler `mark(1)` | `A*` | +| 3 | resolve "save" 0 | `A*` | +| 4 | resolve "l" [{"id":1,"title":"B"}] | `B` | + +Found writing the desk9 reference (2026-11-13): the compiled runtime's `keyed` returned the projection's accessor +without registering the memo under it, so `refresh rows` re-asked nothing. The action ended at once, the guess went +with it, and the row showed unmarked with no request in the air (instant 3 read `A`). diff --git a/spec/04-rows.md b/spec/04-rows.md index ce51b69..6fda3c0 100644 --- a/spec/04-rows.md +++ b/spec/04-rows.md @@ -114,7 +114,13 @@ and where they may not go. element is not there, or not enabled, while `x` has no value, a new question out, or an error, so the handler never runs then: the element's attributes are the handler's compute half, with the same separated - inputs an effect has (EF-1). + inputs an effect has (EF-1). For the failure half, "reads `x`" means + its value (ruled 2026-11-13): `pending? x` of a failed `x` is `false` + and takes no failure, so an `errored` region whose content reads `x` + only under `pending?` never shows its fallback, and a + `disabled={pending? x}` leaves the element enabled; either way the + click throws. (Found by the desk9 reference's fuzz seed 11, eval/results/desk9-reference-2026-11-13: + `add-label` sat in a region of its own reading only `pending? shown`.) Neither is `UNGUARDED_SNAPSHOT` (static). The check adds nothing at run time and Solid has no equivalent: `untrack` of a pending node throws there