Skip to content

[diffs] Refactor Edit Persistance - #1092

Merged
amadeus merged 20 commits into
amadeus/edit-complete-handlerfrom
amadeus/remove-editor-persistance
Aug 28, 2026
Merged

[diffs] Refactor Edit Persistance#1092
amadeus merged 20 commits into
amadeus/edit-complete-handlerfrom
amadeus/remove-editor-persistance

Conversation

@amadeus

@amadeus amadeus commented Aug 24, 2026

Copy link
Copy Markdown
Member

Preamble

Another fairly beefy PR in the editing stack. Similar to the previous branches, I don't expect every line to get a deep review, but I do want to explain the changes at a high level.

This builds on the edit completion work from #1089. That branch gave components a clear accept/reject boundary, and now the big remaining piece to fix from the start of our stack -- refactoring state persistance to not be based on cacheKey, and be less magical and more flexible.

The old persistence system grew out of a narrower requirement: keep some editor state when switching between files. It eventually became responsible for a mix of documents, history, selections, scroll positions, worker cache keys, IndexedDB, and component lifecycle behavior. I think this ended up being a bit less flexible and piggy-backed on some concerns around how Worker Pool caching worked that wasn't necessarily compatible with these requirements.

This PR replaces that machinery with explicit edit-session retention and state transfer. It also gives applications a supported way to inspect, move, restore, or clear an editing session without treating internal editor state as a JSON storage format.

The problem

Before this branch, editor persistence was enabled with persistState and was
implicitly identified by FileContents.cacheKey. That caused a few issue:

  • A rendering cache key also became the identity of an editing session, even though those are different concepts with different invalidation rules.
  • Documents and undo history were retained inside one Editor, while selections and scroll state could be sent to a separate in-memory, IndexedDB, or custom storage adapter.
  • The built-in durable storage saved only part of the state needed to resume an edit. It could restore a cursor or viewport, but not the live document and its undo history across a reload.
  • Editor reuse depended on component and provider implementation details, including a React cache keyed by the identity of an editorOptions object.
  • File and FileDiff sessions did not have an explicit type boundary, which made it easier to restore state into the wrong document model.
  • Scroll ownership was inferred from the DOM. An item editor could accidentally capture a page or shared CodeView scroll position that it did not own.
  • Collapse, virtualization, completion, and permanent cleanup all moved through overlapping paths, so it was easy to preserve too much state or throw away history that should have survived.

The result was some state belonged to the application, some to the component, some to the live editor, and some to a best-effort storage layer.

The new retention model

The main change is a new EditStateManager, which retains complete editing sessions using an application-provided editStateKey.

The key gives an editing session a stable identity outside any one component or Editor instance. When that editor goes away, the manager can hold onto the session-wide state needed to continue later. A new editor using the same key can pick up the draft, undo/redo history, selections, and relevant component state without the application rebuilding those pieces itself.

This makes retention deliberate instead of tying it to FileContents.cacheKey or an editorOptions object. Applications decide which sessions receive keys, how many inactive sessions can be retained, and when retained state should be inspected, partially cleared, or removed entirely.

Most applications only need to provide a stable key and let the manager handle the session lifecycle. Applications with more specific persistence requirements can use EditStateManager directly, or capture state from an editor and provide it as initialState when constructing another editor.

The manager is intentionally an in-memory system. Applications that need state to survive a reload still own that durable storage boundary, which is covered later in this description.

Keyed edit-session retention

The old persistState switch and persistStateStorage option are gone. An application now opts into retention by assigning a stable editStateKey:

<File file={file} edit={editing} editStateKey={`draft:${fileId}`} />

Vanilla editors receive the same identity at construction:

const editor = new Editor('file', options, `draft:${fileId}`);

When the session becomes inactive, its document, history, selections, and eligible viewport state move into EditStateManager. A later editor with the same document kind and key can resume the session.

The document kind is part of the identity. File and FileDiff sessions use separate namespaces, and every Editor is now constructed for exactly one of those kinds:

new Editor('file', options, editStateKey);
new Editor('file-diff', options, editStateKey);

This prevents a file document from being attached to a FileDiff, or a retained diff session from being interpreted as a plain file.

