# 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.

Source: https://clayjs.com/docs/plugins/undo/

The `undo` plugin provides document-wide undo and redo built on DOM mutations. In [edit mode](https://clayjs.com/docs/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](https://clayjs.com/docs/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](https://clayjs.com/docs/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

| Action | Keys |
| --- | --- |
| Undo | Cmd+Z (Ctrl+Z on Windows) |
| Redo | Cmd+Shift+Z (Ctrl+Shift+Z) |
| Redo (Windows) | Ctrl+Y |

When focus is inside an [`editable`](https://clayjs.com/docs/plugins/richclay/) 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](https://clayjs.com/docs/plugins/cms/)
- Form controls marked with [`persist`](https://clayjs.com/docs/attributes/)

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"`](https://clayjs.com/docs/regions/), `clay="no-undo"`, and `clay="no-watch"` regions, inside [`richclay`](https://clayjs.com/docs/plugins/richclay/) regions, and the writes a [live sync](https://clayjs.com/docs/plugins/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](#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](https://clayjs.com/docs/guide/edit-mode/#switching-without-a-reload-clay-mode-switch-live) describes how a participant is prepared, and [Leaving edit](https://clayjs.com/docs/guide/edit-mode/#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:

```js
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.

> [!WARNING]
> `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`:

```js
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:

```js
// { 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:

| Code | What changed since the step was recorded |
| --- | --- |
| `content-changed` | An 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-changed` | The 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-detached` | The element or list the step addresses is no longer in the edited page. |
| `duplicate-id` | Undoing a delete would bring back a `data-id` that a live node already carries. |
| `protected` | The step would erase saved content that keeps its own history. See [what a step never erases](#what-a-step-never-erases). |
| `unsupported` | The step records a shape replay cannot address or cannot verify before writing. |
| `stale-entry` | `clay.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](#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`:

```js
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](#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](https://clayjs.com/docs/recovery/) 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:

```js
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:

```js
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:

```js
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.

```js
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`](https://clayjs.com/docs/plugins/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](https://clayjs.com/docs/plugins/sortable/#moving-an-item-with-code) 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>
```

```js
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](#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](https://clayjs.com/docs/plugins/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.
