Files
insomnia/docs/undo-redo-baseline.md
Jack Kavanagh 1b4f44f0c3 feat(undo): reconcile native + CodeMirror undo via one app-level handler (#10167)
The Edit menu's Undo/Redo used role: 'undo'/'redo', driving only the native
webContents stack. On macOS the menu accelerator swallows Cmd+Z before
CodeMirror's keymap, so CodeMirror surfaces had no working undo — two competing
stacks with the native one always winning and doing nothing useful for CM.

Menu Undo/Redo now send edit:undo/edit:redo to the focused window; a single
renderer handler (editor-undo.ts, wired in renderer-listeners.ts) routes by
focus: CodeMirror surfaces drive cm.undo()/redo(); everything else replays the
native execCommand, preserving prior plain-input behaviour. No double-fire: the
accelerator still keeps CM's own keymap from firing independently.
2026-07-08 08:49:00 -04:00

14 KiB

Undo/Redo Baseline

Catalog of the current undo/redo behaviour across Insomnia's text-input surfaces, established before attempting any improvement. Findings below are split into confirmed by code, confirmed at runtime (Playwright probe against the dev build), and open questions.

TL;DR

  • There are three input technologies, each with different undo semantics.
  • CodeMirror editors have built-in undo history; plain inputs rely on native browser/OS undo.
  • The dominant defect is that undo history is destroyed on component remount, and the single-line editor (OneLineEditor) has no mechanism to restore it (the multi-line CodeEditor does, via a module-level cache — but only when its cache key is stable).
  • A native Electron Edit menu binds Cmd/Ctrl+Z to the OS-level undo, which competes with CodeMirror's internal history.

The three input technologies

A. Multi-line CodeMirror — CodeEditor

code-editor.tsx

Aspect Behaviour Ref
Undo engine CodeMirror built-in history
Init initEditor runs once via useMount; defaultValue applied on mount only → uncontrolled :560
Seed guard clearHistory() after first setValue so the seed isn't undoable :488
External writes maybePrettifyAndSetValue no-ops when value is unchanged :296
Remount survival History is persisted to module-global editorStates[uniquenessKey] (getHistory()) and restored (setHistory()) on re-init — but only if uniquenessKey is unchanged across the remount :324, :503
Persist to model debounced onChange, DEBOUNCE_MILLIS = 100 :598

Consumers (~21): raw body, GraphQL query/variables, environment JSON editor, request headers/params editors, request-script, markdown, mock response, code-prompt modal, etc.

B. Single-line CodeMirror — OneLineEditor

one-line-editor.tsx

Aspect Behaviour Ref
Undo engine CodeMirror built-in history
Init initEditor once via useMount, defaultValue on mount only → uncontrolled :255
Seed guard clearHistory() after first set :221
Remount survival None. No editorStates equivalent — history is destroyed on every remount
setValue handle Preserves cursor but cm.setValue() clears CM undo history :366
Persist to model debounced onChange (100ms) + flush on blur :295, :304

Consumers (~14): URL bar, all key-value rows (name/value/description for headers, query, form-data, env), auth-input rows, cookies modal, WebSocket/gRPC/Socket.IO URL + panes, MCP url bar.

The "uncontrolled + manual setValue" design is deliberate — see the comment at request-pane.tsx:71-75: controlling the editor would make typed input lag behind the model round-trip.

C. Plain inputs — React Aria Input/TextField, raw <input>/<textarea>

Approx counts in packages/insomnia/src/ui: ~79 <Input>, ~42 <TextField>, ~19 <input>, ~10 <textarea>. These are mostly controlled (value + onChange). Undo relies on the browser/OS native undo stack. A controlled input that re-renders on every keystroke can reset that native stack — but this is not universal (see runtime results).

Native Electron Edit menu

window-utils.ts:305-342 defines an Edit menu:

  • Undorole: 'undo', accelerator CmdOrCtrl+Z
  • Redorole: 'redo', accelerator Shift+CmdOrCtrl+Z

These originally mapped to Electron's native webContents.undo()/redo(), which act on the focused native editable element — independent of CodeMirror's internal history. This is why plain inputs got working undo "for free", and why there were two competing undo stacks in CodeMirror surfaces. This is now reconciled — the menu items route through one app-level handler (see opportunity 4 below).

Remount triggers

The request pane builds a composite key and applies it to the URL bar editor and body editor (request-pane.tsx:82):

const uniqueKey = `${activeEnvironment?.modified}::${requestId}::${gitVersion}::${vcsVersion}::${activeRequestMeta?.activeResponseId}`;

Any change to a segment remounts the editor:

  • Sending a requestactiveResponseId changes.
  • Editing an environmentactiveEnvironment.modified changes.
  • Git/Sync version bumpgitVersion / vcsVersion changes.
  • Initial load settling after creating/opening a request (observed below).

On remount, OneLineEditor loses all undo history; CodeEditor can only restore it if the uniquenessKey is unchanged — but several of these triggers change the key itself, defeating the restore.

Runtime findings (Playwright probe, dev build)

Method: drive the real Electron renderer, type via keyboard, read CodeMirror state directly (node.CodeMirror.historySize(), getValue()). Probe was temporary and has been removed.

Scenario Observation
URL bar, stable pane, type then settle undoDepth = 1 — history retained
URL bar typed as the first mutation after creating a collection, then settle undoDepth = 1 immediately → 0 after revalidation settles (initial-load remount wiped it)
Body editor, stable pane, type then settle undoDepth = 1 — history retained
Body editor, then switch tab away and back (remount) undoDepth = 0, value preserved — remount wipes history
Body editor, real Cmd+Z Undid the edit ({"a":1}{"a"})
URL bar, real Cmd+Z (single run) Value unchanged; focus retained — Cmd+Z did not undo
Sidebar filter (plain controlled input), real Cmd+Z Native undo worked ("abc""")

What this confirms

  1. Remount is the dominant history-killer. A stable editor keeps its undo stack across the 100ms persist/revalidation cycle; a remount destroys it (value is re-seeded from the model, history is not).
  2. OneLineEditor is the most exposed because it has no history-restore cache and its consumers (URL bar, key-value rows) are keyed on volatile composite keys.
  3. "All inputs have undo disabled" is not universally true — at least one plain controlled input (sidebar filter) has working native undo.

Resolved: why the URL bar lost undo / focus

Follow-up probing (real keyboard + MutationObserver) showed the URL bar's OneLineEditor remounts ~once shortly after the first edit: the first patchRequest triggers a loader revalidation, which changes a volatile segment of the editor's React key (uniqueKey = activeEnvironment?.modified::requestId::gitVersion::vcsVersion::activeResponseId). The remount blurs the editor and drops its undo history. So a Cmd+Z right after typing finds an already-blurred, empty-history editor — exactly the "re-render + loss of focus" symptom. The earlier "history non-empty when stable" reading was just a timing window before that remount landed. Confirmed: a stable editor keeps focus and undoes correctly; the remount is the disease.

Improvement opportunities (ranked, least disruptive first)

  1. DONE. Give OneLineEditor history persistence across remounts via a shared editor-state-cache (also used by CodeEditor), keyed by a stable uniquenessKey.
  2. DONE. Narrow the URL bar editor's remount key to requestId::environmentId::environment.modified so it refreshes on request/environment change but not on sends or local edits (the focus-loss paths); external value changes resync in place via OneLineEditor's defaultValue effect.
  3. DONE. setValue is non-destructive — it replaces the value via replaceRange (history preserved, undoable) and no-ops when unchanged.
  4. DONE. Reconcile the two undo stacks (native menu vs. CodeMirror) through one app-level handler. The Edit menu's Undo/Redo no longer use role: 'undo'/'redo' (which only drove the native stack and left CodeMirror's Cmd+Z inert behind the menu accelerator); they now send edit:undo/edit:redo to the focused window. A single renderer handler (editor-undo.ts, wired in renderer-listeners.ts) routes by focus: a CodeMirror surface drives cm.undo()/redo(); anything else replays the browser's native edit command (execCommand), preserving the old behaviour for plain inputs.
  5. Plain controlled inputs (Category C). Largest surface, lowest per-item value; defer unless a specific input is reported.

Suggested characterization tests (pin current behaviour before changing it)

  • E2E (Playwright): type in URL bar → assert undo depth retained when stable; Send → assert remount occurs and history is lost (documents the bug); body editor → assert tab-switch remount loses history. These lock the baseline so a fix's improvement is measurable.
  • Unit (Vitest): shouldIndentWithTabs and any extracted history save/restore helper.
  • Co-locate per AGENTS.md; E2E under packages/insomnia-smoke-test/.