Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions compiler/src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<SId>) -> 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<SId>, 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;
}
}
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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) { <p>failed</p> } {
loading fallback { <p>loading</p> } {
<button disabled={pending? labels} onClick={() => { log(labels.length) }}>probe</button>
}
}
errored fallback(e) { <p>failed</p> } {
loading fallback { <p>loading</p> } {
<p>{labels.length}</p>
<button disabled={pending? labels} onClick={() => { log(labels.length) }}>read</button>
}
}
loading fallback { <p>loading</p> } {
<button disabled={labels.length > 9} onClick={() => { log(labels.length) }}>disabled</button>
}
}
"#;
let got: Vec<i64> = 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() {
Expand Down
17 changes: 10 additions & 7 deletions eval/reference/chain1/rx/labels.rx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) {
<input data-testid="label-input" value={labelInput} onInput={e => { labelInput := e.target.value }} />
{loading on(props.id) fallback { <button data-testid="add-label" disabled>add label</button> } {
{errored fallback(e) { <button data-testid="add-label" disabled>add label</button> } {
<button data-testid="add-label" disabled={pending? shown} onClick={() => {
if (labelInput != "" && !shown.includes(labelInput)) {
let label = labelInput
onFlush(() => { labelInput := "" })
add(props.id, label)
}
}}>add label</button>
// the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click
{for have in [shown] {
<button data-testid="add-label" disabled={pending? shown} onClick={() => {
if (labelInput != "" && !have.includes(labelInput)) {
let label = labelInput
onFlush(() => { labelInput := "" })
add(props.id, label)
}
}}>add label</button>
}}
}}
}}
{if labelError != "" {
Expand Down
17 changes: 10 additions & 7 deletions eval/reference/chain2/rx/labels.rx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) {
<input data-testid="label-input" value={labelInput} onInput={e => { labelInput := e.target.value }} />
{loading on(props.id) fallback { <button data-testid="add-label" disabled>add label</button> } {
{errored fallback(e) { <button data-testid="add-label" disabled>add label</button> } {
<button data-testid="add-label" disabled={pending? shown} onClick={() => {
if (labelInput != "" && !shown.includes(labelInput)) {
let label = labelInput
onFlush(() => { labelInput := "" })
add(props.id, label)
}
}}>add label</button>
// the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click
{for have in [shown] {
<button data-testid="add-label" disabled={pending? shown} onClick={() => {
if (labelInput != "" && !have.includes(labelInput)) {
let label = labelInput
onFlush(() => { labelInput := "" })
add(props.id, label)
}
}}>add label</button>
}}
}}
}}
{if labelError != "" {
Expand Down
17 changes: 10 additions & 7 deletions eval/reference/chain3/rx/labels.rx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) {
<input data-testid="label-input" value={labelInput} onInput={e => { labelInput := e.target.value }} />
{loading on(props.id) fallback { <button data-testid="add-label" disabled>add label</button> } {
{errored fallback(e) { <button data-testid="add-label" disabled>add label</button> } {
<button data-testid="add-label" disabled={pending? shown} onClick={() => {
if (labelInput != "" && !shown.includes(labelInput)) {
let label = labelInput
onFlush(() => { labelInput := "" })
add(props.id, label)
}
}}>add label</button>
// the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click
{for have in [shown] {
<button data-testid="add-label" disabled={pending? shown} onClick={() => {
if (labelInput != "" && !have.includes(labelInput)) {
let label = labelInput
onFlush(() => { labelInput := "" })
add(props.id, label)
}
}}>add label</button>
}}
}}
}}
{if labelError != "" {
Expand Down
17 changes: 10 additions & 7 deletions eval/reference/chain4/rx/labels.rx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@ export component Labels(props: { id: number }) {
<input data-testid="label-input" value={labelInput} onInput={e => { labelInput := e.target.value }} />
{loading on(props.id) fallback { <button data-testid="add-label" disabled>add label</button> } {
{errored fallback(e) { <button data-testid="add-label" disabled>add label</button> } {
<button data-testid="add-label" disabled={pending? shown} onClick={() => {
if (labelInput != "" && !shown.includes(labelInput)) {
let label = labelInput
onFlush(() => { labelInput := "" })
add(props.id, label)
}
}}>add label</button>
// the button reads what it guards: `shown` failing puts this region on its fallback, never under a live click
{for have in [shown] {
<button data-testid="add-label" disabled={pending? shown} onClick={() => {
if (labelInput != "" && !have.includes(labelInput)) {
let label = labelInput
onFlush(() => { labelInput := "" })
add(props.id, label)
}
}}>add label</button>
}}
}}
}}
{if labelError != "" {
Expand Down
55 changes: 55 additions & 0 deletions eval/reference/desk9/rx/comments.rx
Original file line number Diff line number Diff line change
@@ -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
}
}

<div>
<div data-testid="comments">
{loading on(props.id) fallback { <p>loading comments</p> } {
{errored fallback(e) { <p>comments error: {e.message}</p> } {
<ul>
{for c in comments {
<li data-testid="comment">{c.author}: {c.text}</li>
}}
{for s in shownSending {
<li data-testid="comment">{s.author}: {s.text} (sending…)</li>
}}
</ul>
}}
}}
</div>
<input data-testid="comment-input" value={commentInput} onInput={e => { commentInput := e.target.value }} />
<button data-testid="post" onClick={() => {
if (commentInput != "") {
let text = commentInput
onFlush(() => { commentInput := "" })
postComment(props.id, props.user, text)
}
}}>post</button>
{if postError != null {
<p>post error: {postError}</p>
}}
</div>
}
120 changes: 120 additions & 0 deletions eval/reference/desk9/rx/desk.rx
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading