Skip to content

Proposal: provide undo/redo functionality through a patchwork provider - #469

Draft
iFreilicht wants to merge 8 commits into
inkandswitch:mainfrom
iFreilicht:plan/document-undo
Draft

iFreilicht wants to merge 8 commits into
inkandswitch:mainfrom
iFreilicht:plan/document-undo

Conversation

@iFreilicht

@iFreilicht iFreilicht commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: The basic idea is to add a provider and API that enables tool authors to support undo/redo actions with minimal effort. This ensures undo/redo behaves consistently across tools and reduces design and maintenance burden on tool authors. Existing functionality in Automerge and Patchwork can be leveraged for implementation.

Design choices

The approach in this proposal implements this functionality under the following design choices:

  1. Undo history is local to a "realm", which is the current term in the code for the JS-context a tool runs in. Usually, this just means the current tab, but it can also mean iframe or worker if a tool runs inside one of those. For the isolation component, this would mean that a tool inside of it cannot share history with tools outside of it. In the tiling frame tool, this means that if multiple tools have the same document on the same draft open at the same time, changes in every one of those tools contribute to a shared history.
  2. Changes in the document are added to the history as steps (one step is a group of one or more changes) based on these criteria below. A new step is created immediately after the previous one gets completed and captures all changes in the document until one of the following events occurs:
    - no change was made within groupDelayMs (default 500ms, same as CodeMirror's newGroupDelay)
    - a tool called checkpoint(), for example after a section of code was pasted
    - a text change was made in another location in the text
    If one of these events occurs, the step is completed and added to the history, a new step is created, and the cycle continues anew.
  3. Changes from other contributors (which can also mean other realms, not just other users!) are not added to the history, even if they work in the same draft of the same document at the same time
  4. Conflicts are resolved defensively so no information gets lost accidentally
  5. No step in the history should ever become unreachable. The simplest example to explain this is the following set of steps:
    - Insert X -> Document contains X
    - Insert A -> Document contains XA
    - Insert B -> Document contains XAB
    - Press Ctrl+Z -> Document contains XA
    - Insert C -> Document contains XAC
    - Press Ctrl+Z -> Document contains XA
    - Press Ctrl+Z -> Document contains XAB, NOT X
    One term for this problem is The Great Undo-Redo Quandary.
  6. Undo history is preserved across document reloads so accidentally closing and then re-opening a document does not prevent the use of Ctrl+Z

Proposed solution

New global provider

Currently, <patchwork-view> is wrapped in a <repo-provider>. This proposal would add <undo-provider> as well, which enables a frame-tool to connect to patchwork:undo to send the host commands undo and redo and enables editor tools to connect to patchwork:undo-source events (by calling undoFor, see Basic use) to receive those host commands (see Additions to patchwork's core for details on what "connect" means here):

// in core/patchwork/src/site-kit/html.ts:
  const body = [
    `<repo-provider><undo-provider><patchwork-view id="root"></patchwork-view></undo-provider></repo-provider>`,
    `<script type="module" src="${entry}"></script>`,
  ];

Use by tool authors

Basic use

To use this facility, most tool authors will only need to call the function undoFor<T>(element: HTMLElement | null, handle: DocHandle<T>, options: UndoOptions = {}): Undo that creates an Undo-object for interacting with the shared history for element displaying the document handle. It also wires up event handlers on handle to react to changes in the document to add new steps to the history and connects to patchwork:undo-source on the provider. This MUST be called in some way for a tool to participate in the Undo-History (though framework-specific hooks are also provided, see below):

//  In a tool's setup code:
const undoBinding: Undo = undoFor(myElement, myDocHandle);

At this point, Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y (Cmd+Z/Cmd+Shift+Z on macOS) will already behave as expected. The only other thing tool authors should do is call undoBinding.dispose() when their tool is closed to unregister event handlers, close connections and avoid memory leaks:

// In a tool's cleanup code:
undoBinding.dispose();

For most tools, this will already be sufficient to implement Undo/Redo!

Advanced use, frontend-framework-agnostic

However, if you want (or need) additional programmatic control, you can interact with the object returned from undoFor. Its type is Undo, which is roughly defined like this:

// Not the literal definition, but all that's relevant for this discussion
interface Undo {
  readonly canUndo: boolean;
  readonly canRedo: boolean;
  undo(options?: PressOptions): UndoResult;
  redo(options?: PressOptions): UndoResult;
  checkpoint(): void;
  subscribe(listener: (state: UndoState) => void): () => void;
  onApplied(listener: (event: AppliedEvent) => void): () => void;
  dispose(): void;
}

type UndoState = { canUndo: boolean; canRedo: boolean };

type UndoResult = {
  applied: boolean;
  patches: Patch[];
  skipped: Skipped[];
};

type AppliedEvent = UndoResult & { direction: "undo" | "redo" };
type PressOptions = { conflict?: "skip" | "force" };
type Skipped = {  path: Prop[];  reason: "conflict" | "unreachable" };
  • Looking at canUndo, canRedo and reacting to their change with subscribe are useful for updating UI elements based on the current state of the history
  • Calling checkpoint is recommended when a tool has more information about an Undo/Redo-Boundary than the provider; for example, when typing, pasting text and typing again, a User will expect three steps in the Undo-History, but our heuristics might not be sufficient to capture that
  • Adding a handler with onApplied can be useful to react to undo- and redo-events. Especially of note is the field applied in its result, which tells you if the undo-event even made any changes to the document at all (which might not be the case depending on changes of other users)
  • Calling undo and redo can be useful when providing your own controls or if you want to programmatically undo and redo multiple changes. For example, while the undo provider already skips steps that change nothing in the document, changes to the document that are not visible in the UI (like a whitespace change in a value that is formatted in the viewer anyway) can still occur, so you might want to call undo yourself once more to ensure the user always gets a visible change

Use in frameworks

For ReactJS and SolidJS, the code in this proposal also provides hooks that wrap undoFor, subscribe, unsubscribe and dispose for you:

  • for React useUndo(element: HTMLElement | null | undefined, handle: DocHandle<T> | undefined, options?: UndoOptions): (UndoState & { binding: Undo }) | undefined
  • for SolidJS: createUndo<T>(element: ElementSource, handle: Accessor<DocHandle<T> | undefined>, options?: UndoOptions): UndoControls

Caveat when building a text editor tool

When building a text editor, a tool author might decide to use a <textarea> or <div contenteditable="true"> as the HTML element containing the actual text of the document. CodeMirror and ProseMirror for example use the latter. However, because a tool might contain many such elements (as well as <input> and <select>) for purposes other than displaying and editing the actual document context, we want browsers to handle Ctrl+Z for them by default. To support this, our custom handler for Ctrl+Z (which is registered on the window object) ignores events coming from the following elements by default and lets the browser handle the shortcut:

  • any element with contenteditable or contenteditable="true"
  • <textarea>
  • <input>
  • <select>
    If a tool author wants to display the document content in any such element, they MUST mark that HTML element with data-patchwork-undo to signal to our event handler that undo/redo events originating from that element should be handled by it and not by the browser.

The helpers for CodeMirror and ProseMirror set this marker on the correct element for you.

Integration into the UI and discoverability

Currently, the implemented undo commands are keyboard shortcuts only, but because undo-provider is global like repo-provider, a frame-tool could add UI elements to trigger these actions if desired. Most probably, a new patchwork base component patchwork-undo-buttons should be created to make this as easy as possible for authors of frame-tools. A function watchUndo that allows sending undo/redo-commands and listening to UndoState of the last selected document is already implemented in the provider.

Additions to patchwork's core

The solution prototyped in this proposal requires additional functionality in core.

First, the ability to not just subscribe to a topic and receive messages that way, but also to send messages via the same connection. For this, connect was added in the prototype, which returns a Connection object:

export type Connection<C = JSONValue> = {
  send(command: C): void;
  close(): void;
};

Second, for persistent undo between document loads, we pre-populate the history with Automerge commits from our own signer. To track the signer in commits, we now populate Automerge's author field with the hex verifying key of the signer returned by loadOrCreateSigner for plain-sync repos and hive.constructSubductionSigner() (which is injected by automerge-repo-keyhive) for keyhive repos. This seems like the only sensible value for the author field anyway.

Integration with CodeMirror, ProseMirror and tldraw

These components are integrated in tools in patchwork-base and have their own history management built in. Fortunately, they're quite extensible. The prototype provides helpers for all these editors which make the integration quite simple, see ./packages/undo/src/codemirror.ts, ./packages/undo/src/prosemirror.ts and ./packages/undo/src/tldraw.ts.
However, for making tool authors' lives easier, I would propose to also add new packages @inkandswitch/patchwork-prosemirror and @inkandswitch/patchwork-codemirror which - similarly to @automerge/prosemirror and @automerge/codemirror - provide an easy to use plugin/extension that bundles and correctly initializes those helpers.
Additionally, we should of course revise the packages for those three tools in patchwork-base to use the new provider once it actually exists.

Automerge limitations

The prototype in this proposal needs to work around multiple bugs/limitations in automerge:

Open topics

  • The fact that we even need to have applied in UndoResult is annoying. Ideally, if canUndo/canRedo are true, running undo/redo would be guaranteed to succeed. However, an invisible action can occur if I undo an addition that somebody else already removed, for example. I'm not sure how to fix this best. Knowing if an undo will apply in advance is impossible without actually trying to execute it. How much of an issue this even is in practice remains to be seen.
  • Selections are currently only restored in CodeMirror and only in a pretty basic manner; if an undo action is executed, the cursor is moved to the end of the changed text. We don't remember what selections a user made in the undo history, which I don't think is much of an issue.
  • Grouping undo/redo actions across documents is currently not supported. For example, if I use a frame-tool that allows me to have multiple documents open at once and drag-and-drop an embed from one into the other, this shows up as two separate changes in each document, and Ctrl+Z would only undo it in one of the documents. This might be a pretty contrived situation and could even be what users expect to happen.
  • The current way of persisting/restoring history is somewhat limited because the value of author is the same for all tabs per browser, while the in-memory history is different for every tab. This seems like an acceptable compromise to me.
  • If multiple tools in a tab have the same document opened, they share a history. I believe this is the behavior a user would deem correct and expected, but there might be usecases where this isn't the case.

Closing words

There is a much more detailed version of this proposal in ./docs/proposals/document-undo.md; I did my best to comb through it and make the AI-speak palatable, but I feel like this PR description is the better overview.
There's also more detailed documentation in ./packages/undo/README.md, which is surprisingly readable.

One thing I really didn't touch upon in this writing is how a change is actually undone, mostly because I don't think this matters much for the proposal or the UX. I've read the code and Claude's specification (see 4.9 Inverting a step) on that and it just seems like uninteresting tedium that I'm glad to have a machine take care of.

The code in this proposal is quite well tested, benchmarked and fuzzed, but I wouldn't say it's ready to merge. Let's talk about the general idea and API first, and if we agree that we want to move forward with this, I'll get this ready for prime-time.

Disclosure of AI usage

I used Claude Opus 5.5 for the entire prototype. Almost none of the code in this proposal was written by me directly. The entire text in this PR description was written by me personally to ensure that I understand to a reasonable degree what the code I'm submitting actually does. I've reviewed and manually rewritten some parts of the proposal document.

iFreilicht and others added 8 commits October 27, 2026 00:49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWZt33guhcqjUKM8uw4izp
The new @inkandswitch/patchwork-undo package gives every tool undo of its
document edits through undoFor(element, handle): one history per realm and
document, shared by every tool editing it, undoing only the realm's own
changes as new changes, with undo runs that the next edit records as a
step. It binds Cmd/Ctrl+Z once per page, and an <undo-provider>, mounted by
setup() next to <repo-provider>, lets host UI reach any document's history.
Helpers integrate CodeMirror and tldraw; React and Solid get bindings.

@inkandswitch/patchwork-providers gains connect() and onCommand, so a
consumer can send commands back over its subscription.

Tests: unit and collaboration scenarios, a fuzzer against a snapshot
oracle, comparisons with CodeMirror's and tldraw's own histories, and
Playwright end-to-end tests with two browser profiles. The design is in
docs/proposals/document-undo.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@inkandswitch/patchwork-undo/prosemirror's undoBoundaries plugin replaces
prosemirror-history: it marks the editor so Cmd/Ctrl+Z goes to the
service, and ends a step at closeHistory(tr). Rich text's block markers
now count for text adjacency, and a restored block marker is inserted with
splitBlock, so @automerge/prosemirror sees a block. A comparison test runs
the same transactions against prosemirror-history.

Editors now hand their keys to the service only when marked with
data-patchwork-undo (CodeMirror's undoBoundaries marks its editor), so
unbound editors such as comment drafts keep their own undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Undo now restores formatting: a step's mark and unmark operations are
inverted per range, each part back to the format it had before, with the
same conflict policy as values. Re-inserted characters and copied texts
get their formatting back, and visibility compares formatting, which a
text's JSON doesn't show. Fixes found by fuzzing with formatting: ranges
are mapped through a change's sequential patches, several formatting
operations in one change are simulated in order, re-inserted characters
stay in order next to copies placed past tombstones, and a restored
object copy loses edits made inside it within an undo run.

The ProseMirror comparison test now covers bold, unbold and typing in
formatted text; the fuzzer sets and removes bold, italic and links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Repo Patchwork builds (each tab's and the automerge protocol
handler worker's, on plain and keyhive sites) passes its signer's hex
verifying key as the Automerge author, so getActorsForAuthor lists the
actors a browser profile wrote as, across reloads. The automerge-repo
patch adds RepoConfig.author: documents are created with it, and it is
set again after every mutation, because a load into an empty document
drops it. signerIdentity(signer) in the bootloader's signer module gives
the peer id and key; patchwork.signer is now set on keyhive sites too.

The undo proposal's Persistence issue describes the author and its
limits; rebuilding steps from it is not implemented yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new history rebuilds steps from the realm's earlier changes: its own
actor's and those of every actor with the document's Automerge author,
which Patchwork sets to the signer's key. The newest 10 steps are
rebuilt at once and the next 10 whenever undo reaches the oldest rebuilt
one (UndoOptions.restore; 0 turns it off), so a long history costs one
metadata read, not decoding every change.

Rebuilt changes of one actor at most a second apart form a step, since
history keeps no checkpoints or millisecond times. Undo and redo presses
carry the change message "patchwork-undo" (PRESS_MESSAGE), so an earlier
run comes back as one step. A document's first change and the one that
sets @Patchwork are never rebuilt: undoing them would take away the
structure tools expect.

The inverse compared changes by seq, which counts each actor's changes
separately; it now uses each change's position in the document's change
order. The proposal describes restoring in 4.15 and its limits in 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name the JS wrapper in @automerge/automerge as the culprit and the wasm
Automerge.marks as the correct function the engine calls, and soften the
editor's note on 4.9 to ask for a rewrite only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lead item 2 with the actual bug (a historical cursor lookup that works
until the document changes) and note what a fix removes. Place item 4's
panic in the Rust core, cite automerge#1593, whose fix is on main but
not yet released, and note what the fix changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iFreilicht
iFreilicht marked this pull request as draft November 1, 2026 10:03
@iFreilicht

Copy link
Copy Markdown
Contributor Author

@pvh tagging you as requested :)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant