0029. 0029 — Canvas actions run through one optimistic op runner
Status: accepted 2026-10-29.
Context
Section titled “Context”Product rule: no action waits for the server before the user sees its result. Each canvas action had its own answer to that: some applied locally and rolled back by hand (delete, wire mode), some applied locally and never rolled back (rename, ungroup), some waited for the answer and then refreshed (move into a frame, wrap with stack, wrap in a section: 1.2–2.1 s on the test account). Undo slots were pushed on the answer, so ⌘Z during a slow save missed the gesture.
Decision
Section titled “Decision”One runner, createOptimistic in src/lib/canvas/optimistic-ops.ts:
- apply the change to local state, synchronously;
- reserve the undo slot (
UndoStack.pushPending, #1596); - hold snapshots off (
pending) so a refresh cannot undo the local change; - send the write, serialised with every other op in the order they were made;
- on success land the undo items and, if the action says so, refresh once the queue is idle;
- on refusal or network error restore only the rows the op touched (
restoreRows) from the state before it, land the slot empty, and show the action’s notice.
Every action is a row in REGISTRY (src/lib/canvas/optimistic-registry.ts): local apply,
server fields, touched rows, landing test (Moved = outcome === 'done', Answered = any
success), undo source, refresh. A new action is a row. State that is not canvas rows gets
its own registry run by the same runner: comment threads (src/lib/canvas/comment-ops.ts),
where an op touches thread ids instead of nodes/edges; the pages list and canvas share
(src/lib/canvas/page-ops.ts). Where the server would mint an id or token, the client mints
it and the server stores it, so nothing needs reconciling. Actions that change a frame doc
plan on the client with the same pure planner the server runs (src/lib/motion/move-plan.ts,
src/lib/motion/scenes.ts), seeded with client-chosen clip ids, and hold the planned docs
through frameDocs.hold until the op lands.
Optimistic concurrency is unchanged: writes that carry nodes.version keep sending it, a
conflict is a refusal (rolled back), and the server records it in canvas_conflicts (#1566),
so History can still offer “keep mine”.
Rejected
Section titled “Rejected”- Snapshot rollback of the whole canvas: it would erase edits made, or received by realtime, while the op was in flight. Only touched rows go back.
- Per-action hand-written optimism: that is what existed; it diverged action by action.
- Folding the line queue (
op-queue.ts) and the place/save schedulers in: they already apply locally and retry offline from IndexedDB; the runner covers single-shot writes that do not retry. Runner ops are kept in the outboxsentlist from the gesture to the answer and sent again after a reload (src/lib/canvas/sent-ops.ts); client-chosen ids make that safe.
Amendment 2026-11-03 — every write is durable, not only placements
Section titled “Amendment 2026-11-03 — every write is durable, not only placements”The runner was never the only write path. Element contents (frame-doc ops from
FrameEditSession) and draws were posted once, outside the outbox: lost offline, on a closed
tab, while the status said “Saved”. Decision:
- One outbox, more parts.
feega-outboxalso keepsdraws(anop-queuewith client-minted node ids, replayed in order) anddocs(FrameDraft: the ops from the last landed revision plus that revision, one per editing session,src/lib/canvas/frame-drafts.ts). - Idempotent on the server. A draw resent with an id that exists answers that node. Frame ops
are sets and add-if-missing, applied by
commitOpson the current head from the sent version, so a replayed draft rebases instead of conflicting. - Unreachable is not refused. A send that throws, 5xx or busy keeps the edit pending and retries with capped backoff; only a refusal drops it (and its draft).
- A write waits for its node’s draw. Nothing naming a node reaches the server before the draw that creates it; snapshots keep the ghost of a queued draw.
- One count. The status and
beforeunloadreadpendingWritesover every part, plus runner ops in flight. - Server undo is kept in the
sentlog until answered; move undo is a placement write and does not wait for it.