clay.js
  1. ClayJS
  2. Reference
  3. Plugins
  4. undo

undo

Whole-page undo and redo over DOM mutations: native text undo left to the browser, automatic or command-only recording, guarded replay with refusal codes, kept copies that survive a reload.

The undo plugin provides document-wide undo and redo built on DOM mutations. In edit mode, Cmd+Z steps backward through changes to the document and Cmd+Shift+Z steps forward, reversing moves, deletions, text edits, and attribute changes while keeping the same live DOM nodes. Every replay is checked against the document as it stands, so a step either applies whole or changes nothing.

Load the plugin#

Add undo to the plugins list on the script tag:

HTML
<script src="https://clayjs.com/v1/clay.js?plugins=undo"></script>

No other plugin implies undo, because loading it binds Cmd+Z for the whole page. Like other edit-only plugins, undo loads in edit mode only. A visitor never downloads or runs it. On a document with <html clay-mode-switch="live"> the module is imported while the tab still shows view; the history starts at the commit.

Shortcuts#

ActionKeys
UndoCmd+Z (Ctrl+Z on Windows)
RedoCmd+Shift+Z (Ctrl+Shift+Z)
Redo (Windows)Ctrl+Y

When focus is inside an editable region, Cmd+Z goes to that editor’s own undo history rather than page undo. When focus is inside an embedded code or rich-text widget (CodeMirror, Monaco, Ace, Quill, Tiptap, ProseMirror), Cmd+Z is also left to that widget’s undo stack.

What undo covers#

The plugin records changes to the document record automatically. That is the default recording mode, and in it every authored mutation is recorded:

  • Adding, removing, moving, and reordering elements
  • Attribute and text changes
  • Edits made through the CMS
  • Form controls marked with persist

When undo restores a removed element, it re-inserts the same DOM node, so its event listeners, focus, and scroll position survive. After an undo or redo, ClayJS copies checked, value, and selected attributes back onto the live properties of persist controls outside no-undo regions.

Undo ignores changes inside clay="editor-ui", clay="no-undo", and clay="no-watch" regions, inside richclay regions, and the writes a live sync merge brings in.

Recording modes#

clay.undo.setRecording("automatic") records every mutation the page makes. It is the default. Writes made within 500 ms of each other are coalesced into one step labelled "Edit", so typing a sentence is a single undo.

clay.undo.setRecording("commands") records nothing on its own. Only a write bracketed by clay.undo.command(label, fn) earns a step. Everything else still changes the document, and a later replay sees those changes, but none of it appears in history:

  • A live sync merge
  • A derived paint, such as the attributes sapjs keeps up to date
  • A sortable gesture’s bookkeeping on the item it holds
  • A late answer whose callback writes after its await

Switching modes is a boundary, not a merge: a change to or from edit ends the history, and the recording mode is carried across it, so a page set to "commands" is still "commands" after the next entry (Across a mode change). setRecording accepts only "automatic" or "commands", and throws for anything else.

clay.undo.ignoreAttribute(name) keeps one of the page’s own attributes outside history: it is saved like any attribute, but a change to it is never an edit, and a replay never compares it. Use it for maintenance values the page rewrites itself, such as a card number a repair reassigns, so that repair never makes an undo or redo refuse. It returns a function that removes the registration.

Across a mode change#

undo is a participant of the mode lifecycle, with the id clay:undo and order 350. Switching without a reload describes how a participant is prepared, and Leaving edit how an exit refuses.

History belongs to one edit session. A session starts its own over the page as it stands, so nothing an earlier session recorded is this one’s to undo, and the commit that ends a session stops its recording. A mode change therefore ends the history, and clay:undo-change reports canUndo: false with it.

The recording mode is the page’s choice and is not part of the session, so it survives: a page that asked for "commands" records commands after the next entry without asking again. The kept copies a page recorded are also outside the sessions, and outlive every one of them.

Typed text and native undo#

Page undo does not take native undo away from a text field. Cmd+Z and Cmd+Shift+Z are left to the browser when focus is inside an input, a textarea, a select, or a contenteditable subtree, during an IME composition, and after another handler has already called preventDefault on the keydown. The field keeps its own per-character undo, and page history and field history never fight over the same keypress.

The gap that remains is different: a plain control’s typed value is not document content. Typing into an <input> or <textarea> without persist changes the control’s live property and nothing else, so no mutation is recorded and undo has nothing to replay. The text becomes content at the moment something writes it:

  • Add persist to every form control whose value is part of the document record. persist mirrors the live value into the DOM so undo records and restores it.
  • Or write the value into the document yourself, inside one clay.undo.command.
  • Put temporary controls such as filter or search boxes inside a clay="editor-ui" or clay="no-undo" region.

One step per command#

clay.undo.command(label, fn) runs fn and records only what fn writes, as one labelled step:

JavaScript
clay.undo.command("Add card", () => list.append(card));

It returns { id, label } for the entry it made, or null when the callback changed nothing. The object is what a later clay.undo.undo({ entryId }) or clay.undo.discard(entryId) takes.

clay.undo.command must be synchronous. For asynchronous work such as an upload or a network request, await the async work first and then perform all DOM writes inside one synchronous callback. A command declared async, one that returns a promise, one nested inside another command, and one run inside a clay.effect all throw instead of recording.

clay.undo.commit(label, fn) is the older bracket. In automatic mode it closes whatever the page had pending, runs fn, and commits everything that landed under label. In commands mode it is a capture too, but it honours an outer exclusion pause, so a commit inside a sync merge records nothing where a command still records its own writes. It returns the entry in commands mode and undefined in automatic mode. Prefer command in app code: it reports the entry it made and never picks up a write that was not its own.

The page title. document.title lives in the head, outside the part of the page undo observes, so a head write is never part of a step on its own. A command does capture a title it changes, and to record one explicitly, inside a command, use recordValue:

JavaScript
clay.undo.command("Rename", () => {
  const oldValue = document.title;
  document.title = "Board 2";
  clay.undo.recordValue(document, { prop: "title", oldValue, newValue: document.title });
});

The explicit value and the command’s own capture are one field of one step, not two, so a single undo puts the old title back.

Saving the document does not clear the undo stack; history survives across saves within the same tab.

Guarded replay#

An undo or redo checks the whole step against the document as it is now and either replays all of it or nothing. It returns a receipt:

JavaScript
// { ok: true, entryId, label, direction }
// { ok: false, code, entryId, label, direction }
clay.undo.undo();
clay.undo.redo();

When the check fails, the receipt carries the reason and nothing in the document moved:

CodeWhat changed since the step was recorded
content-changedAn attribute, a text run, or the saved content of a subtree the step would erase is not what the step recorded. A peer edited the same target.
structure-changedThe children of a list the step edits are not in the order it recorded, or a node it would place is still attached somewhere else.
target-detachedThe element or list the step addresses is no longer in the edited page.
duplicate-idUndoing a delete would bring back a data-id that a live node already carries.
protectedThe step would erase saved content that keeps its own history. See what a step never erases.
unsupportedThe step records a shape replay cannot address or cannot verify before writing.
stale-entryclay.undo.undo({ entryId }) or clay.undo.redo({ entryId }) named an entry that is not on top of its stack. Those calls replay an entry only when it is the next one.

A refused step stays on top of the stack, and its entry in clay.undo.history or clay.undo.future carries refused with the direction, the code and the targets, each named by its nearest data-id ancestor. The same refusal is also kept as recovery, so it survives a reload even though the stack does not. Undo and redo keep returning that refusal until something else changes the stack. clay.undo.discard(entryId) drops only that step: it returns { ok: true, entryId, direction }, leaves every other entry and the document exactly as they are, and answers { ok: false, code: "not-refused" } for an entry that is not the refused top. There is no force flag and no way to replay a refused step anyway.

Every refusal also fires clay:undo-refused on document, with the receipt as detail:

JavaScript
document.addEventListener("clay:undo-refused", (event) => {
  console.warn("could not replay", event.detail.label, event.detail.code);
});

Its detail is { ok: false, code, entryId, label, direction }, with no document HTML and no private text: the copy of what the step would have restored goes to recovery, not into a global event.

Recovery#

History belongs to this tab and this editing session, so a reload starts with nothing to undo. What a reload can still show is the losing side as plain data, kept on one internal channel of the recovery store and never rendered back into the page. Nothing kept here is a save.

A refused step is kept for you. When an undo or redo refuses, ClayJS writes the step’s description into the store: the entry id, its label, the direction, the code, every target, and each step’s before and after. The receipt and clay:undo-refused go out first and the copy is made behind them, so a store that is slow, full or unavailable can never hold up the refusal; the step is still on top of this tab’s stack either way. clay:undo-kept fires on document once the copy is settled:

