@zakkster/lite-undo
v1.0.0
Published
Transactional undo/redo on @zakkster/lite-project: a commit is an undoable transaction, undo/redo are reactive source writes, and preview() shows any historical state as overlays without committing -- time-travel preview for free. Bounded zero-GC history
Maintainers
Readme
@zakkster/lite-undo
Transactional undo/redo on @zakkster/lite-project.
A commit is an undoable transaction. You edit through a lite-project projection
(ephemeral overlays over a keyed source), then commit() through this
controller: it captures each touched key's before (source) and after
(overlay) value, commits the projection, and pushes the transaction onto a bounded
history. undo() writes the before values back into the source; redo()
re-writes the after values. The source is reactive, so every view re-derives --
undo and redo are ordinary writes, not a rebuild.
And because the editing surface is overlays, previewing a historical state is free: apply it as overlays without committing, and the view shows where you'd land while the source and the real cursor stay put.
npm install @zakkster/lite-undoPeer dependencies:
@zakkster/lite-project^1.0.0(the editing surface) and@zakkster/lite-signal^1.5.0(the cursor signals). ESM only. MIT.
Commit, undo, redo
import { keyedStore, project } from "@zakkster/lite-project";
import { createHistory } from "@zakkster/lite-undo";
const store = keyedStore({ title: "untitled", count: 0 });
const doc = project(store); // the editing surface (overlays)
const history = createHistory(doc, store, { capacity: 200 });
doc.set("title", "Draft"); // stage an overlay (source untouched)
history.commit("rename"); // capture before/after, commit, record
doc.set("count", 5);
history.commit("set count");
history.undo(); // count -> 0 (writes the before value back to the source)
history.undo(); // title -> "untitled"
history.redo(); // title -> "Draft"commit(label?) returns false if nothing was staged. transaction(fn, label?)
runs fn (which stages overlays) and commits it as one transaction:
history.transaction(() => {
doc.set("title", "Final");
doc.set("count", 42);
}, "publish"); // one undoable step, two keysUndo/redo write to the source, so any view derived from it updates reactively. The enable/disable state of your buttons is reactive too:
effect(() => { undoBtn.disabled = !history.canUndo(); });
effect(() => { redoBtn.disabled = !history.canRedo(); });How a transaction is captured
flowchart LR
E["doc.set(k, v)<br/>(overlays)"] --> C["history.commit()"]
C --> B["read source.get(k)<br/>= BEFORE"]
C --> A["overlay value<br/>= AFTER"]
B --> R["transaction<br/>{ keys, before, after }"]
A --> R
R --> W["projection.commit()<br/>source := AFTER, clear overlays"]
R --> H["push onto history ring"]
H -->|undo| B2["source := BEFORE"]
H -->|redo| A2["source := AFTER"]The before values are read from the source the instant before the projection
commits -- that inverse is what undo() replays.
Time-travel preview
preview(position) shows the source state at any history position without
committing to it -- it applies that state as projection overlays (masking the
source), so views show it while the source and cursor stay exactly where they are.
cancelPreview() clears precisely those overlays.
// history has N transactions; cursor is at the latest
history.preview(0); // the view now shows the original state...
history.preview(3); // ...now the state after 3 commits...
history.cancelPreview(); // ...and back to live. The source never moved.Wire it to a history panel and you get hover-to-preview:
const entries = history.labels(); // reactive: [{ index, label }, ...] oldest-first
// on hover of entry i: history.preview(i + 1)
// on mouse-out: history.cancelPreview()
// on click: while (history.position() > i + 1) history.undo();Preview reconstructs an arbitrary state -- multi-key, many steps back or forward -- by replaying the right side (before / after) of the transactions between the cursor and the target, closest-to-target winning per key.
History storage
stateDiagram-v2
[*] --> Applied: commit (cursor++)
Applied --> Applied: commit -> truncate redo branch, append
Applied --> Undone: undo (cursor--)
Undone --> Applied: redo (cursor++)
Undone --> Applied: commit -> redo branch discarded
Applied --> Dropped: ring full -> oldest evictedA fixed-capacity ring with an undo/redo cursor. A new commit truncates the redo
branch, appends, and drops the oldest entry when full. Transaction slots and
their keys/before/after arrays are pre-allocated and reused -- a steady
cycle of commit/undo/redo allocates nothing on the engine pool after warm-up
(5,000 cycles flat, verified). It is inlined rather than backed by
lite-history-buffer because undo/redo needs cursor and redo-branch semantics a
plain ring does not provide.
Boundaries (the honest non-claims)
- Undo/redo operate on committed source state. Uncommitted overlays you are still editing are separate and remain.
- Commit through this controller to make a change undoable. A direct
projection.commit()bypasses the history. - Preview uses overlays, so the projection reads dirty during a preview and it
assumes no conflicting pending overlays -- it is for viewing committed history.
cancelPreview()clears exactly the keys it set. - History slots retain value references up to their per-slot high-water key count (bounded by capacity) -- expected for a history buffer.
API
createHistory(projection, source, opts?: { capacity?: number }): History
createUndo(registry): { createHistory } // bind cursor signals to a non-default registry
interface History {
commit(label?): boolean
transaction(fn: () => void, label?): boolean
undo(): boolean
redo(): boolean
canUndo(): boolean // reactive
canRedo(): boolean // reactive
position(): number // reactive
size(): number // reactive
labels(): { index: number; label: unknown }[] // reactive
preview(position: number): void
cancelPreview(): void
clear(): void
dispose(): void
}projection is a lite-project projection; source is the same { get, set } it
wraps.
License
MIT (c) 2026 Zahary Shinikchiev <[email protected]>
