diff --git a/eval/harness/driver.mjs b/eval/harness/driver.mjs index 47ec5ea..7d0f062 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 0000000..617685b --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/README.md @@ -0,0 +1,87 @@ +# 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 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. + +## 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. + +## 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. + +## 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/reference.grade.txt b/eval/results/desk13-probe-2026-11-13/reference.grade.txt new file mode 100644 index 0000000..980f6dc --- /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/repro/blank-heading.spec.tsx b/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx new file mode 100644 index 0000000..6f06a16 --- /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

    }>
      {(it: any) =>
    • {it.title}
    • }
    + select

    }> + loading detail

    }>

    {heading()}

    {(detail() as any)?.body}

    +
    +
    , c); + + const steps: [string, () => void][] = [ + ["list lands", () => pick("list").res([{ id: 2, title: "Old bug" }])], + ["select", () => setSelected(true)], + ["other action", () => other()], + ["rename to Delta", () => rename("Delta")], + ["other lands", () => pick("other").res({})], // re-asks the list + ["detail lands", () => pick("detail").res({ title: "Old bug", body: "long gone" })], + ]; + for (const [n, f] of steps) { f(); await tick(); snap(n); } + console.log(frames.join("\n")); + expect(c.querySelector("h2")?.textContent).toBe("Delta"); +}); 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 0000000..afc462b --- /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)"); +}); 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 0000000..a78b2c3 --- /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 0000000..dc7efdb --- /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 0000000..e1aabc5 --- /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 0000000..0127383 --- /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 0000000..5cc165a --- /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 0000000..76aea22 --- /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/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 0000000..e7ef0e8 --- /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 0000000..443aee1 --- /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 0000000..40d2a37 --- /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 diff --git a/guide/async.md b/guide/async.md index e04289a..4105212 100644 --- a/guide/async.md +++ b/guide/async.md @@ -200,17 +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 := 0`, `items = visit >= 0 ? await loadItems(filter) : []`). +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