From d7a988d18d2a27f78f1cdcc0597154e916d95ed9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 5 Nov 2026 10:45:39 +0000 Subject: [PATCH] feat(chord): a call's answer follows its state Before a provider sends or forwards a call's result or error, it sends the origin's waiting state, past the window if it is full, so a resolved call's changes are applied on the page. TuiScope.optimistic shows a value while calls are pending; the guide shows useOptimistic over a Chord reference; example 63. --- packages/chord/CHANGELOG.md | 2 + packages/chord/docs/design.md | 29 ++- .../chord/docs/guide-plugins-hosts/book.md | 51 +++++ .../chord/docs/guide-plugins-hosts/index.html | 39 ++++ packages/chord/src/react/README.md | 5 + packages/chord/src/remote/provider.ts | 54 +++-- packages/chord/src/tui/README.md | 5 +- packages/chord/src/tui/scope.ts | 32 ++- .../test/examples/63-optimistic-answers.ts | 213 ++++++++++++++++++ packages/chord/test/remote/hold.test.ts | 31 +-- packages/chord/test/remote/provider.test.ts | 95 ++++++-- packages/chord/test/remote/relay.test.ts | 43 ++++ packages/chord/test/tui/scope.test.ts | 59 ++++- 13 files changed, 605 insertions(+), 53 deletions(-) create mode 100644 packages/chord/test/examples/63-optimistic-answers.ts diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 81e4ff6e8..e3e8c0632 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -26,6 +26,7 @@ - Chord v2 gains telemetry across hosts (slice 9): `CHORD_TELEMETRY_SCHEMA` (root export) types every span, event and metric Chord records. With a host adapter (`createHost({ telemetry })`), calls (`chord.call`), methods (`chord.method`, local and remote), relays (`chord.relay`), holds (`chord.hold`), activations (`chord.activate`, ending `live`, `failed`, `parked` or `ended`, with `chord.retry` and `chord.park` events), natives' opens and closes, `load`, `reload` (a `chord.reload.fate` event per object), resolves (a worker start included), revocations, routine calls, job phases (`intent`, `run`, `finish`: a persisted run resumed in a new attempt or process stays under its stored carrier) and HTTP requests (`chord.http`: method, route pattern, status, never the path) are spans; failures carry a fixed category and the message cut at 500 characters, a cancel is no error; commits are `chord.commit { documents, ops, rows, duration }` events of the span they ran under. `hold` and `call` carry the caller's trace carrier (`trace`, at most 8 keys, keys and values of at most 256 characters of printable ASCII, else the message fails the message schema and the link closes; the binary codec's kind byte flag `0x40`), relays forward their own span's, and the provider runs the method under it (an absent or unknown carrier: the adapter's root); a reference's carrier rides on its first hold only, a re-hold after a reconnect being a fresh root linked to it. A hold's `type` and its address's `kind` are bounded at 256 characters by the message schema, and span attributes a peer chose at 256 characters. `ctx.trace(name, attributes, fn)` runs `fn` in a child span with a child ctx. Host-wide metrics: `chord.link.bytes`, `chord.link.state_messages`, `chord.commit.duration`, `chord.calls.in_flight`, `chord.holds.active`, `chord.links.open`, `chord.park.duration`, `chord.backpressure.wait`. Without an adapter nothing is started, built or sent: a call over a link costs what it did before. `createHttp`, `startServer` and `startWorker` take a `telemetry` adapter (`createHttp` continues a W3C `traceparent` only from a request a contributed relay verified: an app's proxy must strip it from browsers). A host with an adapter counts the cost counters of its links' and root scopes' work on it also when the app passed a context without a span. The SQLite telemetry store keeps spans of one microsecond in the order they started. - Chord v2's telemetry closes four gaps found writing the tutorial: `chord.commit` counts the table rows a commit's writes changed (`rows`; a commit writing only rows no longer reads `documents=0 ops=0`); every span Chord starts, `ctx.trace`'s too, carries `chord.host`, the host's address key or `client` without an address (`Host` gains `address`); the SQLite telemetry store keeps span times in whole microseconds from pi-telemetry's `telemetryNow` (store version 2: a recorder migrates a version 1 file in place, its rows kept); and `openTelemetryStore(path)` (new subpath `@earendil-works/chord/telemetry-sqlite/node`) reads a store file read-only, implementing `TelemetryStore` only, refusing a file without a store or with one of another version. `openNodeSqliteDatabase` takes `mode: "read-only"`. - Chord v2's telemetry is stored in one place, the server (decisions "Telemetry from browser pages", "The server is the one place telemetry is stored", "Who a span was for"). Pages and workers record in memory with Chord's `TelemetryRecorder` (the SQLite store's own recorder too) and send finished spans in batches over their link to the server's `PageTelemetry` and `WorkerTelemetry` (`ingest(batch)`), which the app kit serves when `startServer` has a telemetry store; the store gains `ingest` (never replacing a stored span). `startPage({ telemetry: true })` records the page and sends its spans every 3 s and when hidden; the server checks a page's batch strictly (at most 100 spans, every field bounded, ids printable ASCII, no control characters), keeps only spans under the page's own ids (`browser::`), caps them at 1,200 per page and minute, marks them `chord.host: browser` and stamps them with the principal of their link (a page's own `chord.*` attributes are dropped). A worker sends its spans and metrics every second, stamped with its host. `host.root(fn, ctx, { trace: { name, attributes } })` runs `fn` in a new root span (a click). Method and call spans record whom they were for: `chord.principal` (the principal key) by default, or what the host's new `principalAttributes` hook returns (`createHost`, `startServer`, `startWorker`). `ServeOptions.untraced` serves a type whose calls start no method span. `Host` gains `incarnation` and `principalAttributes`. +- Chord v2's `TuiScope` gains `optimistic(read)`: the reader and `show(value, call)`, which shows `value` in place of the reader's until every call shown settled (React's `useOptimistic` for terminal UIs). The guide's "Optimistic UI" shows React's `useOptimistic` and `startTransition` over a Chord call, what it relies on and what it does not cover; example `63-optimistic-answers.ts`. ### Changed @@ -36,6 +37,7 @@ - Chord v2's `host.reload` starts the objects' fates dependents first: removals, then the other fates in the reverse of the order the objects went live (the report keeps its order). A pure dependent reloaded with its provider (a view over a store in one plugin) binds the provider while it is still live and swaps instead of parking until the provider's new instance is live: its contributions are replaced in one batch and its holds over links never show `loading`. A dependent still parks when its new code needs the provider's new version, when its provider went live after it, or when it needs something not live. - Chord v2 keeps calls over a link across a `loading` unbind (decision "Calls in flight survive a `loading` unbind"): a hold whose object restarts, whose swap parks or whose live failure retries no longer answers its calls in flight `unavailable`; each is answered by the instance it already runs on or by the new instance once it is live (or by its failure, `readyTimeout`, its end), and calls a consumer sent by the old names before it saw the `loading` wait the same way instead of `not_ready`. A `failed` unbind (a removed type, a dropped version, a swap parked on a failed dependency) still rejects them. - Chord v2's store writes the commits queued behind a running commit step in one backend transaction (group commit, decision S18): each keeps its own seq, outbox commit and adoption in queue order, the metadata is written once; a refusal in a group rolls it back and runs its commits one by one, so only the refusing commit is refused. +- Chord v2 sends a call's answer after its state (decision "A call's answer follows its state"): before a host sends a `result` or `error`, its own or one it forwards as a relay, it sends the origin's state still waiting for a slot of the window (one `state` message, past the window if it is full; acknowledged like any other). A call settling in a page now finds every change its answering host, and a relaying host, committed before answering, so an optimistic value is replaced by the real one instead of flickering back to the old value. Nothing extra is sent when nothing waits. ### Fixed diff --git a/packages/chord/docs/design.md b/packages/chord/docs/design.md index 77ea35e96..d4839b51d 100644 --- a/packages/chord/docs/design.md +++ b/packages/chord/docs/design.md @@ -382,7 +382,22 @@ host.route(Session, { - a hold that binds waits for a slot; its snapshot and subscription are taken in one synchronous step when its `bound` is sent; bindings and waiting state take turns when both wait, so neither starves; - a hold whose object leaves `live` while its `bound` waits goes back to pending (D1, owner, 2026-10-24; built in - slice 7b with the reload, which makes a live object leave `live`); so does a bound one, unbound (§7, §22). + slice 7b with the reload, which makes a live object leave `live`); so does a bound one, unbound (§7, §22); + - **a call's answer follows its state** (decision "A call's answer follows its state", owner, 2026-11-05): before + the provider sends a call's `result` or `error` (its own, or one it forwards as a relay), it sends the origin's + waiting state (every hold with pending ops, one `state` message), past the window if it is full. So when the call + settles at the consumer, every change the answering host committed before answering is applied there, and an + optimistic value (React's `useOptimistic`, `TuiScope.optimistic`) is replaced by the real one, never by the old. + Trace: window full (`bound`, `state` unacknowledged), a call commits (its ops wait), its `result`: the provider + sends `state` (the waiting ops), then `result`; without the rule the result overtook its own commit and the page + showed the old value until the next `ack`. A message past the window is counted and acknowledged like any other + (once, in order; the window opens only when fewer than K are in flight again); there is at most one per answer, and + its consumer acknowledges it before it sends the call that answer frees a slot for, so running calls and messages + past the window stay within N per origin together. Nothing extra is sent when nothing waits (the common path: a + free window sent the commit's `state` already). Bindings waiting for a slot are not sent past it: their `bound` + carries the latest state when it goes (a hold rebinding after a reload's swap shows its old values until then). + Not covered: changes committed after the answer (a method that returns before its effect lands) or on another host + that does not relay the answer. Not built, by decision: a byte window, credit messages, snapshot admission or debt, an adapter `backlog()` API, `reset` from anyone. - **Calls: a call window N** (default 256, the same on every host) announced in `hello`: how many concurrent calls a @@ -433,7 +448,10 @@ drops, folds or resets. `result` and `error` by its call is checked against the relay's table; an answer for a hold or call not relayed over the link it came from is dropped (Q1: also one crossing a release). A `bound` or `state` left with some entries is forwarded with those; left with none, it is acknowledged to that link at once. A message for an origin from a link - the origin relayed nothing to is ignored whole. + the origin relayed nothing to is ignored whole. A `result` or `error` forwarded goes after the relay's own waiting + state for that origin (the rule above: a worker's answer whose effect the worker pushed to the server arrives after + that server state); the upstream host sent its own waiting state before its answer, and the relay forwarded that at + once. - **Acks:** the relay forwards each upstream `bound`/`state` at once and returns its `ack` to the upstream link that sent it when the downstream acknowledges it (the senders FIFO). A tab served by W workers may have 2W messages in flight (plus the relay's own 2), each worker's window bounding its own. A slow tab stalls only its origin at each @@ -2197,7 +2215,9 @@ validation, no clone of snapshots or ops. provider and consumer), restarted with every snapshot. Two holds of the same principal (two tabs) encode the same ops separately (O(change) each). A part is encoded once at its origin; relays never re-encode it. - **Sending:** one `state` message per commit per link and origin while the window has a slot; otherwise the ops wait - in the members' pending queues and leave as one message when an `ack` frees a slot (section 3). + in the members' pending queues and leave as one message when an `ack` frees a slot, or before a call's `result` or + `error` of that origin, past the window (section 3, "a call's answer follows its state"; a relay sends its own before + each answer it forwards). No message kind or field changes. - **Relays** read and rewrite only envelopes (section 3, "Relays"). - **Size limits per part (R2, Q2; slice 4b):** limits apply to each part (wire and decoded size), configurable per link, checked from the part's length before it is parsed or inflated; the decoded limit is enforced in the one @@ -2474,7 +2494,8 @@ Decision "Slice 11's points", "Slice 11, P3" to "P9", and "Tutorial gaps" (1) (p with an empty environment, `--permission` granting nothing and no SQLite; the builtin allowlist and a frozen substitute `process` inside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the - app's redraw; `pageState(key, initial)`, the host's page state shared with `usePageState`), no pi-tui dependency. + app's redraw; `pageState(key, initial)`, the host's page state shared with `usePageState`; `optimistic(read)`, a + reader showing an optimistic value while a call is pending, React's `useOptimistic`, §3), no pi-tui dependency. ### Messages and languages (built after slice 11) diff --git a/packages/chord/docs/guide-plugins-hosts/book.md b/packages/chord/docs/guide-plugins-hosts/book.md index 98287cebf..031f9c876 100644 --- a/packages/chord/docs/guide-plugins-hosts/book.md +++ b/packages/chord/docs/guide-plugins-hosts/book.md @@ -405,6 +405,57 @@ const { t } = useMessages(messages); // re-renders when th draft.notice = messages.message("empty"); ``` +## Optimistic UI + +A user renames a session. The call goes from the tab through the server to the session's worker, which renames it and +reports the new title to the server's index of sessions, then answers. That round trip takes a moment, and the title +should change at once. React's `useOptimistic` shows a value while a transition runs, and Chord's references fit it: + +```tsx +// plugins/sessions/web.tsx +function SessionName({ id }: { id: string }) { + const sessions = useHold(Sessions, {}); // the server's index, which every list shows + const session = useHold(SessionTitle, { id }); // on the session's worker + const title = useWatch(sessions?.titles)?.[id] ?? ""; + const [shown, showOptimistic] = useOptimistic(title); + const rename = (next: string) => + startTransition(async () => { + showOptimistic(next); // drawn at once + await session?.rename(next, BACKGROUND_CONTEXT); // settles once the index has `next` + }); + return ; +} +``` + +When the transition ends, React drops the optimistic value and draws `title` again. That is only right if `title` is +already the new name by then. **Chord guarantees it:** a call settles in the page only after every change its answering +host committed before answering is applied there, and so does every change a host that relays the answer committed +before it forwarded it. Here the worker's own changes travel before its answer, and the server sends its index change +for this tab before it forwards the worker's answer, even on a slow connection whose window of unacknowledged state is +full (design §3). Without that, the answer could overtake the index change and the old title flickered back. + +**What it does not cover:** + +- Changes committed after the answer: a method that starts work and returns before it lands (a `void` call, a job, a + routine) answers first; the optimistic value goes and the old one shows until the change arrives. +- Changes on a host that neither answers nor relays the answer. A server method that calls a worker and then answers + the tab orders only the server's own changes before its answer; what the worker changed for the tab travels on its + own and may come later. + +So the rule for a method that should feel instant: it awaits its own changes, the pushes to other hosts included (the +worker's `rename` awaits its report to the index), and keeps them on the host that answers or relays the answer. + +A terminal UI does the same with its component scope: `scope.optimistic(read)` wraps a reader and returns it with +`show(value, call)`, which shows `value` until the call settles (until every call shown, when they overlap): + +```ts +const titles = scope.watch(sessions.titles); +const [title, show] = scope.optimistic(() => titles()?.[id]); +await show(next, session.rename(next, ctx)); // title() is `next` at once, the real one when it settles +``` + +`test/examples/63-optimistic-answers.ts` runs this with a stalled terminal client and prints what each redraw showed. + # Plugins {#plugins lede="Move the code into a directory that can be added, edited and removed while the app runs."} So far the facets were part of the app's own code. A **plugin** is the same code in its own directory: diff --git a/packages/chord/docs/guide-plugins-hosts/index.html b/packages/chord/docs/guide-plugins-hosts/index.html index bab3c7ffe..b99648355 100644 --- a/packages/chord/docs/guide-plugins-hosts/index.html +++ b/packages/chord/docs/guide-plugins-hosts/index.html @@ -409,6 +409,45 @@ self.contribute(Translations const { t } = useMessages(messages); // re-renders when the user picks another language // plugins/notes/worker.ts: a notice as data; every client renders it with t.render(notice) draft.notice = messages.message("empty"); +

Optimistic UI

+

A user renames a session. The call goes from the tab through the server to the session's worker, which renames it and +reports the new title to the server's index of sessions, then answers. That round trip takes a moment, and the title +should change at once. React's useOptimistic shows a value while a transition runs, and Chord's references fit it:

+
// plugins/sessions/web.tsx
+function SessionName({ id }: { id: string }) {
+	const sessions = useHold(Sessions, {});                       // the server's index, which every list shows
+	const session = useHold(SessionTitle, { id });                // on the session's worker
+	const title = useWatch(sessions?.titles)?.[id] ?? "";
+	const [shown, showOptimistic] = useOptimistic(title);
+	const rename = (next: string) =>
+		startTransition(async () => {
+			showOptimistic(next);                                    // drawn at once
+			await session?.rename(next, BACKGROUND_CONTEXT);         // settles once the index has `next`
+		});
+	return <TitleField value={shown} onRename={rename} />;
+}
+

When the transition ends, React drops the optimistic value and draws title again. That is only right if title is +already the new name by then. Chord guarantees it: a call settles in the page only after every change its answering +host committed before answering is applied there, and so does every change a host that relays the answer committed +before it forwarded it. Here the worker's own changes travel before its answer, and the server sends its index change +for this tab before it forwards the worker's answer, even on a slow connection whose window of unacknowledged state is +full (design §3). Without that, the answer could overtake the index change and the old title flickered back.

+

What it does not cover:

+
    +
  • Changes committed after the answer: a method that starts work and returns before it lands (a void call, a job, a +routine) answers first; the optimistic value goes and the old one shows until the change arrives.
  • +
  • Changes on a host that neither answers nor relays the answer. A server method that calls a worker and then answers +the tab orders only the server's own changes before its answer; what the worker changed for the tab travels on its +own and may come later.
  • +
+

So the rule for a method that should feel instant: it awaits its own changes, the pushes to other hosts included (the +worker's rename awaits its report to the index), and keeps them on the host that answers or relays the answer.

+

A terminal UI does the same with its component scope: scope.optimistic(read) wraps a reader and returns it with +show(value, call), which shows value until the call settles (until every call shown, when they overlap):

+
const titles = scope.watch(sessions.titles);
+const [title, show] = scope.optimistic(() => titles()?.[id]);
+await show(next, session.rename(next, ctx));              // title() is `next` at once, the real one when it settles
+

test/examples/63-optimistic-answers.ts runs this with a stalled terminal client and prints what each redraw showed.

V

Plugins

Move the code into a directory that can be added, edited and removed while the app runs.

diff --git a/packages/chord/src/react/README.md b/packages/chord/src/react/README.md index de4919eec..0231750f4 100644 --- a/packages/chord/src/react/README.md +++ b/packages/chord/src/react/README.md @@ -13,6 +13,11 @@ peer), non-React apps never load it. No components, no styling. re-rendering when the locale or a translation changes; before a language is followed, the first catalog. - `usePageState(key, initial)`: `useState`'s pair, the value kept by the page (its host's life) under `key` (`surfaces/page-state.ts`, shared with `TuiScope.pageState`), so a component remounted by a plugin reload keeps it. +- Optimistic UI needs no hook of Chord's: React's `useOptimistic` and `startTransition` over a call. A call settles in + the page after every change its answering host (and a host relaying its answer) committed before answering, so the + optimistic value is replaced by the real one, never by the old (design §3, decision "A call's answer follows its + state"; changes committed after the answer or on a host that does not relay it are not covered). The guide's + "Optimistic UI" (`docs/guide-plugins-hosts/book.md`) shows it. - A component unmounted by a reload has not left (design §21): its releases are plain releases. A component of a reloaded plugin is a new function and remounts, its `useState` starting over: what must survive is page state. diff --git a/packages/chord/src/remote/provider.ts b/packages/chord/src/remote/provider.ts index afce4c7d8..45762627d 100644 --- a/packages/chord/src/remote/provider.ts +++ b/packages/chord/src/remote/provider.ts @@ -1,8 +1,8 @@ // The provider side of one origin on a link (§3, §22, W1; decision "Relay flow control per origin"): the holds the // peer sent for that origin, served here or relayed, by their ids; the calls on them, at most this side's N at once; -// and the window: at most K unacknowledged `bound` and `state` messages of this host's own, with what waits meanwhile, -// and the sender of every message in flight, so each `ack` reaches the host that sent its message. A served call's -// method runs under the call's carrier (slice 9). +// and the window: at most K unacknowledged `bound` and `state` messages of this host's own, with what waits meanwhile +// (a call's answer sends the waiting state first, past the window), and the sender of every message in flight, so each +// `ack` reaches the host that sent its message. A served call's method runs under the call's carrier (slice 9). import type { TelemetryContext } from "@earendil-works/pi-telemetry"; import { type Context, withAbortSignal } from "../context/index.ts"; @@ -41,15 +41,19 @@ type Sender = "local" | HostLink; /** A relaying origin: its number on upstream links, and the open upstream links it relayed holds to. */ type Relay = { readonly origin: number; readonly links: Set }; -/** At most this many of this host's own `bound` and `state` messages unacknowledged per origin. */ +/** + * At most this many of this host's own `bound` and `state` messages unacknowledged per origin; past it only the waiting + * state a call's answer sends first. + */ const WINDOW = 2; /** * The provider side of one origin on a link. Ids are the origin's; a message naming an id this side does not know is * ignored (Q1), one reusing a live id is a protocol error, and so are more concurrent calls than this side announced. * This host's own `bound` and `state` messages go through the window: locally served holds wait while it is full - * (their ops in their members' pending queues, their bindings for the next slot). A relay forwards upstream messages - * at once (each upstream host keeps its own window for the origin it sees), so a slow consumer stalls only its origin. + * (their ops in their members' pending queues, their bindings for the next slot); a call's answer, its own or a + * relayed one, sends the waiting state first. A relay forwards upstream messages at once (each upstream host keeps its + * own window for the origin it sees), so a slow consumer stalls only its origin. */ export class ProviderSide { readonly link: HostLink; @@ -173,11 +177,11 @@ export class ProviderSide { /** * A message from upstream link `from` for this origin's relayed holds, forwarded with this origin: `bound` and - * `state` join the window's senders (their `ack` goes back to `from`), a reply ends its call, `gone` forgets its - * hold. A link answers only for what was relayed over it: an entry, a notice or a reply naming a hold (or a call) - * this origin did not relay over `from` is dropped (Q1); a `bound` or `state` left without entries is acknowledged - * to `from` at once. Parts travel unchecked and unparsed: the end that parses a part checks it against its own - * link's limits. + * `state` join the window's senders (their `ack` goes back to `from`), a reply ends its call (after this host's own + * waiting state: `reply`), `gone` forgets its hold. A link answers only for what was relayed over it: an entry, a + * notice or a reply naming a hold (or a call) this origin did not relay over `from` is dropped (Q1); a `bound` or + * `state` left without entries is acknowledged to `from` at once. Parts travel unchecked and unparsed: the end that + * parses a part checks it against its own link's limits. */ forward(message: ConsumerMessage, from: HostLink): void { const relay = this.#relay; @@ -247,11 +251,19 @@ export class ProviderSide { this.#settle(); } - /** A call ended with `reply` (a result or an error): its slot is free; nothing is sent for one already gone. */ + /** + * A call ended with `reply` (a result or an error, its own or forwarded from upstream): its slot is free; nothing is + * sent for one already gone. The state waiting for a slot goes first, past the window if it is full (decision "A + * call's answer follows its state"): when the call settles at the consumer, every change this host committed before + * answering is applied there. + */ reply(id: number, reply: Extract): void { const call = this.#calls.get(id); if (call === undefined) return; this.#calls.delete(id); + // At most one extra message per answer, acknowledged before the call its answer frees a slot for (one direction keeps + // its order): running calls and messages past the window stay within this side's N together. + if (this.#waiting.size > 0) this.#sendWaiting(this.link.context); this.send(reply); if (call.kind === "relayed" && call.span !== undefined) relayEnded(call.span, reply); this.#settle(); @@ -559,17 +571,25 @@ export class ProviderSide { // Every snapshot in one synchronous step: the binding shows all of them at one moment. this.#sendOwn({ kind: "bound", holds: holds.map((hold) => hold.boundEntry(context)) }); } else if (this.#waiting.size > 0) { - const holds = [...this.#waiting]; - this.#waiting.clear(); - this.#waited(); - this.#bindingTurn = this.#binding.size > 0; - this.#sendOwn({ kind: "state", holds: holds.map((hold) => hold.pendingEntry(context)) }); + this.#sendWaiting(context); } else { return; } } } + /** + * The waiting state as one `state` message, taking a slot: a freed one, or one past the window before an answer. + * Waiting bindings take the next slot. + */ + #sendWaiting(context: Context): void { + const holds = [...this.#waiting]; + this.#waiting.clear(); + this.#waited(); + this.#bindingTurn = this.#binding.size > 0; + this.#sendOwn({ kind: "state", holds: holds.map((hold) => hold.pendingEntry(context)) }); + } + /** * A call's result message, its part deflated above the threshold when this host compresses; with byte segments when * its method's result schema has a `bytes()` leaf. diff --git a/packages/chord/src/tui/README.md b/packages/chord/src/tui/README.md index 437cfeea3..0f1491b94 100644 --- a/packages/chord/src/tui/README.md +++ b/packages/chord/src/tui/README.md @@ -29,7 +29,8 @@ startTerminalRuntime

({ - `scope.ts`: `TuiScope(host, changed)`: `hold`, `watch` (a reader, `undefined` while no value), `registry`, `pageState(key, initial)` (the host's value at `key`, shared with React's `usePageState`: a scope opened again after a plugin reload finds it), `messages(t)` (a plugin's messages in the host's language at each call; a change of the - locale or a translation calls `changed`), `dispose` (no `changed` after it). References wait and rebind by themselves; no terminal - library dependency. + locale or a translation calls `changed`), `optimistic(read)` (the reader and `show(value, call)`: `value` shown until + every call shown settled, React's `useOptimistic`), `dispose` (no `changed` after it). References wait and rebind by + themselves; no terminal library dependency. Tests: `test/tui/` and, with the launcher and sandbox, `test/node/tui/`. diff --git a/packages/chord/src/tui/scope.ts b/packages/chord/src/tui/scope.ts index ba4e0ab2d..078bdc612 100644 --- a/packages/chord/src/tui/scope.ts +++ b/packages/chord/src/tui/scope.ts @@ -2,7 +2,8 @@ // counterpart of the React bindings for terminal UI libraries. It holds references, follows watchables and registries, // calls one `changed` callback (the app's redraw) on every change, and releases everything at `dispose`. Its page state // is the host's, shared by every scope (React's `usePageState`): a scope opened again after a plugin reload finds it. -// Its messages render in the host's language, and a change of the locale or a translation redraws. +// Its messages render in the host's language, and a change of the locale or a translation redraws. A reader can show +// an optimistic value while a call is pending (React's `useOptimistic`). // References wait and rebind by themselves (no retry loop, unlike p2's); no terminal library dependency. import { BACKGROUND_CONTEXT, withAbortSignal } from "../context/index.ts"; @@ -72,6 +73,35 @@ export class TuiScope

{ return () => read(); } + /** + * `read` with an optimistic value (React's `useOptimistic`): `show(value, call)` returns `call` and shows `value` in + * place of `read`'s until every call shown so settled, then `read`'s again; each change calls `changed`. A call over + * a link settles after the changes its answering host committed before answering (decision "A call's answer follows + * its state"), so the value shown next is the real one. + */ + optimistic( + read: () => T | undefined, + ): readonly [read: () => T | undefined, show: (value: T, call: Promise) => Promise] { + let shown: T | undefined; + let pending = 0; + const redraw = (): void => { + if (!this.#abort.signal.aborted) this.#changed(); + }; + const settled = (): void => { + if (--pending === 0) shown = undefined; + redraw(); + }; + const show = (value: T, call: Promise): Promise => { + shown = value; + pending++; + // Its caller handles a rejection of `call`; this only follows it. + call.then(settled, settled); + redraw(); + return call; + }; + return [() => (pending > 0 ? shown : read()), show]; + } + /** * The host's page state at `key`, kept for the terminal's life, not the scope's (a new key starts at `initial`); its * changes call `changed` until `dispose`. diff --git a/packages/chord/test/examples/63-optimistic-answers.ts b/packages/chord/test/examples/63-optimistic-answers.ts new file mode 100644 index 000000000..ceff74a64 --- /dev/null +++ b/packages/chord/test/examples/63-optimistic-answers.ts @@ -0,0 +1,213 @@ +// A call's answer follows its state (decision of 2026-11-05), and the optimistic value a terminal UI shows while a +// call is pending (`TuiScope.optimistic`, React's `useOptimistic`). A session's worker renames its session and pushes +// the new title to the server's index inside the method, as an app's worker should. A slow terminal client holds the +// index (served by the server) and calls `rename` (relayed by the server to the worker). The server's window for the +// client is full, so its index change waits; before the server forwards the worker's answer it sends that waiting +// state, so when the call settles in the client the index already has the new title: the optimistic title is replaced +// by the real one and the old one never shows again. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/examples/63-optimistic-answers.ts +import { BACKGROUND_CONTEXT } from "../../src/context/index.ts"; +import { hostKind, local, map, method, obj, remote, str } from "../../src/contracts/index.ts"; +import { defineState } from "../../src/documents/index.ts"; +import { createHost, defineFacet, type Host } from "../../src/host/index.ts"; +import { binaryCodec, describeMessage, type Link, memoryLinkPair } from "../../src/link/index.ts"; +import { TuiScope } from "../../src/tui/scope.ts"; + +const context = BACKGROUND_CONTEXT; + +type P = { kind: "owner" } | { kind: "server" } | { kind: "worker"; session: string }; + +const Server = hostKind("server"); +const Session = hostKind("session", { id: str() }); + +const Report = obj({ id: str(), title: str() }); + +/** The server's index of session titles: one store, whoever views it. */ +const SessionIndex = local("example.session-index", { + version: 1, + params: {}, + members: { titles: map(str()), report: method(Report) }, +}); + +/** The index as clients and workers hold it. */ +const Sessions = remote("example.sessions", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { all: { state: { titles: map(str()) }, methods: { report: method(Report) } } }, +}); + +/** A session, on its worker: its title, and the method that changes it. */ +const SessionTitle = remote("example.session-title", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => Session({ id }), + variants: { owner: { state: { title: str() }, methods: { rename: method(str()) } } }, +}); + +const IndexDoc = defineState("index", { + schema: obj({ titles: map(str()) }), + version: 1, + initial: () => ({ titles: {} }), +}); + +const TitleDoc = defineState("title", { schema: obj({ title: str() }), version: 1, initial: () => ({ title: "" }) }); + +const serverFacet = defineFacet

((host) => { + host.serve(SessionIndex, (_, self) => { + const doc = self.state(IndexDoc); + return { + titles: self.derive([doc], ([index]) => index.titles), + report: async ({ id, title }, ctx) => { + await doc.change((draft) => { + draft.titles[id] = title; + }, ctx); + return null; + }, + }; + }); + host.serve(Sessions, (_params, _principal, self) => { + const index = self.get(SessionIndex, {}); + return { variant: "all", members: { titles: index.titles, report: (report, ctx) => index.report(report, ctx) } }; + }); +}); + +const workerFacet = defineFacet

((host) => { + host.serve(SessionTitle, ({ id }, _principal, self) => { + const doc = self.state(TitleDoc); + const sessions = self.get(Sessions, {}); + return { + variant: "owner", + members: { + title: self.derive([doc], ([current]) => current.title), + // The rule for a method that should feel instant: it awaits its own changes, the push to the server included. + rename: async (title, ctx) => { + await doc.change((draft) => { + draft.title = title; + }, ctx); + await sessions.report({ id, title }, ctx); + return null; + }, + }, + }; + }); +}); + +/** The client's end of its link: while paused, what arrives waits (so it acknowledges nothing); logs what it gets. */ +class SlowLink implements Link { + paused = false; + readonly #link: Link; + readonly #waiting: Uint8Array[] = []; + #listener: (message: Uint8Array) => void = () => {}; + + constructor(link: Link) { + this.#link = link; + link.onMessage((message) => { + if (this.paused) this.#waiting.push(message); + else this.#deliver(message); + }); + } + + /** Delivers what waited one message per task, as a socket does: what a message settles runs before the next. */ + async resume(): Promise { + for (let message = this.#waiting.shift(); message !== undefined; message = this.#waiting.shift()) { + this.#deliver(message); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + this.paused = false; + } + + send(message: Uint8Array): void { + this.#link.send(message); + } + + onMessage(listener: (message: Uint8Array) => void): void { + this.#listener = listener; + } + + close(): void { + this.#link.close(); + } + + onClose(listener: () => void): void { + this.#link.onClose(listener); + } + + #deliver(message: Uint8Array): void { + const decoded = binaryCodec.decode(message); + if (decoded.kind === "state" || decoded.kind === "result") { + console.log(" client ←", describeMessage(decoded).slice(0, 100)); + } + this.#listener(message); + } +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); + +// 1. A server routing sessions to workers (in memory here), and a terminal client on a link that can stall. +const server = createHost

({ address: Server(), principal: { kind: "server" }, onError: console.error }); +const workers: Host

[] = []; +server.route( + Session, + { + authorize: () => true, + resolve: async (address) => { + const [a, b] = memoryLinkPair(); + const principal = { kind: "worker", session: address.params.id } as const; + const worker = createHost

({ address, principal, onError: console.error }); + worker.connect(b, { principal: { kind: "server" }, trusted: true }, context); + void worker.load([{ name: "@example/worker", facet: workerFacet }], context); + workers.push(worker); + return { link: a, principal, trusted: true }; + }, + }, + context, +); +await server.load([{ name: "@example/server", facet: serverFacet }], context); +const [up, down] = memoryLinkPair(); +server.accept(up, { principal: { kind: "owner" } }, context); +const slow = new SlowLink(down); +const client = createHost

({ onError: console.error }); +client.connect(slow, { principal: { kind: "server" } }, context); +await client.load([], context); + +// 2. A component's scope: it holds the index and the session, and shows the session's title from the index, with an +// optimistic value while a rename is pending. Each redraw records what it shows. +const drawn: (string | undefined)[] = []; +const scope = new TuiScope(client, () => drawn.push(title())); +const sessions = scope.hold(Sessions, {}); +const session = scope.hold(SessionTitle, { id: "S1" }); +const titles = scope.watch(sessions.titles); +const [title, show] = scope.optimistic(() => titles()?.S1); +await session.rename("Draft", context); +await settle(); +console.log("the index shows:", title()); + +// 3. The client stalls (it acknowledges nothing). Other sessions report to the index: two `state` messages fill the +// server's window for this client, the third change waits there. +slow.paused = true; +const index = await server.get(SessionIndex, {}, context); +for (const id of ["S2", "S3", "S4"]) await index.report({ id, title: `session ${id}` }, context); + +// 4. The user renames S1. The title shows the new name at once; the worker renames, pushes the title to the index +// (whose change waits in the server's window too) and answers. The server sends its waiting state before it forwards +// the answer, past its window. +drawn.length = 0; +const renamed = show("Renamed", session.rename("Renamed", context)); +await settle(); +console.log("while the call is pending, the title shows:", title()); + +// 5. The client catches up, one message per task: the states, then the answer. When the call settles the index has the +// new title, so the title never shows "Draft" again (an answer that overtook the waiting state showed it once more). +console.log("the client resumes:"); +await slow.resume(); +await renamed; +await settle(); +console.log("the redraws showed:", drawn); +console.log("the index shows:", title()); + +scope.dispose(); +await client.close(context); +await server.close(context); +for (const worker of workers) await worker.close(context); diff --git a/packages/chord/test/remote/hold.test.ts b/packages/chord/test/remote/hold.test.ts index 129a11538..84e50f41d 100644 --- a/packages/chord/test/remote/hold.test.ts +++ b/packages/chord/test/remote/hold.test.ts @@ -5,7 +5,7 @@ import { broken, ChordError } from "../../src/data/index.ts"; import type * as Delta from "../../src/delta/index.ts"; import { decoder } from "../../src/delta/index.ts"; import { defineState } from "../../src/documents/index.ts"; -import { createHost, defineFacet, type FacetHost, type Host, type Self } from "../../src/host/index.ts"; +import { type Ctx, createHost, defineFacet, type FacetHost, type Host, type Self } from "../../src/host/index.ts"; import type { ChordHost } from "../../src/host/internal.ts"; import { ChordObject } from "../../src/host/object.ts"; import { type Link, memoryLinkPair } from "../../src/link/index.ts"; @@ -632,7 +632,10 @@ const RETIRING = wireNames(Retiring, "v"); /** Every member of `Retiring` retired with its document, sorted. */ const retiredAll = [RETIRING.state.doc!, RETIRING.state.big!, RETIRING.state.t!].sort(); -/** A server serving `Retiring` to a raw peer that acks only when told; `call` calls a method of hold 0. */ +/** + * A server serving `Retiring` to a raw peer that acks only when told; `call` calls a method of the object hold 0 holds, + * from the server itself as the hold's principal (an answer over the link would send the waiting state before it). + */ async function retiring() { const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); await server.load( @@ -673,13 +676,15 @@ async function retiring() { server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context); const peer = new RawPeer(b, { ack: false }); await transported(); - let calls = 0; - const call = async (method: "set" | "retire", args?: number) => { - const id = calls++; - const payload = args === undefined ? {} : { args: jsonPart(args) }; - peer.send({ kind: "call", call: id, hold: 0, method: RETIRING.methods[method]!, ...payload }); - expect((await peer.next("result")).call).toBe(id); - }; + const call = (method: "set" | "retire", args?: number) => + server.root( + async (ctx) => { + const object = await ctx.get(Retiring, {}); + await (method === "set" ? object.set(args!, ctx) : object.retire(ctx)); + }, + context, + { principal: { kind: "owner" } }, + ); const { name: type, version, fingerprint } = Retiring; peer.send({ kind: "hold", hold: 0, type, version, fingerprint, params: "{}", address: SERVER_ADDRESS }); await peer.next("bound"); @@ -1063,14 +1068,12 @@ describe("holds across a reload (slice 7b §6)", () => { const [a, b] = memoryLinkPair(); server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context); const peer = new RawPeer(b, { ack: false }); - const SHOWN = wireNames(Shown, "v"); await peer.next("hello"); hold(peer, 0, Shown); await peer.next("bound"); - for (let call = 0; call < 3; call++) { - peer.send({ kind: "call", call, hold: 0, method: SHOWN.methods.bump! }); - await peer.next("result"); - } + // Commits from the server itself as the hold's principal: an answer over the link would send the waiting state. + const bump = async (ctx: Ctx) => (await ctx.get(Shown, {})).bump(ctx); + for (let commit = 0; commit < 3; commit++) await server.root(bump, context, { principal: { kind: "owner" } }); // One state in flight with the bound: the third commit's ops wait in their pending queue. await peer.next("state"); await server.reload([], context); diff --git a/packages/chord/test/remote/provider.test.ts b/packages/chord/test/remote/provider.test.ts index 64dd630f5..2d63209e8 100644 --- a/packages/chord/test/remote/provider.test.ts +++ b/packages/chord/test/remote/provider.test.ts @@ -4,7 +4,7 @@ import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts"; import { type Contract, int, local, map, method, obj, remote } from "../../src/contracts/index.ts"; import { PendingOps } from "../../src/delta/index.ts"; import { defineState } from "../../src/documents/index.ts"; -import { createHost, defineFacet, type FacetHost } from "../../src/host/index.ts"; +import { type Ctx, createHost, defineFacet, type FacetHost } from "../../src/host/index.ts"; import { type Compression, memoryLinkPair, type PartLimits } from "../../src/link/index.ts"; import { DeflatedPart, jsonPart, type Message } from "../../src/link/internal.ts"; import { nodeCompression } from "../../src/node/compression.ts"; @@ -645,21 +645,15 @@ forEachCodec(() => { }); it("overflows past 10,000 pending ops to a replacement with the current value (race 23)", async () => { - const { peer } = await slowConsumer(); + const { server, peer } = await slowConsumer(); hold(peer, 0, Grid, {}); await peer.next("bound"); - const fill = (id: number, value: number) => - peer.send({ - kind: "call", - call: id, - hold: 0, - method: GRID.methods.fill!, - args: jsonPart({ n: 6_000, value }), - }); - fill(0, 1); - fill(1, 2); - fill(2, 3); - for (const _ of [0, 1, 2]) await peer.next("result"); + // Calls on the owner's object from the server itself: an answer over the link would send the waiting state first. + const fills = async (ctx: Ctx) => { + const grid = await ctx.get(Grid, {}); + for (const value of [1, 2, 3]) await grid.fill({ n: 6_000, value }, ctx); + }; + await server.root(fills, context, { principal: { kind: "owner" } }); // The first fill took the second slot; the next two overflowed the queue. peer.ack(); await transported(); @@ -670,6 +664,79 @@ forEachCodec(() => { }); }); + describe('an answer after its state (decision "A call\'s answer follows its state")', () => { + it("sends the waiting state before a result when the window is full: one message, every hold, past the window", async () => { + const { server, peer } = await slowConsumer(); + hold(peer, 0, Notes, { session: "S1" }); + hold(peer, 1, Notes, { session: "S2" }); + await peer.next("bound"); + await peer.next("bound"); + await addNote(server, "S2", "a"); + await addNote(server, "S2", "b"); + call(peer, 0, 0, add, { text: "c" }); + await transported(); + // The two bindings fill the window: both commits and the call's own wait, then go before its result. + expect(peer.kinds()).toEqual(["hello", "state", "result"]); + const { holds } = statesOf(peer)[0]!; + expect(holds.map(({ hold, members }) => [hold, EDITOR.named(members).count])).toEqual([ + [1, [["r", 2]]], + [0, [["r", 1]]], + ]); + expect(partValue((await peer.next("result")).value!)).toBe(1); + }); + + it("acknowledges every message once, in order, those past the window too: the window opens after their acks", async () => { + const { server, peer } = await slowConsumer(); + hold(peer, 0, Notes, { session: "S1" }); + await peer.next("bound"); + await addNote(server, "S1", "a"); + await addNote(server, "S1", "b"); + call(peer, 0, 0, add, { text: "c" }); + await peer.next("result"); + await addNote(server, "S1", "d"); + await transported(); + // Three in flight (the bound, a state, the state past the window): the first ack leaves the window full. + expect(windowed(peer)).toEqual(["state", "state"]); + peer.ack(); + await transported(); + expect(windowed(peer)).toEqual(["state", "state"]); + peer.ack(); + await transported(); + expect(windowed(peer)).toEqual(["state", "state", "state"]); + for (const _ of [0, 1]) peer.ack(); + // Acks crossing nothing are ignored: two slots, not more. + for (const _ of [0, 1]) peer.ack(); + for (const text of ["e", "f", "g"]) await addNote(server, "S1", text); + await transported(); + expect(windowed(peer)).toEqual(["state", "state", "state", "state", "state"]); + }); + + it("sends nothing more when nothing waits: a full window, an answer of a call that changed nothing", async () => { + const { server, peer } = await slowConsumer(); + hold(peer, 0, Notes, { session: "S1" }); + await peer.next("bound"); + await addNote(server, "S1", "a"); + call(peer, 0, 0, fail, { how: "chord" }); + await transported(); + expect(peer.kinds()).toEqual(["hello", "state", "error"]); + }); + + it("costs nothing on the common path: a call's commit goes out once, at its commit, never again with its answer", async () => { + const telemetry = new InMemoryTelemetryContext(); + const { peer } = await rawLinked((h) => void serveNotes(h), { telemetry }); + hold(peer, 0, Notes, { session: "S1" }); + await peer.next("bound"); + for (const [id, text] of ["a", "b", "c"].entries()) { + call(peer, id, 0, add, { text }); + await peer.next("result"); + } + await transported(); + const states = telemetry.getMetrics().filter(({ name }) => name === "chord.link.state_messages"); + expect(states.reduce((sum, { value }) => sum + value, 0)).toBe(3); + expect(peer.kinds()).toEqual(["hello", "state", "state", "state"]); + }); + }); + describe("origins at the provider (a relay's: on its parent link)", () => { it("key holds and calls by (origin, id): the same ids on two origins are two holds; answers carry the origin", async () => { const { peer } = await slowConsumer("parent"); diff --git a/packages/chord/test/remote/relay.test.ts b/packages/chord/test/remote/relay.test.ts index 899af7091..619556ff2 100644 --- a/packages/chord/test/remote/relay.test.ts +++ b/packages/chord/test/remote/relay.test.ts @@ -21,6 +21,7 @@ import { rawWorkers, rejection, relayed, + SERVER_ADDRESS, Server, Session, serveNotes, @@ -337,6 +338,48 @@ forEachCodec(() => { }); }); + describe("a relayed answer after the relay's own state (decision \"A call's answer follows its state\")", () => { + it("forwards a worker's result after the state the relay's own objects keep waiting for the tab; every ack reaches its sender", async () => { + const workers: RawPeer[] = []; + const relay = await relayed({ + server: (h) => void serveNotes(h), + resolve: rawWorkers(workers, { ack: false }), + }); + const [a, b] = memoryLinkPair(); + relay.server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context); + const tab = new RawPeer(b, { ack: false }); + const { name: type, version, fingerprint } = Notes; + const params = jsonPart({ session: "S1" }); + tab.send({ kind: "hold", hold: 0, type, version, fingerprint, params, address: SERVER_ADDRESS }); + await tab.next("bound"); + holdPlaced(tab, 1); + const worker = await workerAt(workers); + const held = await worker.next("hold"); + bindPlaced(worker, held); + await tab.next("bound"); + // The relay's own window for the tab: its bound and this state; the next commit waits. + await addNote(relay.server, "S1", "a"); + await addNote(relay.server, "S1", "b"); + tab.send({ kind: "call", call: 0, hold: 1, method: add, args: jsonPart({ text: "x" }) }); + const forwarded = await worker.next("call"); + worker.send({ kind: "result", ...originOf(held), call: forwarded.call, value: jsonPart(1) }); + await transported(); + expect(tab.kinds()).toEqual(["hello", "state", "state", "result"]); + const waited = tab.received.filter((message) => message.kind === "state").at(-1)!; + expect(waited.holds.map(({ hold, members }) => [hold, EDITOR.named(members).count])).toEqual([ + [0, [["r", 2]]], + ]); + // Four messages in flight to the tab, one of them the worker's: its ack goes to the worker, the others free the + // relay's own window (the state past it included). + for (const _ of [0, 1, 2, 3]) tab.ack(); + await transported(); + expect(worker.kinds().filter((kind) => kind === "ack")).toEqual(["ack"]); + for (const text of ["c", "d", "e"]) await addNote(relay.server, "S1", text); + await transported(); + expect(tab.kinds().filter((kind) => kind === "state")).toHaveLength(4); + }); + }); + describe("an upstream drop", () => { fakeTimers(); diff --git a/packages/chord/test/tui/scope.test.ts b/packages/chord/test/tui/scope.test.ts index 6a3582bf7..70b827a39 100644 --- a/packages/chord/test/tui/scope.test.ts +++ b/packages/chord/test/tui/scope.test.ts @@ -1,7 +1,8 @@ // Tests of `TuiScope` (slice 11 §4, decision "Slice 11, P9"): references held for a component, watchables and // registries followed, one `changed` callback for every change, everything released at `dispose`, and the host's page // state shared by every scope (decision "Tutorial gaps" (1)), and messages in the host's language, a change redrawing -// (decision "Messages and languages in the app kit"). +// (decision "Messages and languages in the app kit"), and an optimistic value shown while a call is pending (decision +// "A call's answer follows its state"). import { describe, expect, it } from "vitest"; import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts"; @@ -92,6 +93,62 @@ describe("a component's scope", () => { }); }); +describe("a scope's optimistic values", () => { + it("show the value while its call is pending, then the reader's, calling changed on each change", async () => { + const h = await host(); + let changes = 0; + const scope = new TuiScope(h, () => changes++); + const notes = scope.hold(Notes, {}); + const [text, show] = scope.optimistic(scope.watch(notes.text)); + await until(() => text() === "first"); + const before = changes; + const written = show("second", notes.write("second", context)); + expect(text()).toBe("second"); + expect(changes).toBe(before + 1); + await written; + await until(() => changes > before + 1); + expect(text()).toBe("second"); + scope.dispose(); + await h.close(context); + }); + + it("show the last value until every call settled; a rejected call drops it as a resolved one does", async () => { + const h = await host(); + const calls: string[] = []; + const scope = new TuiScope(h, () => calls.push(text()!)); + const [text, show] = scope.optimistic(() => "real"); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + show("one", first.promise); + const failing = show("two", second.promise); + first.resolve(null); + await first.promise; + await Promise.resolve(); + expect(text()).toBe("two"); + second.reject(new Error("refused")); + await expect(failing).rejects.toThrow("refused"); + await Promise.resolve(); + expect(calls).toEqual(["one", "two", "two", "real"]); + scope.dispose(); + await h.close(context); + }); + + it("never call changed after dispose", async () => { + const h = await host(); + let changes = 0; + const scope = new TuiScope(h, () => changes++); + const [, show] = scope.optimistic(() => "real"); + const call = Promise.withResolvers(); + show("shown", call.promise); + scope.dispose(); + call.resolve(null); + await call.promise; + await Promise.resolve(); + expect(changes).toBe(1); + await h.close(context); + }); +}); + describe("a scope's page state", () => { it("is the host's: a scope opened after another was disposed (a reload) finds its value, a new key starts fresh", async () => { const h = await host();