JavaScript
document.addEventListener("clay:undo-kept", (event) => {
  // { ok: true, entryId, direction }
  // { ok: false, code, entryId, direction } - the store wrote nothing
});

One record is kept per step and direction, under refused:<entryId>:<direction>: refusing the same step again replaces its copy, and the older one leaves only once the newer copy is durable, at the revision this tab can see. A refused record is never the kind a save covers, so a normal save dismisses nothing here.

The page’s own material. A step nobody can replay is one kind of loss. Another is writing with nowhere left to go, such as a card another tab deleted while this one was typing in it. clay.undo.keep writes the page’s own copy into the same store, and nothing else decides what that copy holds:

JavaScript
const written = await clay.undo.keep({
  key: `card:${id}`,
  label: saved.title,
  context: { kind: "card", id, label: saved.title },
  payload: {
    text: [saved.title, ...drafts.map((d) => `${d.name}: ${d.text}`)].join("\n"),
    card: { title, desc, comment, listId },
  },
});
if (!written.ok) return;   // the drafts stay exactly where they are
for (const draft of drafts) clay.unsent.drop(draft.record, draft.name);
  • key names the material, not the record it came from: one key holds one copy, so keeping the same key again replaces the earlier one. It is stored under app:<key>.
  • payload.text is what the review panel quotes under Yours, and it is the difference between the two kinds a record can be: a copy holding one is staged as a text copy, one without it as a conflict copy. This channel reads no saved bytes, so no save clears either kind: a copy leaves when a person dismisses it, or when the page removes it once something durable covers it.
  • context is { kind, id, label, field } and is what names the material in the panel. A resolver registered with clay.conflicts.registerContext names it again in a later session.
  • written is { ok: true, stored }, where stored holds the { id, revision } the copy was written at, or { ok: false, code } when the store refused the write. The copy stays in memory either way, so this tab can still show it, but a reload would lose it: say so and keep offering the text some other way.
  • clay.undo.kept() is what a later session reviews: { ok, records }, with one { id, key, kind, revision, payload, supported } per copy from every writer of this document, not only this tab. The label and the context a copy was kept with are inside its payload.
  • clay.undo.dismissKept(id, { revision }) removes one copy at the revision the reviewer was looking at. A copy another tab replaced since then is left exactly as it is, with { ok: false, code: "stale" }.

A review action the page offers. clay.undo.reviewActions(fn) registers a provider that is asked about every record the review panel draws, and each action it returns becomes a button on that row. The panel never learns what a card is, and a provider that throws costs its own buttons only. The call returns a function that unregisters it:

JavaScript
const unregister = clay.undo.reviewActions((record) => {
  const card = record?.payload?.card;
  if (!card || !String(record.key ?? "").startsWith("app:card:")) return [];
  return [{ label: "Keep as new card", run: () => keepAsNewCard(record) }];
});

An action’s run may be async and answers { ok, dismiss }. The panel dismisses a row only for { ok: true, dismiss: true }, and only at the revision the row was drawn at. An action that removes its own record returns { ok: true, dismiss: false }: that says the record is already gone, so the panel must not dismiss it a second time at a revision that no longer exists. Any other answer dismisses nothing and leaves the copy where it is. Either way the panel re-reads the store afterwards, so a row whose record your action removed goes by itself, and a failed one stays where it was.

JavaScript
async function keepAsNewCard(record) {
  const kept = record.payload.card;
  const made = clay.undo.command("Keep as new card", () => {
    const card = makeCard(kept.title, freshId(), kept.desc);
    list.querySelector(":scope > ol.cards").prepend(card);
    return card;
  });
  if (!made) return { ok: false };
  const saved = await clay.save.flush();
  if (saved.state !== "saved" && saved.state !== "clean") return { ok: false };
  const cleared = await clay.undo.dismissKept(record.id, { revision: record.revision });
  return { ok: !!cleared.ok, dismiss: false };
}

The copy is made by one command, so it is one labelled step, and it leaves only after clay.save.flush() has had the host accept the new card’s bytes: until then it is the only place that writing exists, and a save that failed or did not cover it leaves it reviewable.

What a step never erases#