Retention is synchronous and limited to the current in memory session. Each document-kind namespace keeps up to 100 inactive retained sessions by default. Active sessions do not count toward that limit and cannot be evicted or cleared out from under their editor.

The manager exposes a small application-facing API for inspecting and clearing state:

EditStateManager.get('file', editStateKey);
EditStateManager.clear('file', editStateKey);
EditStateManager.clear('file', editStateKey, { history: true });
EditStateManager.clearAll();
EditStateManager.setCapacity(50);

Save and Cancel are separate from state retention. Returning 'accept' keeps the edited contents in the component, while returning 'reject' restores the original contents. Neither choice deletes the keyed session from EditStateManager. If the same editStateKey is used again, the editor resumes that session and its history. Call EditStateManager.clear() after editing ends when the next edit should start fresh.

State capture and transfer

Editors now expose two different state APIs:

editor.getViewState();
editor.setViewState(viewState);

editor.getEditState();

getViewState() returns a defensive copy of selections and editor-owned viewport offsets. setViewState() applies that copy to an attached editor.

getEditState() returns the complete session at its latest lifecycle checkpoint. It remains available while rendering is recycled and during onEditComplete, which lets an owner transfer the final state while it is still readable from the editor:

const state = event.editor.getEditState();

if (state != null) {
  const nextEditor = new Editor(state.documentKind, {
    initialState: state,
  });
}

The editor adopts initialState; it does not clone it. Partial initial state is also supported. Missing fields are completed from the component on first attachment, which allows an application to restore only a document, file metadata, or view state when that is all it owns.

The change and completion events now carry the relevant editor instead of embedding another state snapshot into every event. Consumers can pull the exact level of state they need:

onEditChange(event) {
  persistFile(event.file);
  persistView(event.editor.getViewState());
}

FileDiff state

A FileDiff session has more state than the editable new-side document. It also needs the old-side baseline and hunk information used to interpret that document.

The complete FileDiff EditState therefore includes a diffSession. An edited document and its undo/redo history must travel with the matching diff session. Restoring the document without that metadata could combine new-side text with an unrelated old file and produce an invalid diff.

Partial diffs still need complete file contents before they can be edited. When those files are not initially present, loadDiffFiles can hydrate them asynchronously before the editing session is started.

Viewport ownership

The previous editor state walked the DOM to find a scroll container. That made vertical scroll restoration ambiguous for components inside a larger page, virtualizer, or CodeView.

Viewport ownership is now explicit:

  • Horizontal scroll belongs to the editable component and is retained with its editor state.
  • Vertical scroll is included only when ownsVerticalViewport: true is supplied when the editor is created.
  • CodeView item editors never own CodeView's shared vertical viewport.
  • Page and ancestor scroll positions remain application or container state.

This keeps item-local state with the item without making one editor responsible for moving an entire page or virtualized list when it resumes.

React and CodeView

The React EditProvider and vanilla CodeView factory signatures now pass the document kind and edit-state key through to editor construction:

createEditor(documentKind, options, editStateKey) {
  return new Editor(documentKind, options, editStateKey);
}

React no longer caches editors by editorOptions object identity. A factory creates a fresh editor for a new session, while editStateKey provides explicit continuity when that session should resume. Changing the provider, options, or key does not replace an active editor; those values apply when the next session is created.

File, FileDiff, MultiFileDiff, and PatchDiff all forward editStateKey. CodeView uses getEditStateKey(item) so each item can derive a stable key from application identity:

const codeView = new CodeView({
  getEditStateKey(item) {
    return `review:${reviewId}:${item.id}`;
  },
  createEditor(documentKind, options, editStateKey) {
    return new Editor(documentKind, options, editStateKey);
  },
});

Collapse and virtualization continue to recycle the same editor without ending the edit session. Removing an item or turning edit mode off completes the session. A later editor can resume it only when that item has an editStateKey.

Completion and teardown

This branch tightens the lifecycle introduced in #1089 so the editor owns the entire completion sequence.

The disposer returned by editor.edit(component) remains the normal way to finish a session. It detaches the editor and completes the component with the correct install behavior.

Cleanup reasons now have distinct behavior:

editor.cleanUp('complete'); // Finish and allow an accepted result to install.
editor.cleanUp('discard'); // Finish, but never install a result.
editor.cleanUp('recycle'); // Temporarily detach without completing.

A changed session still publishes onEditComplete during discard so the application can observe or persist the final value, but an accepted return value is ignored because the component is being removed. Recycle keeps the private document and history alive for collapse and virtualization.

Unchanged, annotation-only, selection-only, and fully undone sessions still do not call onEditComplete. Save and Cancel controls therefore reset their own intent when editing starts and when a completion action is chosen, rather than depending on a completion callback that may not run. The docs examples and playground now follow that rule consistently.

Durable persistence

The old IndexedDB and custom IStateStorage adapters have been removed. They could serialize selections and viewport offsets, but they could not serialize a complete live session safely.

Durable storage is now deliberately application-owned:

  • Persist the latest FileContents from onEditChange or an explicit save point.
  • Optionally persist editor.getViewState() when selections or viewport state should survive a reload.
  • Rebuild a fresh TextDocument and pass it through initialState when loading the draft again.
  • For FileDiff, persist the complete old file and latest new file, then rebuild the component from those application values.

Complete EditState should not be serialized. It contains a live document, history entries, diff metadata, and potentially application annotation values that are not JSON-safe. Durable restoration intentionally starts a fresh undo timeline.

EditStateManager retains complete sessions in memory, while the application decides how and where durable file data is stored.

API cleanup

The public names now distinguish view state from complete edit state:

EditorState          -> EditorViewState
EditorViewState      -> EditorViewportState
getState()           -> getViewState()
setState()           -> setViewState()

The editor constructor, React provider, and CodeView factory changes are breaking because the document kind and key are now explicit creation-time inputs. The migration guide linked above has the direct before/after mappings.

@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pierre-diffshub Ready Ready Preview Aug 28, 2026 7:39am
pierre-docs-diffs Ready Ready Preview Aug 28, 2026 7:39am
pierre-docs-trees Ready Ready Preview Aug 28, 2026 7:39am
pierrejs-diff-demo Ready Ready Preview Aug 28, 2026 7:39am

Request Review

@blacksmith-sh

This comment has been minimized.

* in memory so a later editor using the same kind and key can resume them.
*/
constructor(
documentKind: EditorDocumentKind,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe introduce DiffsEditor and FileEditor instead of argument?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a possibility, but felt like that was yet another large refactor that would happen on top of an already large refactor heh, so figured this was simpler and matched what we do in other places with like InteractionManager

@amadeus
amadeus force-pushed the amadeus/remove-editor-persistance branch from 7d30437 to a92d427 Compare August 25, 2026 00:14
amadeus added 20 commits August 28, 2026 00:37
This cleans up a ton of internal code around session persistance.

Also fixed a bug with collapsed state resetting undo state when it
shouldn't
Still some rough edged to be worked out in the next couple phases.
This is a lot, and I had to fight my way through a bunch of different
things, but I think we also fixed a bunch of bad bugs alogn the way.

We've now hooked up documentKey retention.
This turned out to be a bit bigger than I originally thought because
there was a bunch of bullshit that wasn't properly accounted for and the
AI did definitely do some slop.

Anyways, I think we've landed somewhere that's really good, the default
persistance works really nicely now with the homepage stuff, and doesn't
require any hacky bullshit
Also hook it up in the agent ui
About to be rewritten
Getting the plumbing in place for the new API
Alright, final docs, test fixes and some last minute API tweaks.

I think we got this refactor in the books
Also make sure cancellation vs save is properly exemplified and works
properly in the playground.
This simplifies a lot of stuff around annotations getting changed
despite content changes removing them and then reverting back to the original.
A `bug` was found if the internal component editor lifecycle methods are
use improperly. This would go against how the component was designed to
be used, so I've gone ahead and intentionally marked them as internal
and alligned their naming conventions to include __
@amadeus
amadeus merged commit 407925c into main Aug 28, 2026
8 checks passed
@amadeus
amadeus deleted the amadeus/remove-editor-persistance branch August 28, 2026 19:23
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.

3 participants