ADRs
ADR 0074 — StepBack: one back/forward (undo/redo) module

Status: Accepted (2026-07-17)

Context

Undo was re-implemented once per Studio surface:

  • The Design-System / Library / Sorgente surfaces got their history from useStudioTree — a local useState tree plus a historyRef stack, undo driving setLocal.
  • The Workflow surface got a separate copy in WorkflowCompositionArtboard — its own historyRef, its own undo, but the tree lived in the parent (useWorkflowArtboards, ADR 0066) and undo round-tripped through the parent's saveTree.

studio-bus.undoActiveTree() only delegated to whichever surface was active — it owned nothing. So the two copies drifted. The DS copy (local state) worked; the Workflow copy did not: undo popped the ref and reverted the bus's treeRef, but the parent-owned render prop never updated, so ⌘Z visibly did nothing on Workflow. This is the exact failure the user hit moving from DS to Workflow — "worked on DS, fell apart at the seams on Workflow."

The same shape (a feature re-implemented per surface, then diverging) is a standing risk for every cross-surface capability (selection, delete, reorder).

Decision

Extract undo/redo into one standalone module, @arno/stepback, that owns the history AND the authoritative value, and have every surface plug into it instead of re-rolling a stack.

  • createStepBack<T> — a pure, framework-agnostic controller: current + a past (undo) stack + a future (redo) stack. Generic over T, so ANY back/forward mechanic uses it, not just the Studio tree.
  • useStepBack<T> — the React binding. It owns value in React state, so a surface that renders from value can never desync its bus handle from its render. The surface supplies onCommit (persistence) only; StepBack never persists itself. replace re-seeds from outside (remote sync, context switch) without recording history.
  • useUndoRedoHotkeys — the ⌘Z / ⌘⇧Z (and Ctrl+Y) bindings, mounted once in StudioDndShell, routed to undoActiveTree / redoActiveTree on the bus.
  • The bus gains redo? on the handle and redoActiveTree().

@arno/stepback depends only on react — nothing Studio-specific. It ships with Layers by default (they mount together) but relocates freely: the "attachment" is the shell that co-mounts Layers wiring the hotkeys to the bus.

Consequences

  • The Workflow undo bug is fixed by construction. Both surfaces now render from StepBack's value; there is no parent round-trip to lose.
  • Redo exists for the first time, everywhere, on ⌘⇧Z / Ctrl+Y.
  • Both per-surface history copies are deleted. useStudioTree and WorkflowCompositionArtboard keep only their genuinely-surface-specific parts (Sorgente debounce/remote-sync; parent-owned persistence) and delegate history to StepBack.
  • Establishes the module pattern for future extractions (a feature = an @arno/* package with explicit deps + a mount interface; e.g. selection already moved to @arno/selection-frame).

Alternatives considered

  • Put history in @arno/ponte (the bus core). Rejected as the primary home because the user's model is "a module that travels with Layers and is relocatable"; a standalone package expresses that better and keeps ponte lean. The bus still exposes the undo/redo bridges — it delegates to the surface's StepBack, it does not own the stack.
  • Fix only the Workflow copy. Rejected — it leaves the duplication that caused the divergence, guaranteeing the next surface repeats it.