diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ef6e4..72ea93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +**Fixed** + +- `affects` on a keyed or structured source that takes a guess threw `INVALID_AFFECTS_TARGET` in a dev build, so the + action never reached its `await`, and did nothing in a production build, so `pending?` stayed false (#50, + S-affects-keyed). +- A keyed source that takes a guess (`source rows key id = q.rows`) merges each answer by key instead of replacing every + row, so a row's readers re-run only when its own leaves change (#51). +- The guide's action example (`guide/async.md` §3) lost an error thrown before the first `await`: the `onFlush` clear + ran after the catch. The catch now writes the error through `onFlush` as well (#52). + ## 0.3.1 **New** diff --git a/guide/async.md b/guide/async.md index 4105212..5465243 100644 --- a/guide/async.md +++ b/guide/async.md @@ -72,10 +72,16 @@ export action save(next: Profile) { profile := answer // the truth } catch (e) { saveError := e.message // lands in the same frame as the revert + onFlush(() => { saveError := e.message }) // and stays, if the body failed before its first await } } ``` +Write the error both ways. A failure after the `await` lands in the action's last instant, with the revert, and the +`onFlush` run then writes the same value, which changes nothing. A failure before the first `await` (a `throw`, or a +call that throws) lands in the action's first instant, so the clear above runs after it and would erase it; the second +`onFlush` runs after the clear, in the same instant (EF-8: one step's callbacks run in call order), and puts it back. + A handler can be the action itself, with no keyword: an arrow that writes `guess`, declares `affects`, or both writes and awaits is an action (AC-1b). Declare a named `action` when you call it from more than one place. diff --git a/ref/compile/rt-server.ts b/ref/compile/rt-server.ts index 93f0647..2ecf15b 100644 --- a/ref/compile/rt-server.ts +++ b/ref/compile/rt-server.ts @@ -136,7 +136,7 @@ export function createApp(opts: { api?: Record unknown> }, question(name: string, compute: (prev: unknown) => unknown, o: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); - if (mode.optimistic && mode.structured) { const [store] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined); return ro(() => store); } + if (mode.optimistic && mode.structured) { const [store] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined); return ro(() => store); } if (mode.optimistic && mode.derived) { const [get] = createOptimistic(fn as any, o as any); return ro(get as Acc); } // AC-3 over an equation: the client's createOptimistic(fn), so the owner trees (and hydration keys) match if (mode.optimistic) { const [truth] = createSignal(fn as any, o as any); const [get] = createOptimistic(() => (truth as Acc)()); return ro(get as Acc); } if (mode.structured) { @@ -241,6 +241,21 @@ export function createApp(opts: { api?: Record unknown> } export type ServerApp = ReturnType; +/** A keyed answer is merged into the draft by key: a row whose key is still there keeps its object and takes the new + * leaves, so an equal answer changes nothing and a changed one changes only its leaves (#51). Any other answer is + * assigned. + * TODO(Solid rc.22): return the keyed answer from the derive and let Solid reconcile it, dropping this merge. On + * 2.0.0-rc.21 that path throws in holdStaging (memo over latest() of the rows); fixed on Solid next (dba2aad). */ +function landDraft(draft: any, v: unknown, key: string | undefined) { + if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v); + const had = new Map(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row); + v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +function mergeLeaves(o: any, v: any) { + for (const k of Object.keys(o)) if (!(k in v)) delete o[k]; + for (const k of Object.keys(v)) { const a = o[k], b = v[k]; if (a && b && typeof a === "object" && typeof b === "object" && Array.isArray(a) === Array.isArray(b)) { if (Array.isArray(a)) { a.length = b.length; } mergeLeaves(a, b); } else if (a !== b) o[k] = b; } +} /** A store draft becomes `v`: keys `v` lacks are removed, the rest assigned (an array's length follows). */ function assignDraft(draft: any, v: unknown) { if (!v || typeof v !== "object") return; diff --git a/ref/compile/rt-web.ts b/ref/compile/rt-web.ts index 576da10..044fc5e 100644 --- a/ref/compile/rt-web.ts +++ b/ref/compile/rt-web.ts @@ -177,7 +177,7 @@ export function createApp(opts: { root: any; api?: Record unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); if (mode.optimistic && mode.structured) { // AC-3 path targets: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key - const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined); + const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined); const get = () => store; storeAccs.add(get); const plain = () => { throw new Error("a plain write to an optimistic store is not lowered yet"); }; return [get, { set: plain, path: plain, guess: (v, keys = []) => setStore((d: any) => { if (keys.length === 0) return typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v; /* a whole guess is the setter\'s return, which Solid diffs shallowly against the view as guesses */ let o = d; for (const k of keys.slice(0, -1)) o = o[k]; const last = keys[keys.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }) }]; @@ -265,7 +265,7 @@ export function createApp(opts: { root: any; api?: Record 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 hoistedOf.get(get) ?? []) refresh(h as any); const t = truthOf.get(get) ?? get; refresh((storeAccs.has(t) ? t() : t) as any); }, - affects: (get: Acc) => affects(get as any), + affects: (get: Acc) => affects((storeAccs.has(get) ? get() : get) as any), // a structured source's accessor wraps its store: Solid takes the store (#50), as `refresh` above preOr: (v: unknown, init: () => unknown) => v === undefined ? init() : v, // ---- modules (09 MO-1): a dependency's factory runs once per app; its exports are shared by every importer ---- @@ -316,6 +316,21 @@ export function createApp(opts: { root: any; api?: Record; +/** A keyed answer is merged into the draft by key: a row whose key is still there keeps its object and takes the new + * leaves, so an equal answer changes nothing and a changed one changes only its leaves (#51). Any other answer is + * assigned. + * TODO(Solid rc.22): return the keyed answer from the derive and let Solid reconcile it, dropping this merge. On + * 2.0.0-rc.21 that path throws in holdStaging (memo over latest() of the rows); fixed on Solid next (dba2aad). */ +function landDraft(draft: any, v: unknown, key: string | undefined) { + if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v); + const had = new Map(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row); + v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +function mergeLeaves(o: any, v: any) { + for (const k of Object.keys(o)) if (!(k in v)) delete o[k]; + for (const k of Object.keys(v)) { const a = o[k], b = v[k]; if (a && b && typeof a === "object" && typeof b === "object" && Array.isArray(a) === Array.isArray(b)) { if (Array.isArray(a)) { a.length = b.length; } mergeLeaves(a, b); } else if (a !== b) o[k] = b; } +} /** A store draft becomes `v`: keys `v` lacks are removed, the rest assigned (an array's length follows). */ function assignDraft(draft: any, v: unknown) { if (!v || typeof v !== "object") return; diff --git a/ref/compile/rt.ts b/ref/compile/rt.ts index 82c980a..5eecb91 100644 --- a/ref/compile/rt.ts +++ b/ref/compile/rt.ts @@ -187,7 +187,7 @@ export function createApp(opts: { api?: Record unknown> question(name: string, compute: (prev: unknown) => unknown, opts: { loadingValue?: unknown } | undefined, mode: { optimistic: boolean; structured: boolean; derived?: boolean }, key?: string): [Acc, Writer] { const fn = named(name, compute); if (mode.optimistic && mode.structured) { // AC-3 path targets: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key - const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => assignDraft(draft, r instanceof Flight ? await r.first : await r))(); assignDraft(draft, r); }, key ? [] : {}, key ? { key } : undefined); + const [store, setStore] = (createOptimisticStore as any)((draft: any) => { const r = fn(draft); if (r instanceof Flight || r instanceof Promise) return (async () => landDraft(draft, r instanceof Flight ? await r.first : await r, key))(); landDraft(draft, r, key); }, key ? [] : {}, key ? { key } : undefined); const get = () => store; storeAccs.add(get); const plain = () => { throw new Error("a plain write to an optimistic store is not lowered yet"); }; return [get, { set: plain, path: plain, guess: (v, keys = []) => setStore((d: any) => { if (keys.length === 0) return typeof v === "function" ? (v as (x: unknown) => unknown)(d) : v; /* a whole guess is the setter\'s return, which Solid diffs shallowly against the view as guesses */ let o = d; for (const k of keys.slice(0, -1)) o = o[k]; const last = keys[keys.length - 1]; o[last] = typeof v === "function" ? (v as (x: unknown) => unknown)(o[last]) : v; }) }]; @@ -273,7 +273,7 @@ export function createApp(opts: { api?: Record unknown> 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 hoistedOf.get(get) ?? []) refresh(h as any); const t = truthOf.get(get) ?? get; refresh((storeAccs.has(t) ? t() : t) as any); }, - affects: (get: Acc) => affects(get as any), + affects: (get: Acc) => affects((storeAccs.has(get) ? get() : get) as any), // a structured source's accessor wraps its store: Solid takes the store (#50), as `refresh` above preOr: (v: unknown, init: () => unknown) => v === undefined ? init() : v, // ---- modules (09 MO-1): a dependency's factory runs once per app; its exports are shared by every importer ---- @@ -324,6 +324,21 @@ export function createApp(opts: { api?: Record unknown> } export type App = ReturnType; +/** A keyed answer is merged into the draft by key: a row whose key is still there keeps its object and takes the new + * leaves, so an equal answer changes nothing and a changed one changes only its leaves (#51). Any other answer is + * assigned. + * TODO(Solid rc.22): return the keyed answer from the derive and let Solid reconcile it, dropping this merge. On + * 2.0.0-rc.21 that path throws in holdStaging (memo over latest() of the rows); fixed on Solid next (dba2aad). */ +function landDraft(draft: any, v: unknown, key: string | undefined) { + if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v); + const had = new Map(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row); + v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +function mergeLeaves(o: any, v: any) { + for (const k of Object.keys(o)) if (!(k in v)) delete o[k]; + for (const k of Object.keys(v)) { const a = o[k], b = v[k]; if (a && b && typeof a === "object" && typeof b === "object" && Array.isArray(a) === Array.isArray(b)) { if (Array.isArray(a)) { a.length = b.length; } mergeLeaves(a, b); } else if (a !== b) o[k] = b; } +} /** A store draft becomes `v`: keys `v` lacks are removed, the rest assigned (an array's length follows). */ function assignDraft(draft: any, v: unknown) { if (!v || typeof v !== "object") return; diff --git a/ref/oracle/lower.ts b/ref/oracle/lower.ts index 85063b2..e867ee0 100644 --- a/ref/oracle/lower.ts +++ b/ref/oracle/lower.ts @@ -37,6 +37,21 @@ function boundaryRead(f: () => unknown, match: (err: unknown) => boolean, alt: ( /** A boundary's fallback that is decided when read, so a failure that changes class while the fallback shows is re-decided. */ class Fallback { get: () => unknown; constructor(g: () => unknown) { this.get = g; } } function unwrapFallback(v0: unknown): unknown { let v = v0; if (v instanceof Fallback) v = v.get(); if (v instanceof Forward) throw v.err; return v; } +/** A keyed answer is merged into the draft by key: a row whose key is still there keeps its object and takes the new + * leaves, so an equal answer changes nothing and a changed one changes only its leaves (#51). Any other answer is + * assigned. + * TODO(Solid rc.22): return the keyed answer from the derive and let Solid reconcile it, dropping this merge. On + * 2.0.0-rc.21 that path throws in holdStaging (memo over latest() of the rows); fixed on Solid next (dba2aad). */ +function landDraft(draft: any, v: unknown, key: string | undefined) { + if (!key || !Array.isArray(v) || !Array.isArray(draft)) return assignDraft(draft, v); + const had = new Map(); for (const row of draft) if (row && typeof row === "object") had.set(row[key], row); + v.forEach((x: any, i) => { const row = x && typeof x === "object" ? had.get(x[key]) : undefined; if (row) { mergeLeaves(row, x); if (draft[i] !== row) draft[i] = row; } else draft[i] = x; }); + draft.length = v.length; +} +function mergeLeaves(o: any, v: any) { + for (const k of Object.keys(o)) if (!(k in v)) delete o[k]; + for (const k of Object.keys(v)) { const a = o[k], b = v[k]; if (a && b && typeof a === "object" && typeof b === "object" && Array.isArray(a) === Array.isArray(b)) { if (Array.isArray(a)) { a.length = b.length; } mergeLeaves(a, b); } else if (a !== b) o[k] = b; } +} /** A store draft becomes `v`: keys `v` lacks are removed, the rest assigned (an array's length follows). */ function assignDraft(draft: any, v: unknown) { if (!v || typeof v !== "object") return; @@ -257,7 +272,7 @@ export class Oracle { this.hoistAwaits(d.name, d.init, scope); const compute = this.computeOf(d.name, d.init, scope); if (this.optimisticTargets.has(d.name) && this.pathTargets.has(d.name)) { // AC-3 path targets over ST-5 structured: createOptimisticStore(fn, seed) — the derive writes its draft; the setter guesses per key - const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; assignDraft(draft, v); }; + const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; landDraft(draft, v, d.key); }; const [store, setStore] = (createOptimisticStore as any)(derive, d.key ? [] : {}, d.key ? { key: d.key } : undefined); const plain = () => { throw new Unsupported("a plain write to an optimistic store"); }; scope.vars.set(d.name, { k: "store", get: () => store, set: plain, setPath: plain, question: true, setGuess: setStore }); @@ -294,7 +309,7 @@ export class Oracle { const compute = this.computeOf(d.name, d.e, scope); const plain = () => { throw new Unsupported("a plain write to an equation"); }; if (this.pathTargets.has(d.name) || d.key) { - const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; assignDraft(draft, v); }; + const derive = async (draft: any) => { const r = compute(draft); const v = r instanceof Flight ? await r.first : await r; landDraft(draft, v, d.key); }; const [store, setStore] = (createOptimisticStore as any)(derive, d.key ? [] : {}, d.key ? { key: d.key } : undefined); scope.vars.set(d.name, { k: "store", get: () => store, set: plain, setPath: plain, question: true, setGuess: setStore }); } else { @@ -508,7 +523,7 @@ export class Oracle { case "throw": throw untrack(() => this.ev(s.e, scope, env)); case "action": this.lowerAction([], s.body, scope, env)(); return; case "refresh": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_REFRESH_TARGET"); const key = this.lastKey.get(s.name); if (key) this.forget(key); const d = Scope.all.find(x => x.vars.get(s.name) === b); for (const h of (d && this.hoistedByName.get(d)?.get(s.name)) ?? []) refresh(h as any); refresh((b.k === "store" ? b.get() : ((b.k === "sig" || b.k === "memo") && (b as any).truth) || b.get) as any); return; } - case "affects": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_AFFECTS_TARGET"); affects(b.get as any); return; } + case "affects": { const b = scope.lookup(s.name); if (!b || !("get" in b)) throw new Error("INVALID_AFFECTS_TARGET"); affects((b.k === "store" ? b.get() : b.get) as any); return; } // a store binding's get wraps the store: Solid takes the store (#50) case "optimistic": case "write": { const b = s.target.ctx ? this.provision(s.target.name, scope) : scope.lookup(s.target.name); const v = untrack(() => this.ev(s.e, scope, env)); // W-6: a function value is Solid's updater form — the setters take it as is diff --git a/scenarios/S-affects-keyed.md b/scenarios/S-affects-keyed.md new file mode 100644 index 0000000..996c2e1 --- /dev/null +++ b/scenarios/S-affects-keyed.md @@ -0,0 +1,46 @@ +# S-affects-keyed — `affects` on a keyed source that takes a guess + +Rules exercised: AC-5, AC-3, AC-4, H-9. + +## Program + +```rx +export default component App() { + source tick := 0 + day = await fetch("d" + str(tick)) + source rows key id = day.rows + source err := "none" + action move(id: number, at: number) { + try { + let i = rows.findIndex(r => r.id == id) + guess rows[i].at := at + affects rows + await fetch("save") + refresh day + } catch (e) { err := e.message } + } + errored fallback(e) {

error

} { + loading fallback {

loading

} { +

{for r in rows { {r.at} }}{pending? rows ? "saving" : ""} {err}

+ } + } +} +``` + +A keyed source over an answer, written by a guess, lowers to an optimistic store. The action declares its work on the +whole list with `affects rows`, so `pending? rows` is true until the action ends (AC-5), and the save is asked. + +## Frames + +| instant | action | frame | +| ------- | ------ | ----- | +| 0 | mount | `loading` | +| 1 | resolve "d0" {"rows":[{"id":1,"at":0},{"id":2,"at":3}]} | `03 none` | +| 2 | handler `move(1, 5)` | `53saving none` | +| 3 | resolve "save" 0 | `53saving none` | +| 4 | resolve "d0" {"rows":[{"id":1,"at":5},{"id":2,"at":3}]} | `53 none` | + +Reported as solidjs/reactivescript#50: the compiled runtime handed `affects` the source's accessor, a wrapper around +the store, where `refresh` already unwrapped it. Solid's dev build threw `INVALID_AFFECTS_TARGET` into the action's +`catch`, so the save was never asked and the guess reverted (instant 2 read `03 none`, then the error); its +production build ignored the call, so `pending? rows` never turned true.