} {
+ {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 00000000..b22a170e
--- /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 := ""
+
+
+
+
+}
diff --git a/eval/reference/desk9/rx/labels.rx b/eval/reference/desk9/rx/labels.rx
new file mode 100644
index 00000000..43c048a7
--- /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 00000000..09403eef
--- /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
+
+
+}
diff --git a/eval/reference/desk9/rx/related.rx b/eval/reference/desk9/rx/related.rx
new file mode 100644
index 00000000..aea6011f
--- /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 00000000..5093aaca
--- /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) {
+
+
+ }
+ }
+}
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 00000000..9f179bda
--- /dev/null
+++ b/eval/results/desk9-reference-2026-11-13/README.md
@@ -0,0 +1,56 @@
+# 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.
+
+## What the reference does differently
+
+- **Issue data is app state** (`desk.rx`), 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 00000000..866078b9
--- /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 00000000..0e6e2a6a
--- /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/ref/compile/rt-web.ts b/ref/compile/rt-web.ts
index fb3b009a..b1c5d82f 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 6e7a4299..de4e6be8 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 e42d47eb..dec99cf4 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 00000000..90af7ce0
--- /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`).
From a0f24dd9c0c6e0a19d25633aea7ae9c03ad73028 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 07:21:03 +0000
Subject: [PATCH 02/12] R-8: an errored region or disabled covers a failure
only when it reads the value
Ruled 2026-11-13: pending? x takes no failure, so a region whose content reads x
only under pending? never shows its fallback, and the click throws (desk9 fuzz
seed 11). rxc refuses it as UNGUARDED_SNAPSHOT; the five references with the old
labels.rx are fixed (labels and chain4 tasks still pass all their tests).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
compiler/src/check.rs | 54 ++++++++++++++++---
eval/reference/chain1/rx/labels.rx | 17 +++---
eval/reference/chain2/rx/labels.rx | 17 +++---
eval/reference/chain3/rx/labels.rx | 17 +++---
eval/reference/chain4/rx/labels.rx | 17 +++---
eval/reference/labels/rx/labels.rx | 17 +++---
.../desk9-reference-2026-11-13/README.md | 5 +-
guide/diagnostics.md | 4 +-
guide/reference.md | 2 +-
spec/04-rows.md | 8 ++-
10 files changed, 112 insertions(+), 46 deletions(-)
diff --git a/compiler/src/check.rs b/compiler/src/check.rs
index 7697f960..8d0c37eb 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 1fb4ff95..43c048a7 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 1fb4ff95..43c048a7 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 1fb4ff95..43c048a7 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 1fb4ff95..43c048a7 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/labels/rx/labels.rx b/eval/reference/labels/rx/labels.rx
index 1fb4ff95..43c048a7 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
index 9f179bda..72eec16f 100644
--- a/eval/results/desk9-reference-2026-11-13/README.md
+++ b/eval/results/desk9-reference-2026-11-13/README.md
@@ -27,7 +27,10 @@ and linked over `eval/node_modules`. `rxc` from this branch. Graded with `harnes
- **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.
+ `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
diff --git a/guide/diagnostics.md b/guide/diagnostics.md
index 06f9d71e..97a68638 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 8dc645c5..8ce22b7a 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/spec/04-rows.md b/spec/04-rows.md
index ce51b69c..6fda3c06 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
From 9b0dd12860a41964c6abc9a52ab8a9ef1f6927c8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 07:22:33 +0000
Subject: [PATCH 03/12] guide: lay every write over the answer; keep server
data with the app
Two patterns from the desk9 reference, with a generic example: writes in flight as
guess lists laid over the answer by one equation every view reads, and server data
plus its actions in a module so a write outlives its page.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
guide/async.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 52 insertions(+), 1 deletion(-)
diff --git a/guide/async.md b/guide/async.md
index 6760b42c..37be80bd 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,56 @@ 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.
+
+### Where server data lives: with the app, not the page
+
+A write can outlive the page it was made on: the user leaves while it is out and comes back. Its `refresh` re-asks
+the question it can see. If that question was the old page's, it is gone, and the new page keeps the answer it asked
+for before the write landed, beside views asked after it that show the write.
+
+Declare the data a write changes, its equations and its actions in a module (top-level `source`s and `action`s), and
+let pages read it and call the actions. To make a 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) : []`). The page's own inputs (a draft, a text field) stay in the page.
+
### 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,
From c51eea57ba2a911c2f197f91dd990ec576f5952b Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 07:25:42 +0000
Subject: [PATCH 04/12] guide: narrow the module pattern to writes that outlive
their page
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
guide/async.md | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/guide/async.md b/guide/async.md
index 37be80bd..241164dc 100644
--- a/guide/async.md
+++ b/guide/async.md
@@ -197,14 +197,16 @@ export action rename(id: string, name: string) {
its guess up until the answer lands.
- A removal is the same: `source removing: string[] = []`, and the list equation filters those ids out.
-### Where server data lives: with the app, not the page
+### A write that outlives its page
-A write can outlive the page it was made on: the user leaves while it is out and comes back. Its `refresh` re-asks
-the question it can see. If that question was the old page's, it is gone, and the new page keeps the answer it asked
-for before the write landed, beside views asked after it that show the write.
+Server data usually belongs to the page that shows it: it loads when the page opens and goes when it closes. The one
+case that needs more is a write still out when its page closes. Its `refresh` re-asks the question it can see, the
+closed page's, and the page opened again keeps the answer it asked for before the write landed, beside views asked
+after it that show the write.
-Declare the data a write changes, its equations and its actions in a module (top-level `source`s and `action`s), and
-let pages read it and call the actions. To make a page start fresh when it opens, have opening it call an action that
+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. Everything else
+the page shows stays in the page. 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) : []`). The page's own inputs (a draft, a text field) stay in the page.
From b78e9ffda70b30b2de65699c219b958a1b21a711 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 07:27:20 +0000
Subject: [PATCH 05/12] guide: frame the module pattern as data belongs with
its lifetime
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
.../desk9-reference-2026-11-13/README.md | 2 +-
guide/async.md | 19 ++++++++++---------
2 files changed, 11 insertions(+), 10 deletions(-)
diff --git a/eval/results/desk9-reference-2026-11-13/README.md b/eval/results/desk9-reference-2026-11-13/README.md
index 72eec16f..acf018ab 100644
--- a/eval/results/desk9-reference-2026-11-13/README.md
+++ b/eval/results/desk9-reference-2026-11-13/README.md
@@ -34,7 +34,7 @@ and linked over `eval/node_modules`. `rxc` from this branch. Graded with `harnes
## What the reference does differently
-- **Issue data is app state** (`desk.rx`), as in Exact's reference ("every resource is the root's"): the list, the
+- **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.
diff --git a/guide/async.md b/guide/async.md
index 241164dc..52a9099f 100644
--- a/guide/async.md
+++ b/guide/async.md
@@ -197,18 +197,19 @@ export action rename(id: string, name: string) {
its guess up until the answer lands.
- A removal is the same: `source removing: string[] = []`, and the list equation filters those ids out.
-### A write that outlives its page
+### Data belongs with its lifetime
-Server data usually belongs to the page that shows it: it loads when the page opens and goes when it closes. The one
-case that needs more is a write still out when its page closes. Its `refresh` re-asks the question it can see, the
-closed page's, and the page opened again keeps the answer it asked for before the write landed, beside views asked
+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.
-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. Everything else
-the page shows stays in the page. 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) : []`). The page's own inputs (a draft, a text field) stay in the page.
+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
From af4e9b2875d0460026c59f628c2d93bc3bd5fc1a Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 07:28:38 +0000
Subject: [PATCH 06/12] guide: state is not tied to components, so place it by
lifetime
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
guide/async.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/guide/async.md b/guide/async.md
index 52a9099f..e04289a2 100644
--- a/guide/async.md
+++ b/guide/async.md
@@ -199,7 +199,8 @@ export action rename(id: string, name: string) {
### Data belongs with its lifetime
-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
+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
From b876bab0bc9c71a5db40e0b1ca2f19c5c26d9dde Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 08:15:43 +0000
Subject: [PATCH 07/12] eval: desk13 Opus probe on the new guide; guide limits
on module data; harness Chromium fallback
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
eval/harness/driver.mjs | 8 +++-
.../results/desk13-probe-2026-11-13/README.md | 45 +++++++++++++++++++
.../reference.grade.txt | 6 +++
.../desk13-probe-2026-11-13/rx-1.grade.txt | 12 +++++
.../desk13-probe-2026-11-13/rx-2.grade.txt | 8 ++++
.../desk13-probe-2026-11-13/rx-3.grade.txt | 14 ++++++
.../desk13-probe-2026-11-13/rx-4.grade.txt | 9 ++++
.../desk13-probe-2026-11-13/rx-5.grade.txt | 9 ++++
.../desk13-probe-2026-11-13/rx-6.grade.txt | 10 +++++
guide/async.md | 15 ++++++-
10 files changed, 134 insertions(+), 2 deletions(-)
create mode 100644 eval/results/desk13-probe-2026-11-13/README.md
create mode 100644 eval/results/desk13-probe-2026-11-13/reference.grade.txt
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-1.grade.txt
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-2.grade.txt
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-3.grade.txt
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-4.grade.txt
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-5.grade.txt
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-6.grade.txt
diff --git a/eval/harness/driver.mjs b/eval/harness/driver.mjs
index 47ec5eac..7d0f0621 100644
--- a/eval/harness/driver.mjs
+++ b/eval/harness/driver.mjs
@@ -23,7 +23,13 @@ export async function serve(dist) {
}
/** CHROMIUM=/path/to/chromium drives a system browser (NixOS and the like, where Playwright's download does not run). */
-export async function launch() { return chromium.launch(process.env.CHROMIUM ? { executablePath: process.env.CHROMIUM } : {}); }
+// Without CHROMIUM, a Playwright whose own browser is missing falls back to a preinstalled one (/opt/pw-browsers/chromium,
+// as in cloud containers), so an agent's ./test runs there too.
+export async function launch() {
+ if (process.env.CHROMIUM) return chromium.launch({ executablePath: process.env.CHROMIUM });
+ try { return await chromium.launch(); }
+ catch (e) { const alt = "/opt/pw-browsers/chromium"; if (existsSync(alt)) return chromium.launch({ executablePath: alt }); throw e; }
+}
/** One test: a fresh page on the app, `run(app)` with the helpers below. Returns { ok, error }. */
export async function runTest(browser, url, test) {
diff --git a/eval/results/desk13-probe-2026-11-13/README.md b/eval/results/desk13-probe-2026-11-13/README.md
new file mode 100644
index 00000000..2920a9dd
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/README.md
@@ -0,0 +1,45 @@
+# desk9 on the new guide, Opus, 6 apps (2026-11-13)
+
+Not preregistered. Question: with `guide/async.md`'s two new sections (#58: "lay every view of a write over the
+answer", "data belongs with its lifetime"), does Opus get near the hand-written reference's 0?
+
+**Setup:** Solid `next` + the #4828 fix (b8a8c2805, solidjs/solid#4829), rxc from main at 7e5bc81, desk9 scaffolded
+`--brief plain --inline-docs`, Opus via general-purpose agents, graded with `harness/grade.sh`. The reference
+(`reference/desk9/rx`) grades 77/77, 38/38, 0/20 on this build.
+
+## Results
+
+| app | guide | hidden | overlap | fuzz holes | defects |
+| --- | --- | ---: | ---: | ---: | ---: |
+| rx-1 | #58 | 76 | 37 | 2 | 4 |
+| rx-2 | #58 | 76 | 37 | 0 | 2 |
+| rx-3 | #58 | 76 | 37 | 3 | 5 |
+| rx-4 | #58 + `visit := -1` | 77 | 37 | 1 | 2 |
+| rx-5 | #58 + `visit := -1` | 77 | 37 | 1 | 2 |
+| rx-6 | #58 + `visit := -1` | 77 | 36 | 1 | 3 |
+
+rx-1..3 could not run `./test`: the eval's Playwright wanted its own Chromium build, which this container lacks, and
+`./test` did not pass `CHROMIUM`. They wrote blind. `harness/driver.mjs` now falls back to `/opt/pw-browsers/chromium`.
+rx-4..6 tested normally.
+
+## What the defects are
+
+- **Guide-caused, every app: settings moved into a module.** All six applied "data belongs with its lifetime" to the
+ settings page too, with `guess saved := next` on the module's question. Reopening settings while a save is out then
+ waits for the save: the tab switch is held with the reload, so the page never shows "Loading settings…" (overlap
+ "a save that answers after the page was left"). rx-1..3 also loaded settings at app start (`visit := 0`), one ask
+ too many (hidden "loads again when reopened"); the `-1` line fixed that for rx-4..6.
+- **Guide-caused, rx-4/5/6: the open count moved out of the list's region.** With the data in a module, all three
+ put the count `effect` beside it (module or App). An effect outside the list's `loading` waits for every re-ask, so
+ a close guessed while a refresh is out shows in the rows but not in the tab (fuzz rx-4 s13, rx-5 s17, rx-6 s16).
+ The reference's `OpenCount` sits inside the region.
+- **Detail heading blank** (rx-1 s11, s15; rx-3 s15): `heading = detail == null ? "" : titleOf(renaming, …)` reads
+ `""` while `detail.title` in the same frame reads the answer (probed). Needs a rename guess out, a failed list
+ re-ask and a close's re-ask; a minimal scenario does not reproduce it. Unresolved: likely Solid.
+- rx-3 s11, s19: page hangs (120 s). Not traced.
+- rx-6: a row error from a write that answered after the issues page was left shows on its return (agent).
+
+## Guide change after this probe
+
+`guide/async.md` "Data belongs with its lifetime" now states three limits: only data a write *refreshes* moves to a
+module; guess it only through lists of its own; copy what the page shows (the count) where the page shows it.
diff --git a/eval/results/desk13-probe-2026-11-13/reference.grade.txt b/eval/results/desk13-probe-2026-11-13/reference.grade.txt
new file mode 100644
index 00000000..980f6dca
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/reference.grade.txt
@@ -0,0 +1,6 @@
+== hidden
+77/77 passed
+== overlap
+38/38 passed
+== fuzz
+ref-desk9: 0/20 seeds found a hole
diff --git a/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt
new file mode 100644
index 00000000..a78b2c34
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt
@@ -0,0 +1,12 @@
+== hidden
+✗ base: tabs: the settings page loads again when reopened, with a fresh draft
+76/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-1: 2/20 seeds found a hole
+ seed 11 step 44: detail title: "#3 Bravofooter says 2019finding related helpretry assign to ", server "Delta"
+ tab help ; tab issues ; tab help ; land loadSettings: ; tab issues ; filter closed ; tab help ; land fetchIssues:open ; land fetchIssues:closed ; tab issues ; tab help ; tab issues ; focus ; land fetchIssues:open ; select #4 ; rename Alpha ; rename Bravo ; post me too ; post fixed? ; land fetchIssue:4 ; row-assign 0 ; row-close 0 ; select #3 ; focus ; close ; land fetchComments:4 ; row-close 0
+ seed 15 step 33: detail title: "#2 long gonefinding related helpretry assign to ann close re", server "Old bug"
+ land fetchIssues:open ; row-assign 1 ; row-assign 0 ; land assign:3,guest ; land fetchIssues:open ; land loadSettings: ; filter closed ; land assign:1,guest ; land fetchIssues:closed ; user ann ; row-close 0 ; row-close 0 ; row-close 0 ; land closeIssue:2 ; row-close 0 ; select #2 ; land fetchIssues:closed ; assign ; row-close 0 ; land fetchComments:2 ; land closeIssue:2 ; assign ; land fetchI
diff --git a/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt
new file mode 100644
index 00000000..dc7efdb7
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt
@@ -0,0 +1,8 @@
+== hidden
+✗ base: tabs: the settings page loads again when reopened, with a fresh draft
+76/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-2: 0/20 seeds found a hole
diff --git a/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt
new file mode 100644
index 00000000..e1aabc56
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt
@@ -0,0 +1,14 @@
+== hidden
+✗ base: tabs: the settings page loads again when reopened, with a fresh draft
+76/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-3: 3/20 seeds found a hole
+ seed 11 step -1: page crash: session timed out (120 s): the page hangs
+
+ seed 15 step 33: detail title: "#2 long gonefinding related helpretryassign to ann renameclo", server "Old bug"
+ land fetchIssues:open ; row-assign 1 ; row-assign 0 ; land assign:3,guest ; land fetchIssues:open ; land loadSettings: ; filter closed ; land assign:1,guest ; land fetchIssues:closed ; user ann ; row-close 0 ; row-close 0 ; row-close 0 ; land closeIssue:2 ; row-close 0 ; select #2 ; land fetchIssues:closed ; assign ; row-close 0 ; land fetchComments:2 ; land closeIssue:2 ; assign ; land fetchI
+ seed 19 step -1: page crash: session timed out (120 s): the page hangs
+
diff --git a/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt
new file mode 100644
index 00000000..01273834
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt
@@ -0,0 +1,9 @@
+== hidden
+77/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-4: 1/20 seeds found a hole
+ seed 13 step 7: count: tab Issues (3), 0 open rows shown
+ filter closed ; fail fetchIssues:open ; land fetchIssues:closed ; row-assign 0 ; filter open ; land assign:2,guest ; land fetchIssues:open
diff --git a/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt
new file mode 100644
index 00000000..5cc165a4
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt
@@ -0,0 +1,9 @@
+== hidden
+77/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+37/38 passed
+== fuzz
+desk13-rx-5: 1/20 seeds found a hole
+ seed 17 step 8: count: tab Issues (2), 1 open rows shown
+ user bob ; land fetchIssues:open ; row-assign 1 ; land assign:3,bob ; land fetchIssues:open ; row-close 2 ; land closeIssue:4 ; row-close 1
diff --git a/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt
new file mode 100644
index 00000000..76aea22c
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt
@@ -0,0 +1,10 @@
+== hidden
+77/77 passed
+== overlap
+✗ base: tabs: a save that answers after the page was left shows the reloaded settings
+✗ rows: a row action that answers after the issues page was left changes nothing on its return
+36/38 passed
+== fuzz
+desk13-rx-6: 1/20 seeds found a hole
+ seed 16 step 34: count: tab Issues (3), 0 open rows shown
+ user guest ; fail fetchIssues:open ; focus ; tab help ; tab issues ; filter closed ; land fetchIssues:open ; focus ; land fetchIssues:closed ; select #2 ; land fetchComments:2 ; close ; user ann ; fail closeIssue:2 ; row-assign 0 ; close ; user bob ; user guest ; assign ; land assign:2,guest ; land fetchLabels:2 ; fail closeIssue:2 ; assign ; land assign:2,ann ; focus ; land fetchIssue:2 ; lan
diff --git a/guide/async.md b/guide/async.md
index e04289a2..a5fe48c9 100644
--- a/guide/async.md
+++ b/guide/async.md
@@ -210,7 +210,20 @@ So for the data such a write changes (the list and the item it refreshes), decla
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) : []`).
+(`source visit := -1`, `items = visit >= 0 ? await loadItems(filter) : []`). A module's question is asked when the
+app starts, whether or not its page is open, so start the counter below the guard (`-1`): the first open asks, and a
+page that never opens never loads. Start it at `0` only for the page the app opens on.
+
+Three limits keep this from costing more than it fixes:
+
+- **Only data a write refreshes.** A write whose answer is the new value (a save that returns the saved settings) has
+ nothing to re-ask, so its data stays in the page: an answer that lands after the page closed lands on nothing, and
+ the page opened again loads afresh.
+- **Guess it only through lists of its own** (the next section). A `guess` on the module's question itself ties that
+ question to the write, so the next open's load, and the page switch that asks it, waits for the write to land.
+- **Copy what the page shows where the page shows it.** A tab's count of the open rows is an `effect` in a component
+ inside the list's region (`` under the list's `loading`), not beside the module: an
+ effect outside the region waits for every re-ask of the list, so it misses the guesses the rows already show.
### Data the server owns: three layers
From 6e3c918d486b496e4da7797fb1c3b4eaa1636cf8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 10:28:27 +0000
Subject: [PATCH 08/12] guide: one lifetime rule (visit, write, screen) instead
of three limits
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
.../results/desk13-probe-2026-11-13/README.md | 4 +-
guide/async.md | 47 +++++++++----------
2 files changed, 25 insertions(+), 26 deletions(-)
diff --git a/eval/results/desk13-probe-2026-11-13/README.md b/eval/results/desk13-probe-2026-11-13/README.md
index 2920a9dd..94cf9c63 100644
--- a/eval/results/desk13-probe-2026-11-13/README.md
+++ b/eval/results/desk13-probe-2026-11-13/README.md
@@ -41,5 +41,5 @@ rx-4..6 tested normally.
## Guide change after this probe
-`guide/async.md` "Data belongs with its lifetime" now states three limits: only data a write *refreshes* moves to a
-module; guess it only through lists of its own; copy what the page shows (the count) where the page shows it.
+`guide/async.md` "Data belongs with its lifetime" now gives one rule (each value has one lifetime: the visit, the write
+or the screen; split a value that would need two) and derives the module list, the save, the guess and the count from it.
diff --git a/guide/async.md b/guide/async.md
index a5fe48c9..41052127 100644
--- a/guide/async.md
+++ b/guide/async.md
@@ -200,30 +200,29 @@ export action rename(id: string, name: string) {
### 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 := -1`, `items = visit >= 0 ? await loadItems(filter) : []`). A module's question is asked when the
-app starts, whether or not its page is open, so start the counter below the guard (`-1`): the first open asks, and a
-page that never opens never loads. Start it at `0` only for the page the app opens on.
-
-Three limits keep this from costing more than it fixes:
-
-- **Only data a write refreshes.** A write whose answer is the new value (a save that returns the saved settings) has
- nothing to re-ask, so its data stays in the page: an answer that lands after the page closed lands on nothing, and
- the page opened again loads afresh.
-- **Guess it only through lists of its own** (the next section). A `guess` on the module's question itself ties that
- question to the write, so the next open's load, and the page switch that asks it, waits for the write to land.
-- **Copy what the page shows where the page shows it.** A tab's count of the open rows is an `effect` in a component
- inside the list's region (`` under the list's `loading`), not beside the module: an
- effect outside the region waits for every re-ask of the list, so it misses the guesses the rows already show.
+component only reads and calls what it is given. So give each value one lifetime, and declare it where that lifetime
+is. There are three:
+
+- **The visit.** What a page asks for and its inputs: asked when the page opens, gone when it closes.
+- **The write.** A write lives until the server answers, which can be after its page closed. Its guess lives exactly
+ as long, and so does whatever it re-asks.
+- **The screen.** What copies what is shown lives where it is shown, inside the region that shows it.
+
+When one value would need two lifetimes, split it into two values. Each case below follows from that:
+
+- **A list a write refreshes** is needed by the visit and by the write, so it outlives both: declare it, the writes
+ and their actions in a module (top-level `source`s and `action`s), and let the page read it. The visit becomes a
+ counter its question reads, moved by opening the page (`source visit := -1`,
+ `items = visit >= 0 ? await loadItems(filter) : []`). A module's question is asked when the app starts, so the
+ counter starts below the guard and the first open asks; start it at `0` only for the page the app opens on.
+- **A write whose answer is the new value** (a save that returns the saved settings) re-asks nothing, so its data
+ needs only the visit and stays in the page.
+- **A guess** never goes on the module's question: that would give the question the write's lifetime, and the next
+ visit's load, with the page switch that asks it, would wait for the write. Guesses go in lists of their own, laid
+ over the answer (the next section).
+- **A count in a tab** copies the rows on screen, so it is an `effect` in a component inside the list's region
+ (`` under the list's `loading`), not beside the module: outside the region it waits
+ for every re-ask of the list and misses the guesses the rows already show.
### Data the server owns: three layers
From 4028f8f5df26fa2fe1891f8a896420208a3a844c Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 10:43:35 +0000
Subject: [PATCH 09/12] eval: desk13 rerun on the lifetime rule (2, 1, 0)
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
eval/results/desk13-probe-2026-11-13/README.md | 16 ++++++++++++++++
.../desk13-probe-2026-11-13/rx-7.grade.txt | 8 ++++++++
.../desk13-probe-2026-11-13/rx-8.grade.txt | 8 ++++++++
.../desk13-probe-2026-11-13/rx-9.grade.txt | 6 ++++++
4 files changed, 38 insertions(+)
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-7.grade.txt
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-8.grade.txt
create mode 100644 eval/results/desk13-probe-2026-11-13/rx-9.grade.txt
diff --git a/eval/results/desk13-probe-2026-11-13/README.md b/eval/results/desk13-probe-2026-11-13/README.md
index 94cf9c63..0f6763d8 100644
--- a/eval/results/desk13-probe-2026-11-13/README.md
+++ b/eval/results/desk13-probe-2026-11-13/README.md
@@ -43,3 +43,19 @@ rx-4..6 tested normally.
`guide/async.md` "Data belongs with its lifetime" now gives one rule (each value has one lifetime: the visit, the write
or the screen; split a value that would need two) and derives the module list, the save, the guess and the count from it.
+
+## Rerun on the lifetime rule (rx-7..9)
+
+| app | hidden | overlap | fuzz holes | defects |
+| --- | ---: | ---: | ---: | ---: |
+| rx-7 | 75 | 38 | 0 | 2 |
+| rx-8 | 77 | 38 | 1 | 1 |
+| rx-9 | 77 | 38 | 0 | **0** |
+
+Mean 1.0 (rx-4..6: 2.33). No settings or count-placement failures: all three kept settings in the page and put the
+count inside the list's region.
+
+- rx-7: the help item's `errored` sits outside its `loading on(selected)`, so a new selection keeps the old failure
+ or not-found message instead of showing loading (agent; not a lifetime case).
+- rx-8 s16: the rows render as empty text (`["","",""]`) after the open list lands, while the tab counts 3. Same
+ shape as rx-1/rx-3's blank heading: a text node left empty while its value is there. Likely Solid; no minimal repro.
diff --git a/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt
new file mode 100644
index 00000000..e7ef0e81
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt
@@ -0,0 +1,8 @@
+== hidden
+✗ base: help: selecting after a failure shows loading…, not the old failure
+✗ base: help: selecting after not found shows loading…, not the old message
+75/77 passed
+== overlap
+38/38 passed
+== fuzz
+desk13-rx-7: 0/20 seeds found a hole
diff --git a/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt
new file mode 100644
index 00000000..443aee15
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt
@@ -0,0 +1,8 @@
+== hidden
+77/77 passed
+== overlap
+38/38 passed
+== fuzz
+desk13-rx-8: 1/20 seeds found a hole
+ seed 16 step 34: count: tab Issues (3), 0 open rows shown
+ user guest ; fail fetchIssues:open ; focus ; tab help ; tab issues ; filter closed ; land fetchIssues:open ; focus ; land fetchIssues:closed ; select #2 ; land fetchComments:2 ; close ; user ann ; fail closeIssue:2 ; row-assign 0 ; close ; user bob ; user guest ; assign ; land assign:2,guest ; land fetchLabels:2 ; fail closeIssue:2 ; assign ; land assign:2,ann ; focus ; land fetchIssue:2 ; lan
diff --git a/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt
new file mode 100644
index 00000000..40d2a377
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt
@@ -0,0 +1,6 @@
+== hidden
+77/77 passed
+== overlap
+38/38 passed
+== fuzz
+desk13-rx-9: 0/20 seeds found a hole
From 389b58ee9f58adf2ef7f51a285462f1ab5c5b0c0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 11:30:55 +0000
Subject: [PATCH 10/12] eval: plain-Solid repro for rows rendered without text
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
.../results/desk13-probe-2026-11-13/README.md | 8 +++
.../repro/blank-rows.spec.tsx | 55 +++++++++++++++++++
2 files changed, 63 insertions(+)
create mode 100644 eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx
diff --git a/eval/results/desk13-probe-2026-11-13/README.md b/eval/results/desk13-probe-2026-11-13/README.md
index 0f6763d8..2dae2e5a 100644
--- a/eval/results/desk13-probe-2026-11-13/README.md
+++ b/eval/results/desk13-probe-2026-11-13/README.md
@@ -59,3 +59,11 @@ count inside the list's region.
or not-found message instead of showing loading (agent; not a lifetime case).
- rx-8 s16: the rows render as empty text (`["","",""]`) after the open list lands, while the tab counts 3. Same
shape as rx-1/rx-3's blank heading: a text node left empty while its value is there. Likely Solid; no minimal repro.
+
+## The blank text, in plain Solid
+
+`repro/blank-rows.spec.tsx` (drop into solid-next `packages/web/test/`) reproduces rx-8 s16 in about 50 lines: a
+keyed `createProjection` over a memo of an async list, two `createOptimistic` guess lists (assigns, closes), a declined
+close among overlapping assigns, then a switch to another list. When the new list's re-ask lands, `For` renders one
+`
` per row with no text. It fails on next b8a8c2805 and on cd4de2874 (before #4828). Without the projection
+(`For` over the memo) it passes. Errored, `Loading on` and the page remount are not needed.
diff --git a/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx b/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx
new file mode 100644
index 00000000..afc462b3
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx
@@ -0,0 +1,55 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ */
+import { test, expect } from "vitest";
+import { For, Loading, action, createOptimistic, createMemo, createSignal, createProjection, refresh, flush } from "solid-js";
+import { render } from "../src/index.js";
+const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };
+test("rows that land while guesses are out render their text", async () => {
+ const asks: { key: string; res: (v: any) => void; rej: (e: any) => void }[] = [];
+ const inAir = new Map>();
+ const ask = (key: string) => new Promise((res, rej) => asks.push({ key, res, rej }));
+ const askQ = (key: string) => { let p = inAir.get(key); if (!p) { p = ask(key).finally(() => inAir.delete(key)); inAir.set(key, p); } return p; }; // a question asked again while in the air joins it
+ const pick = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); return asks.splice(i, 1)[0]; };
+ const c = document.body.appendChild(document.createElement("div"));
+ const frames: string[] = []; const snap = (l: string) => frames.push(`${l.padEnd(22)} ${c.innerHTML} | in the air: ${asks.map(a => a.key)}`);
+
+ const [filter, setFilter] = createSignal("closed");
+ const server = createMemo(() => askQ(filter()));
+ const [assigningT] = createSignal<{ id: number; user: string }[]>(() => []);
+ const [assigning, guessAssigning] = createOptimistic(() => assigningT());
+ const [closingT] = createSignal(() => []);
+ const [closing, guessClosing] = createOptimistic(() => closingT());
+ const rowsSrc = createMemo(() => (server() as any[]).filter(i => !closing().includes(i.id))
+ .map(i => ({ id: i.id, title: i.title, assignee: assigning().reduce((n, x) => x.id === i.id ? x.user : n, i.assignee) })));
+ const rows = createProjection(() => rowsSrc() as any, [] as any, { key: "id" } as any);
+ const showOpen = action(function* () { setFilter("open"); });
+ const assignTo = action(function* (id: number, user: string) {
+ try { guessAssigning(xs => [...xs, { id, user }]); yield ask(`assign:${user}`); refresh(server); } catch (e) {}
+ });
+ const closeOne = action(function* (id: number) {
+ try { guessClosing(xs => [...xs, id]); yield ask(`close`); refresh(server); } catch (e) {}
+ });
+ const list = () =>
{(it: any) =>
{`#${it.id} ${it.title} (${it.assignee})`}
}
;
+ render(() => loading
}>{list()}, c);
+
+ const steps: [string, () => void][] = [
+ ["mount", () => {}],
+ ["closed list lands", () => pick("closed").res([{ id: 2, title: "Old bug", assignee: "eve" }])],
+ ["assign #2 ann", () => assignTo(2, "ann")],
+ ["close #2", () => closeOne(2)],
+ ["assign #2 guest", () => assignTo(2, "guest")],
+ ["that assign lands", () => pick("assign:guest").res({})], // re-asks the closed list
+ ["close declined", () => pick("close").rej(new Error("declined"))],
+ ["assign #2 guest again", () => assignTo(2, "guest")],
+ ["assign ann lands", () => pick("assign:ann").res({})], // re-asks the closed list (joins the one in the air)
+ ["switch to open", () => showOpen()],
+ ["open list lands", () => pick("open").res([{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }])],
+ ["second assign lands", () => pick("assign:guest").res({})], // re-asks the open list
+ ["open list lands again", () => pick("open").res([{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }])],
+ ];
+ for (const [n, f] of steps) { f(); await tick(); snap(n); }
+ console.log(frames.join("\n"));
+ expect(c.textContent).toBe("#1 Login fails (null)#3 Crash on save (null)");
+});
From 98eaf6db66bbe86e1f391fbb3bcb7deb3c31c1ff Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 16:42:36 +0000
Subject: [PATCH 11/12] eval: regrade on next f00ce6f52 (#4831 fixed): rx-8 0
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
eval/results/desk13-probe-2026-11-13/README.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/eval/results/desk13-probe-2026-11-13/README.md b/eval/results/desk13-probe-2026-11-13/README.md
index 2dae2e5a..73a65eff 100644
--- a/eval/results/desk13-probe-2026-11-13/README.md
+++ b/eval/results/desk13-probe-2026-11-13/README.md
@@ -67,3 +67,8 @@ keyed `createProjection` over a memo of an async list, two `createOptimistic` gu
close among overlapping assigns, then a switch to another list. When the new list's re-ask lands, `For` renders one
`
` per row with no text. It fails on next b8a8c2805 and on cd4de2874 (before #4828). Without the projection
(`For` over the memo) it passes. Errored, `Loading on` and the page remount are not needed.
+
+## On next f00ce6f52 (with the #4831 fix)
+
+The repro passes. rx-8 regrades 77/77, 38/38, 0/20: **0 defects** (rx-7..9 now 2, 0, 0). The reference stays at 0.
+rx-1 and rx-3 keep their blank detail heading (s11, s15): a plain memo, not a projection, so a separate bug.
From 05c0f3d3853da5c3371b147a7601ac0229b2864c Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 13 Nov 2026 17:10:23 +0000
Subject: [PATCH 12/12] eval: plain-Solid repro for the blank heading
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWLtkg281f1NkGEDjCt
---
.../results/desk13-probe-2026-11-13/README.md | 13 ++++++
.../repro/blank-heading.spec.tsx | 46 +++++++++++++++++++
2 files changed, 59 insertions(+)
create mode 100644 eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx
diff --git a/eval/results/desk13-probe-2026-11-13/README.md b/eval/results/desk13-probe-2026-11-13/README.md
index 73a65eff..617685b5 100644
--- a/eval/results/desk13-probe-2026-11-13/README.md
+++ b/eval/results/desk13-probe-2026-11-13/README.md
@@ -72,3 +72,16 @@ close among overlapping assigns, then a switch to another list. When the new lis
The repro passes. rx-8 regrades 77/77, 38/38, 0/20: **0 defects** (rx-7..9 now 2, 0, 0). The reference stays at 0.
rx-1 and rx-3 keep their blank detail heading (s11, s15): a plain memo, not a projection, so a separate bug.
+
+## The blank heading, in plain Solid
+
+`repro/blank-heading.spec.tsx` reproduces rx-1/rx-3 s15 on next f00ce6f52 in about 45 lines. There is no projection
+and no error boundary. Setup:
+
+- a list memo, and a detail memo that is still loading;
+- one `createOptimistic` rename guess, read by both the list's rows and the heading memo;
+- another action, out at the same time, that re-asks the list;
+- the rename itself, still out.
+
+When the other action lands (its re-ask goes out), and then the detail lands, the `
` renders empty while
+`detail.body` beside it shows. Without the other action the test passes.
diff --git a/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx b/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx
new file mode 100644
index 00000000..6f06a16f
--- /dev/null
+++ b/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx
@@ -0,0 +1,46 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ */
+import { test, expect } from "vitest";
+import { For, Loading, Show, action, createOptimistic, createMemo, createSignal, refresh, flush } from "solid-js";
+import { render } from "../src/index.js";
+const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } };
+test("a heading that reads a guess shows it when its async source lands", async () => {
+ const asks: { key: string; res: (v: any) => void }[] = [];
+ const ask = (key: string) => new Promise(res => asks.push({ key, res }));
+ const pick = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); return asks.splice(i, 1)[0]; };
+ const c = document.body.appendChild(document.createElement("div"));
+ const frames: string[] = []; const snap = (l: string) => frames.push(`${l.padEnd(18)} ${c.innerHTML} | in the air: ${asks.map(a => a.key)}`);
+
+ const [selected, setSelected] = createSignal(false);
+ const [renamesT] = createSignal(() => []);
+ const [renames, guessRename] = createOptimistic(() => renamesT());
+
+ const list = createMemo(() => ask("list"));
+ const rows = createMemo(() => (list() as any[]).map(i => ({ id: i.id, title: renames().reduce((t, r) => r, i.title) })));
+ const detail = createMemo(() => selected() ? ask("detail") : null);
+ const heading = createMemo(() => { const d: any = detail(); return d == null ? "" : renames().reduce((t, r) => r, d.title); });
+
+ const rename = action(function* (title: string) { guessRename(rs => [...rs, title]); yield ask("rename"); refresh(list); refresh(detail); });
+ const other = action(function* () { yield ask("other"); refresh(list); });
+
+ render(() =>
+ loading}>