From c85e3c6f7a1d2b08c923206e0587a06484bed0f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:58:38 +0000 Subject: [PATCH] Add onSettled (handler form) and onFlush as language functions (EF-7, EF-8) onSettled(() => B) in imperative scope runs B after the change the calling run belongs to commits; onFlush(() => B) runs B as its own instant right after the current one, so a write can stay out of another write's hold. Both are Solid's own functions; in reactive scope they are IO_IN_REACTIVE (the owned onSettled is effect () { ... }), a non-closure argument is HOOK_CALLBACK, and an onSettled cleanup return is SETTLED_CLEANUP_UNOWNED at compile time. Scenarios S-flush-1 and S-settled-1 pass every runner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNyLJmSs7V2omSfCrh --- ref/compile/emit.ts | 1 + ref/compile/rt-server.ts | 2 ++ ref/compile/rt-web.ts | 4 ++- ref/compile/rt.ts | 4 ++- ref/compile/run.ts | 2 +- ref/oracle/lower.ts | 3 +- ref/oracle/run.ts | 4 +-- ref/src/check.ts | 16 +++++++++ ref/src/runtime.ts | 30 ++++++++++++++++- ref/src/scenario.ts | 2 +- ref/ssr/run.ts | 2 +- ref/types/project.ts | 4 ++- scenarios/S-flush-1.md | 73 ++++++++++++++++++++++++++++++++++++++++ scenarios/S-settled-1.md | 64 +++++++++++++++++++++++++++++++++++ spec/00-vocabulary.md | 4 ++- spec/02-core.md | 36 +++++++++++++++++++- spec/06-stores.md | 4 +-- spec/07-surface.md | 6 +++- spec/99-diagnostics.md | 5 ++- 19 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 scenarios/S-flush-1.md create mode 100644 scenarios/S-settled-1.md diff --git a/ref/compile/emit.ts b/ref/compile/emit.ts index a7b9859..2308160 100644 --- a/ref/compile/emit.ts +++ b/ref/compile/emit.ts @@ -423,6 +423,7 @@ class Gen { case "stylemap": return this.classMap(e.css, e.line); // 07 CSS-5 case "call": { if (e.f.k === "id" && e.f.name === "until" && !env.lookup("until") && e.args[0]?.k === "fn") { const p = e.args[0]; return `__rt.until(() => ${this.expr(p.body, env, { imp: false, dyn: ctx.dyn })})`; } // AC-6: Solid's until; the predicate is reactive scope + if (e.f.k === "id" && (e.f.name === "onSettled" || e.f.name === "onFlush") && !env.lookup(e.f.name)) return `__rt.${e.f.name}(${e.args.map(go).join(", ")})`; // EF-7, EF-8: Solid's own if (e.f.k === "member") return `${go(e.f.o)}?.${e.f.p}(${e.args.map(go).join(", ")})`; return `${go(e.f)}(${e.args.map(go).join(", ")})`; } diff --git a/ref/compile/rt-server.ts b/ref/compile/rt-server.ts index 0fb587b..fb63ec6 100644 --- a/ref/compile/rt-server.ts +++ b/ref/compile/rt-server.ts @@ -187,6 +187,8 @@ export function createApp(opts: { api?: Record unknown> hoisted(_name: string, _get: Acc) {}, refresh: onServer("refresh"), affects: onServer("affects"), + onSettled: onServer("onSettled"), // EF-7: imperative scope only, and no imperative run happens here (SV-2a) + onFlush: onServer("onFlush"), // EF-8 preOr: (v: unknown, init: () => unknown) => v === undefined ? init() : v, module(key: string, factory: (rt: unknown) => Record) { let m = modules.get(key); if (!m) { m = factory(app); modules.set(key, m); } return m; }, diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index 36766cb..cec91d7 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -7,7 +7,7 @@ import { getObserver } from "solid-js"; import { createSignal, createMemo, createEffect, createOptimistic, createOptimisticStore, createStore, createProjection, createDeferred, createErrorBoundary, createContext, Show, For, Loading, Errored, createComponent, - flush, untrack, latest, isPending, action, refresh, affects, until, NotReadyError, getOwner, runWithOwner, sharedConfig, + flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, getOwner, runWithOwner, sharedConfig, } from "solid-js"; import { getContext, setContext, $TARGET } from "@solidjs/signals"; import { hydrate, render } from "@solidjs/web"; @@ -230,6 +230,8 @@ export function createApp(opts: { root: any; api?: Record unknown) => until(p), // AC-6 + onSettled: (f: () => void) => onSettled(f), // EF-7 + onFlush: (f: () => void) => onFlush(f), // EF-8 refresh(name: string, get: Acc) { const key = lastKey.get(name); if (key) flights.delete(key); for (const h of hoistedBy.get(name) ?? []) refresh(h as any); refresh((storeAccs.has(get) ? get() : get) as any); }, affects: (get: Acc) => affects(get as any), preOr: (v: unknown, init: () => unknown) => v === undefined ? init() : v, diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index 80a9194..65bb7e1 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -8,7 +8,7 @@ import { getObserver } from "@solidjs/signals"; import { Show, For, Loading, Errored } from "solid-js"; import { createSignal, createMemo, createEffect, createRenderEffect, createRoot, createOptimistic, createOptimisticStore, createStore, createProjection, createDeferred, - flush, untrack, latest, isPending, action, refresh, affects, until, NotReadyError, createContext, getContext, setContext, getOwner, runWithOwner, $TARGET, createErrorBoundary, + flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, createContext, getContext, setContext, getOwner, runWithOwner, $TARGET, createErrorBoundary, } from "@solidjs/signals"; export { flush }; @@ -238,6 +238,8 @@ export function createApp(opts: { api?: Record unknown> }, hoisted(name: string, get: Acc) { let l = hoistedBy.get(name); if (!l) hoistedBy.set(name, (l = [])); l.push(get); }, until: (p: () => unknown) => until(p), // AC-6 + onSettled: (f: () => void) => onSettled(f), // EF-7 + onFlush: (f: () => void) => onFlush(f), // EF-8 refresh(name: string, get: Acc) { const key = lastKey.get(name); if (key) flights.delete(key); for (const h of hoistedBy.get(name) ?? []) refresh(h as any); refresh((storeAccs.has(get) ? get() : get) as any); }, affects: (get: Acc) => affects(get as any), preOr: (v: unknown, init: () => unknown) => v === undefined ? init() : v, diff --git a/ref/compile/run.ts b/ref/compile/run.ts index 9c5feea..b58f46e 100644 --- a/ref/compile/run.ts +++ b/ref/compile/run.ts @@ -24,7 +24,7 @@ async function runTable(sc: Scenario, t: Scenario["tables"][number], base: Scena const src = t.program ?? sc.program; let rows = t.rows; let own = 0; if (t !== base && base && rows.length && rows[0].instant > 0) { const pre = base.rows.filter(r => r.instant < rows[0].instant); own = pre.length; rows = [...pre, ...rows]; } - const chains: typeof rows[] = []; for (const r of rows) { if (r.action === "(effect)" && chains.length) chains[chains.length - 1].push(r); else chains.push([r]); } + const chains: typeof rows[] = []; for (const r of rows) { if ((r.action === "(effect)" || r.action === "(flush)") && chains.length) chains[chains.length - 1].push(r); else chains.push([r]); } const handlerStmts = rows.filter(r => r.action.startsWith("handler")).map(r => { try { return parseStmts(r.action.slice(7)); } catch { return []; } }); resetErrorHalt(); let out: ReturnType; diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index c3e3a09..9701c14 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -9,7 +9,7 @@ import { Show, For, Loading, Errored } from "solid-js"; import { getObserver } from "@solidjs/signals"; import { createSignal, createMemo, createEffect, createOptimistic, createOptimisticStore, createStore, createProjection, createDeferred, - createRoot, createRenderEffect, flush, untrack, latest, isPending, action, refresh, affects, until, NotReadyError, resetErrorHalt, createContext, getContext, setContext, getOwner, runWithOwner, $TARGET, createErrorBoundary + createRoot, createRenderEffect, flush, untrack, latest, isPending, action, refresh, affects, until, onSettled, onFlush, NotReadyError, resetErrorHalt, createContext, getContext, setContext, getOwner, runWithOwner, $TARGET, createErrorBoundary } from "@solidjs/signals"; const noFlight = (v: unknown) => { if (v instanceof Flight || v instanceof Promise) throw new Unsupported("`await` not in tail position (the oracle lowers a tail `await` to the memo's result)"); }; @@ -416,6 +416,7 @@ export class Oracle { case "call": { let f: any, self: any; if (e.f.k === "id" && e.f.name === "until" && !env?.has("until") && !scope.lookup("until")) { const p = e.args[0]; if (p?.k !== "fn") throw new Error("UNTIL_PREDICATE"); return until(() => this.ev(p.body, scope, env)); } // AC-6: Solid's until + if (e.f.k === "id" && (e.f.name === "onSettled" || e.f.name === "onFlush") && !env?.has(e.f.name) && !scope.lookup(e.f.name)) { const cb = this.ev(e.args[0], scope, env) as () => void; return (e.f.name === "onSettled" ? onSettled : onFlush)(cb); } // EF-7, EF-8: Solid's own if (e.f.k === "member") { self = this.ev(e.f.o, scope, env); f = self == null ? undefined : self[e.f.p]; } else f = this.ev(e.f, scope, env); const args = e.args.map(a => this.ev(a, scope, env)); if (f === BUILTINS.fetch) { const fl = this.flight(String(args[0])); return process.env.ORACLE_PROMISES ? fl.first : fl; } diff --git a/ref/oracle/run.ts b/ref/oracle/run.ts index 7b416a1..2388da2 100644 --- a/ref/oracle/run.ts +++ b/ref/oracle/run.ts @@ -17,8 +17,8 @@ async function runTable(sc: Scenario, t: Scenario["tables"][number], base: Scena const src = t.program ?? sc.program; let rows = t.rows; let own = 0; if (t !== base && base && rows.length && rows[0].instant > 0) { const pre = base.rows.filter(r => r.instant < rows[0].instant); own = pre.length; rows = [...pre, ...rows]; } - // chains: a row followed by its `(effect)` rows is one flush here - const chains: typeof rows[] = []; for (const r of rows) { if (r.action === "(effect)" && chains.length) chains[chains.length - 1].push(r); else chains.push([r]); } + // chains: a row followed by its `(effect)` and `(flush)` rows (EF-8) is one step here + const chains: typeof rows[] = []; for (const r of rows) { if ((r.action === "(effect)" || r.action === "(flush)") && chains.length) chains[chains.length - 1].push(r); else chains.push([r]); } let o: Oracle; resetErrorHalt(); (await import("./lower.ts")).resetScopes(); Unsupported.last = ""; const handlerStmts = rows.filter(r => r.action.startsWith("handler")).map(r => { try { return parseStmts(r.action.slice(7)); } catch { return []; } }); diff --git a/ref/src/check.ts b/ref/src/check.ts index bc87955..82a3bf9 100644 --- a/ref/src/check.ts +++ b/ref/src/check.ts @@ -45,6 +45,13 @@ const noFails = (): Fails => ({ cls: new Set(), props: [] }); export const keyName = (k: string) => k.split("#").pop()!.replace(/@\d+$/, ""); const addF = (a: Fails, b: Fails) => { for (const x of b.cls) a.cls.add(x); a.props.push(...b.props); return a; }; const catchOf = (classes?: string[]): Catch => classes ? new Set(classes) : "all"; +/** EF-5's cleanup shapes: a `return` of a closure anywhere in the body, a closure as its last expression, or an expression body that is one. */ +function returnsClosure(f: Extract): boolean { + const inStmts = (ss: Stmt[]): boolean => ss.some(s => (s.k === "return" && s.e?.k === "fn") || (s.k === "sif" && (inStmts(s.t) || inStmts(s.f))) || (s.k === "try" && (inStmts(s.body) || inStmts(s.handler ?? []) || inStmts(s.fin ?? [])))); + if (!f.stmts) return f.body.k === "fn"; + const last = f.stmts[f.stmts.length - 1]; + return inStmts(f.stmts) || (last?.k === "expr" && last.e.k === "fn"); +} /** A short rendering of an expression, for messages. */ function show(e: Expr): string { switch (e.k) { @@ -608,6 +615,15 @@ class Checker { this.expr(p, env, { scope: "reactive", server: ctx.server }); // the predicate is reactive scope: it reads, tracked; it does not write return; } + if (e.f.k === "id" && (e.f.name === "onSettled" || e.f.name === "onFlush") && !env.lookup(e.f.name)) { // EF-7, EF-8: Solid's onSettled / onFlush, functions of the language + const name = e.f.name; + if (ctx.scope === "reactive") this.err("IO_IN_REACTIVE", name === "onSettled" ? "EF-7" : "EF-8", name === "onSettled" ? "onSettled(…) schedules work from imperative scope (a handler, an apply block, an action); in a component body the mount hook is `effect () { … }` (EF-4)" : "onFlush(…) is called from imperative scope (a handler, an apply block, an action): it starts a run of its own after this flush"); + const p = e.args[0]; + if (e.args.length !== 1 || !p || p.k !== "fn" || p.params.length) { this.err("HOOK_CALLBACK", name === "onSettled" ? "EF-7" : "EF-8", `${name} takes a closure, \`${name}(() => { … })\`: its body is the run it schedules`); e.args.forEach(go); return; } + if (name === "onSettled" && returnsClosure(p)) this.err("SETTLED_CLEANUP_UNOWNED", "EF-7", "an onSettled callback returns a cleanup, but it has no region to bind it to: setup with teardown is `effect () { … }` (EF-4, EF-5)"); + this.expr(p, env, { scope: "imperative", server: ctx.server }); // the callback is imperative scope + return; + } if (e.f.k === "id") { const s = env.lookup(e.f.name); if (ctx.scope === "reactive" && (s?.io || (!s && e.f.name === "log"))) { diff --git a/ref/src/runtime.ts b/ref/src/runtime.ts index 8b9d04b..e2e748d 100644 --- a/ref/src/runtime.ts +++ b/ref/src/runtime.ts @@ -154,6 +154,8 @@ export class Runtime { private started: Run[] = []; // runs started by the current imperative run, not yet assigned a change private currentRun: Run | null = null; private pendingJoin: number[] = []; private pendingBodyEnds: number[] = []; + private pendingFlush: (() => unknown)[] = []; // EF-8: onFlush callbacks; one run after the flush that registered them + private pendingSettled: { f: () => unknown; change: number | null }[] = []; // EF-7: onSettled callbacks, by the change their run belongs to (null: the next instant opened) private pendingGuesses: { node: Node; path: string[]; value: unknown }[] = []; private pendingRefresh: Node[] = []; private pendingAffects: Node[] = []; program: Program; @@ -229,7 +231,17 @@ export class Runtime { step(writes: Write[]): { frame: string; effects: string[] }[] { const out: { frame: string; effects: string[] }[] = []; let w = writes; - do { this.instant++; out.push(this.runInstant(w)); w = this.pendingWrites; this.pendingWrites = []; } while (w.length && w.some(x => this.effective(x))); // W-3: an apply's writes that are all equal open no instant + for (;;) { + do { this.instant++; out.push(this.runInstant(w)); w = this.pendingWrites; this.pendingWrites = []; } while (w.length && w.some(x => this.effective(x))); // W-3: an apply's writes that are all equal open no instant + const logBefore = this.log.length; + const stray = this.pendingSettled.filter(s => s.change === null); this.pendingSettled = this.pendingSettled.filter(s => s.change !== null); + for (const s of stray) this.fireSettled(s.f); // EF-7: registered by a run whose writes opened no instant — it fires now, with this one + const fs = this.pendingFlush; this.pendingFlush = []; + for (const f of fs) f(); // EF-8: the flush is done; its onFlush callbacks are one run, a new instant + out[out.length - 1].effects.push(...this.log.slice(logBefore)); + w = this.pendingWrites; this.pendingWrites = []; + if (!w.length || !w.some(x => this.effective(x))) break; + } return out; } /** Whether a write would change its slot (E-6 per path; a functional write is applied to the current value, W-6). */ @@ -254,6 +266,7 @@ export class Runtime { for (const n of this.allNodes()) { n.changed = false; n.demanded = false; n.evaluated = false; } for (const c of this.pendingJoin) this.union(this.instant, c); this.pendingJoin = []; // a continuation's instant is the action's change for (const r of this.started) r.change = this.instant; this.started = []; + for (const s of this.pendingSettled) if (s.change === null) s.change = this.instant; // EF-7: the change the registering run's writes open (or are adopted into) const queue = [...writes]; for (let qi = 0; ; qi++) { if (qi >= queue.length) { // AC-6: a write (or landing) that flips an until joins its action's change; the action resumes in this instant @@ -363,6 +376,9 @@ export class Runtime { if (typeof r === "function") e.cleanup = r as () => void; } this.effects = this.effects.filter(e => e.alive); + const due = this.pendingSettled.filter(s => s.change !== null && s.change <= this.instant && !this.openChanges.has(this.find(s.change))); // EF-7: its change has committed + this.pendingSettled = this.pendingSettled.filter(s => !due.includes(s)); + for (const s of due) this.fireSettled(s.f); return { frame, effects: this.log.slice(logBefore) }; } @@ -851,6 +867,7 @@ export class Runtime { case "call": { let f: unknown, self: unknown; if (e.f.k === "id" && e.f.name === "until" && !env?.has("until") && !region.lookup("until")) return this.until(e.args[0], region, env); // AC-6 + if (e.f.k === "id" && (e.f.name === "onSettled" || e.f.name === "onFlush") && !env?.has(e.f.name) && !region.lookup(e.f.name)) return this.hook(e.f.name, e.args[0], region, env); // EF-7, EF-8 if (e.f.k === "member") { self = this.ev(e.f.o, region, env); if (isAbsent(self)) return self; f = self == null ? undefined : (self as any)[e.f.p]; if (isObj(self)) this.track(self, e.f.p); } // a method call: `xs.map(f)`, TS verbatim else f = this.ev(e.f, region, env); const args = e.args.map(a => this.ev(a, region, env)); const bad = args.find(isAbsent); if (isAbsent(f)) return f; @@ -897,6 +914,17 @@ export class Runtime { }; } /** A flight's answer: the nodes that asked it recompute, the runs waiting on it resume. */ + // ---- onSettled, onFlush (EF-7, EF-8; Solid's) ---- + private hook(name: "onSettled" | "onFlush", arg: Expr | undefined, region: Region, env?: Map): undefined { + if (this.demandMode) throw new RuleError("IO-2", `${name} in reactive scope`); + if (!arg || arg.k !== "fn" || arg.params.length) throw new RuleError("HOOK_CALLBACK", `${name} takes a closure, \`${name}(() => { … })\``); + const f = this.ev(arg, region, env ?? new Map()) as () => unknown; + if (name === "onFlush") this.pendingFlush.push(f); + else this.pendingSettled.push({ f, change: this.currentRun?.change ?? null }); + return undefined; + } + private fireSettled(f: () => unknown) { if (typeof f() === "function") throw new RuleError("SETTLED_CLEANUP_UNOWNED", "an onSettled callback returned a cleanup: there is no region to bind it to (EF-7)"); } + // ---- until (AC-6; Solid D-31) ---- private untils: { flight: Flight; pred: Expr; region: Region; env?: Map }[] = []; private authoritative = false; diff --git a/ref/src/scenario.ts b/ref/src/scenario.ts index 158deea..c018b56 100644 --- a/ref/src/scenario.ts +++ b/ref/src/scenario.ts @@ -117,7 +117,7 @@ export function runScenario(sc: Scenario): Result { let got: { frame: string; effects: string[] } | undefined; try { if (r.action === "mount") { pending = rt.step([]); got = pending.shift(); } - else if (r.action === "(effect)") got = pending.shift(); + else if ((r.action === "(effect)" || r.action === "(flush)")) got = pending.shift(); else if (r.action.startsWith("handler")) { pending = rt.handler(parseStmts(r.action.slice(7))); got = pending.shift(); } else if (/^(resolve|reject)\s/.test(r.action)) { const m = /^(resolve|reject)\s+(\S+)\s+([\s\S]*)$/.exec(r.action)!; diff --git a/ref/ssr/run.ts b/ref/ssr/run.ts index d0f6b7a..91d9ed3 100644 --- a/ref/ssr/run.ts +++ b/ref/ssr/run.ts @@ -267,7 +267,7 @@ async function runTable(sc: Scenario, t: Scenario["tables"][number], base: Scena const src = t.program ?? sc.program; let rows = t.rows; let own = 0; // own: rows before this index are the base's, replayed to drive state if (t !== base && base && rows.length && rows[0].instant > 0) { const pre = base.rows.filter(r => r.instant < rows[0].instant); own = pre.length; rows = [...pre, ...rows]; } - const chains: Row[][] = []; for (const r of rows) { if (r.action === "(effect)" && chains.length) chains[chains.length - 1].push(r); else chains.push([r]); } + const chains: Row[][] = []; for (const r of rows) { if ((r.action === "(effect)" || r.action === "(flush)") && chains.length) chains[chains.length - 1].push(r); else chains.push([r]); } const cf = clientFns(sc, src); // SV-7b: a client question's landing is the browser's — the stream ends before it const upTo = chains.findIndex(c => !(c[0].action === "mount" || (/^(resolve|reject)\s/.test(c[0].action) && !isClientRow(c[0], cf)))); const served = upTo < 0 ? chains : chains.slice(0, upTo); diff --git a/ref/types/project.ts b/ref/types/project.ts index 1932c4d..4d6b990 100644 --- a/ref/types/project.ts +++ b/ref/types/project.ts @@ -12,7 +12,7 @@ import type { JsDecl } from "../src/parse.ts"; import type { ModSig } from "../src/check.ts"; const ROW_WORDS = /^((?:io|pending|lane|fails\([^)]*\))\s+)+/; -const BUILTINS = new Set(["len", "str", "map", "filter", "sum", "fail", "fetch", "every", "log", "until"]); +const BUILTINS = new Set(["len", "str", "map", "filter", "sum", "fail", "fetch", "every", "log", "until", "onSettled", "onFlush"]); const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]); const isHandler = (a: string) => /^on[A-Z:]/.test(a); const J = (v: unknown) => v === undefined ? "undefined" : JSON.stringify(v); @@ -55,6 +55,8 @@ export declare function effect(f: () => void | (() => void)): void; export declare function draft(seed: T, f: (d: T) => void): T; export declare function len(x: unknown): number; export declare function until(p: () => T): Promise>; // AC-6: Solid's until — the first truthy value +export declare function onSettled(f: () => void): void; // EF-7: imperative scope only, so no cleanup return +export declare function onFlush(f: () => void): void; // EF-8 export declare function str(x: unknown): string; export declare function map(xs: X, f: (x: ElemOf, i: number) => U): U[]; export declare function filter(xs: X, f: (x: ElemOf) => unknown): ElemOf[]; diff --git a/scenarios/S-flush-1.md b/scenarios/S-flush-1.md new file mode 100644 index 0000000..751071c --- /dev/null +++ b/scenarios/S-flush-1.md @@ -0,0 +1,73 @@ +# S-flush-1 — `onFlush`: a write that keeps out of another update's hold + +Rules exercised: EF-8, W-3, H-2, AC-1; Solid `onFlush` (solidjs/solid#4292). + +## Program + +``` +export default component App() { + source open := true + source page := 1 + details = await fetch("p" + str(page)) + +
+ {if open {

menu:

} else {

closed:

}} + {loading fallback {

loading

} {

{details catch (e) => "failed"}

}} +
+} +``` + +A menu that closes when an item is picked, while the pick loads the next +page. The handler is the menu's `select`: it closes the menu and asks for a +new page in one run. + +## Frames + +| instant | action | frame | +| ------- | --------------------------------------------------------- | ---------- | +| 0 | mount | `menu:loading` | +| 1 | resolve details "A" | `menu:A` | +| 2 | handler `onFlush(() => { open := false }); page := 2` | `menu:A` | +| 3 | (flush) | `closed:A` | +| 4 | resolve details "B" | `closed:B` | + +Instant 2: `page := 2` asks a new question; the disarmed boundary forwards +`details`'s reader into the hold (H-6), so the instant is held and the +screen keeps `menu:A`. Instant 3: the flush is done, and the `onFlush` +callback is a run of its own (EF-8), so `open := false` is a new instant. +Nothing it changes derives from the held work, so it commits at once: the +menu closes while the page is still loading. Instant 4: the hold commits. + +## Variant: one run, one hold + +| instant | action | frame | +| ------- | ------------------------------------------- | ---------- | +| 2 | handler `open := false; page := 2` | `menu:A` | +| 3 | resolve details "B" | `closed:B` | + +Without `onFlush`, both writes are one instant (W-3) and the menu stays open +until the page lands. + +## Rejected variants + +``` +-- reject: IO_IN_REACTIVE +export default component App() { + source open := true + closer = onFlush(() => { open := false }) +

{open}

+} +``` + +`onFlush` schedules a run; it is called from imperative scope (Solid warns +with `ON_FLUSH_IN_OWNED_SCOPE`; here it is a compile error). + +``` +-- reject: HOOK_CALLBACK +export default component App() { + source open := true + +} +``` + +The argument is the run to schedule: a closure, `() => { … }`. diff --git a/scenarios/S-settled-1.md b/scenarios/S-settled-1.md new file mode 100644 index 0000000..a47b2cd --- /dev/null +++ b/scenarios/S-settled-1.md @@ -0,0 +1,64 @@ +# S-settled-1 — `onSettled` from a handler: after the change it started commits + +Rules exercised: EF-7, EF-4, AC-1, H-2; Solid `onSettled` (unowned form). + +## Program + +``` +export default component App() { + source status := "idle" + action save() { + await fetch("save") catch (e) => null + status := "saved" + } +

{status}

+} +``` + +Solid's own example: a save button whose handler launches an action and +schedules a toast for when it is done. + +## Frames + +| instant | action | frame | effect | +| ------- | -------------------------------------------------------- | ------- | -------------------------- | +| 0 | mount | `idle` | | +| 1 | handler `save(); onSettled(() => log("toast", status))` | `idle` | | +| 2 | resolve "save" 0 | `saved` | `log(toast, saved)` | + +Instant 1: the handler's run is the action's change (AC-1), so the callback +waits for that change. Instant 2: the action's body ends, the change +commits, and the callback runs after it, reading the committed state. + +## Variant: nothing held + +| instant | action | frame | effect | +| ------- | ------------------------------------------------------------ | ------ | --------------------- | +| 1 | handler `status := "x"; onSettled(() => log("toast", status))` | `x` | `log(toast, x)` | + +A change that nothing holds commits at once, and the callback runs after it +in the same instant. + +## Rejected variants + +``` +-- reject: IO_IN_REACTIVE +export default component App() { + source n := 0 + ready = onSettled(() => log(n)) +

{n}

+} +``` + +In a component body the mount hook is `effect () { … }` (EF-4). + +``` +-- reject: SETTLED_CLEANUP_UNOWNED +export default component App() { + source n := 0 + +} +``` + +A callback scheduled from imperative scope has no region to bind a cleanup +to; setup with teardown is `effect () { … return () => { … } }` (EF-5). diff --git a/spec/00-vocabulary.md b/spec/00-vocabulary.md index e2df651..6864ac3 100644 --- a/spec/00-vocabulary.md +++ b/spec/00-vocabulary.md @@ -36,7 +36,8 @@ the committed state (V-13). Its writes begin the next instant. An effect is not a reader (V-4): its compute half holding a pending value does not hold the screen. -Solid: `createEffect(compute, apply)`; an apply half with no inputs is `onSettled`. +Solid: `createEffect(compute, apply)`; an apply half with no inputs is `onSettled` +(its owned form; the form called from a handler is 02 EF-7). ## V-4. Reader [Solid: reader] @@ -63,6 +64,7 @@ writes reach, and ends by committing. Several writes in one imperative run are one instant. Nothing observes a half-settled instant. Solid: a flush; "several writes in one synchronous tick are one change." +`onFlush` (02 EF-8) schedules a run of its own after one. ## V-7. Hold [Solid: hold] diff --git a/spec/02-core.md b/spec/02-core.md index a7d0077..559d563 100644 --- a/spec/02-core.md +++ b/spec/02-core.md @@ -179,7 +179,8 @@ Function application `f(e…)` covers both language functions and FFI imports; semantic one. - **W-3 (Instant).** All writes performed by one imperative run — one handler invocation, one apply block, one resumed continuation — form one instant - (V-6). There is no API to split or merge instants from inside a run. A + (V-6). There is no API to split or merge instants from inside a run: + `onFlush` (EF-8) does not split one, it schedules another run. A run whose writes all equal their slots' values (E-6) changes nothing and opens no instant (ruled with DV-5, 2026-10-23: the earlier "an equal write still opens an instant" existed only for the frame-indexed `pre`). @@ -282,6 +283,39 @@ Function application `f(e…)` covers both language functions and FFI imports; the instant runs before any apply block of that instant (O-2), so `log(leave, 1); log(enter, 2)` across a step change is a legal expectation (S-ai-wizard). +- **EF-7 (onSettled).** [taken by default 2026-10-27] `onSettled(() => B)`, + called in imperative scope, schedules `B` to run once, as an imperative + run of its own, in the observe phase of the instant in which the change + the calling run belongs to commits: the change its writes open, or the + change that adopts them (a handler that launches an action: AC-1). A + run whose writes open no instant (an apply block that writes nothing) + has its callbacks run at the end of the current instant. `B` reads the + committed state (SC-2); its writes begin the next instant (EF-3). It is + Solid's `onSettled` in its unowned form: "schedule work to run after the + action / transition triggered by the event has completed" (S-settled-1). + The owned form, in a component body, is `effect () { … }` (EF-4), so in + reactive scope `onSettled` is `IO_IN_REACTIVE`. `B` has no region, so a + cleanup return (EF-5's shapes) is `SETTLED_CLEANUP_UNOWNED`, a compile + error (Solid: a dev-mode throw). The argument is a closure with no + parameters (`HOOK_CALLBACK`). Like `until` (AC-6) it is a function of + the language, not syntax: a binding named `onSettled` shadows it. +- **EF-8 (onFlush).** [taken by default 2026-10-27] `onFlush(() => B)`, + called in imperative scope, schedules `B` to run once, as an imperative + run of its own, right after the instant the calling run opens and the + instants its apply blocks' writes go on to open (EF-3). It never waits + on a hold: the callback runs even while that instant is held. Every `onFlush` callback registered in one step + runs in call order as one instant. Its use is keeping a write out of + another write's hold: `B`'s writes are a new change, which commits on its + own unless it joins a hold the way any later change does (H-2, D-8; a + write to a held slot, or a reader of both). So `onFlush(() => { open := + false }); page := 2` closes a menu now while the page loads (S-flush-1), + where `open := false; page := 2` is one instant and waits for it (W-3). + A write in `B` lands after the run's own writes; writing one slot in + both is legal and composes in that order (W-6). In reactive scope it is + `IO_IN_REACTIVE` (Solid: the `ON_FLUSH_IN_OWNED_SCOPE` warning); the + argument is a closure with no parameters (`HOOK_CALLBACK`); a binding + named `onFlush` shadows it. Solid: `onFlush` (solidjs/solid#4292); a + no-op on the server, where no imperative run happens (10 SV-2a). ### V — Views diff --git a/spec/06-stores.md b/spec/06-stores.md index dd2aa91..44dae9a 100644 --- a/spec/06-stores.md +++ b/spec/06-stores.md @@ -120,8 +120,8 @@ selected = draft(pre selected -> {}) { d[id] := true; d[prevId] := false } test asked). The handler that launches it is the same instant, so the handler's own plain writes — before or after the call — are the action's and are held with it - (D-7's same-tick adoption); to land something now, make it a guess or a - separate event. + (D-7's same-tick adoption); to land something now, make it a guess, a + separate event, or an `onFlush` run (02 EF-8). `action { … }` inline in a handler is the same for one run. There is no escape: a write after an `await` is still the action's (the language has the scope, so none of 2.0's generator plumbing — S-9's "fresh write diff --git a/spec/07-surface.md b/spec/07-surface.md index 13e5d73..ad2dbc7 100644 --- a/spec/07-surface.md +++ b/spec/07-surface.md @@ -228,7 +228,11 @@ action { refresh user; affects user } // inline, one run (AC-1) - A handler that launches an action and also writes (`() => { draft := ""; send() }`) makes one change: the handler's plain writes are adopted by the action and held with it (AC-1). Writes that should land now go in a - separate instant (a separate event), or are guesses. + separate instant (a separate event, or `onFlush(() => { draft := "" })`: + 02 EF-8), or are guesses. +- `onSettled(() => { … })` runs a block once the change this run belongs + to has committed (02 EF-7): `() => { save(); onSettled(() => toast("Saved")) }`. + In a component body the same thing is `effect () { … }` (EF-4). - The optimistic-action idiom, with a spinner: ``` diff --git a/spec/99-diagnostics.md b/spec/99-diagnostics.md index 3aa16ba..6d5a504 100644 --- a/spec/99-diagnostics.md +++ b/spec/99-diagnostics.md @@ -37,7 +37,8 @@ Bins: | `NO_OWNER_CLEANUP` | U | same | | `NO_OWNER_BOUNDARY` | U | same | | `RUN_WITH_DISPOSED_OWNER` | S | O-3: its static form is `REGION_ESCAPE`; the runtime error stays the JS-boundary backstop (CN-3b) | -| `SETTLED_CLEANUP_UNOWNED` | U | EF-4/EF-5 with O | +| `SETTLED_CLEANUP_UNOWNED` | S | EF-7: an `onSettled` callback that returns a cleanup (EF-5's shapes); `effect () { … }` is the owned form (EF-4) | +| `ON_FLUSH_IN_OWNED_SCOPE` | S | EF-8: `onFlush` in reactive scope is `IO_IN_REACTIVE`, an error (Solid: a dev warning) | | `SNAPSHOT_ARGUMENT` (new) | S | E-10: reactive argument to a value parameter at an instantiation | | `SNAPSHOT_INITIALIZER` (new) | S | E-4 / DV-6: a source initializer reads a reactive value outside `peek(…)` | | `INVALID_REFRESH_TARGET` | S | `refresh` requires a source with a question (V-9) | @@ -122,6 +123,8 @@ named. | `CLIENT_NOT_AWAITED` | S | 12 SF-5b | S-server-3 | | `STYLE_PLACEMENT` | S | 07 CSS-1: a `