Saved content that keeps its own history is protected. When a step would take a subtree out of the page and that subtree contains a clay="no-undo" region, or a region a plugin marked no-undo at runtime as richclay does, the replay refuses with protected instead of erasing content undo could not put back. The same step keeps working when the region is chrome that is never saved, such as clay="editor-ui", because nothing is lost.

Not every write that is not content counts against a step. A derived paint (a sapjs attribute Undo never records), a tab-local attribute such as route-active, and the draggable and style a sortable gesture writes on the item it holds are all ignored, so a repaint or a drag never makes a recorded step refuse.

Moves and sorts#

A drag and a clay.sortable.move call are each one step, labelled by the list’s sortable-label attribute or Move. A drag whose source list a peer changed while the drag was held records no step and fires clay:undo-unavailable on document with { label, code: "conflicted" }. The sortable page has the whole contract, including how a move is checked against both lists.

History surface#

clay.undo.history lists the undo stack oldest first, and clay.undo.future lists the redo stack next redo first, so history.at(-1) and future[0] are the entries the next Undo and Redo would replay. Each entry is { id, label, timestamp, refused? }, where refused is present only on the entry that was just refused and holds { direction, code, targets }. A target is { id, kind }, and its id is the nearest [data-id] ancestor of the node the step addresses: a text step has no id of its own, so it names the card or entry it was typed in, and a node with no such ancestor has null.

clay:undo-change fires on document after every entry, replay, refusal, dismissal and clear, and when an edit session starts or ends. Its detail is { canUndo, canRedo, undo, redo }, where undo and redo are those two entries or null. One listener drives every history control:

HTML
<button id="undo" hidden></button>
<button id="redo" hidden></button>
<p id="refused" hidden></p>
JavaScript
const undoButton = document.querySelector("#undo");
const redoButton = document.querySelector("#redo");
const notice = document.querySelector("#refused");

function paint({ canUndo, canRedo, undo, redo }) {
  undoButton.textContent = undo ? `Undo ${undo.label}` : "Undo";
  redoButton.textContent = redo ? `Redo ${redo.label}` : "Redo";
  undoButton.disabled = !canUndo;
  redoButton.disabled = !canRedo;

  const blocked = [undo, redo].find((entry) => entry?.refused);
  notice.hidden = !blocked;
  if (!blocked) return;
  const done = blocked.refused.direction === "redo" ? "redone" : "undone";
  notice.textContent = `"${blocked.label}" could not be ${done} because the document changed.`;
  const dismiss = document.createElement("button");
  dismiss.textContent = "Dismiss";
  dismiss.onclick = () => clay.undo.discard(blocked.id);
  notice.append(" ", dismiss);
}

document.addEventListener("clay:undo-change", (event) => paint(event.detail));
paint({
  canUndo: clay.undo.canUndo,
  canRedo: clay.undo.canRedo,
  undo: clay.undo.history.at(-1) || null,
  redo: clay.undo.future[0] || null,
});

undoButton.onclick = () => clay.undo.undo();
redoButton.onclick = () => clay.undo.redo();

clay.undo.on is the lower layer, on the plugin rather than on the document. Its events are "commit", "undo", "redo", "refused", "discard", and "clear". undo and redo receive the receipt, refused receives the receipt with its code, and discard receives { entryId, label, direction }. canUndo, canRedo, history, and future are also readable at any moment, which is what a control created late reads.

Limits#

  • History belongs to one edit session, capped at 100 steps, and is not saved. A mode change ends it, and reloading the document starts with an empty history; recovery is what carries a refused step, and the copies a page kept, across the reload.
  • There is no shared undo across collaborators. Changes from another tab or person via live sync stay out of your local stack, so your Cmd+Z does not revert someone else’s edit.
  • Structural steps are guarded like attribute and text steps, so a peer’s edit to a step’s target makes it refuse instead of replaying over the peer’s work. Undoing "Add card" checks that the card is still the node the step added, that its saved content is what the step recorded, and that the list is in the order the step recorded. If a peer edited the card, the undo is refused content-changed and the card stays as the peer left it. If a peer inserted or moved a neighbour, or created a node that already holds the deleted item’s data-id, the undo of a delete is refused structure-changed or duplicate-id. The refused step stays on top, so there is nothing to clear and no cleanup to schedule after a sync frame: replay it once the conflict is gone, or clay.undo.discard(entryId) it and move on.