From 8f8dfc0a10e502eaac229d69fbe2258ba01ec50a Mon Sep 17 00:00:00 2001 From: jackkav Date: Fri, 7 Aug 2026 17:40:23 +0200 Subject: [PATCH] fix(undo): preserve editor undo/content when toggling markdown write/preview and request-script tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling markdown write<->preview or the pre/after-response script tabs unmounts and remounts a CodeEditor while it is hidden. Undo (and, for markdown, the content) was clobbered: - persistState bailed out early when the hidden editor reported a zero-size viewport, so it never saved the undo history. Now it always persists history (and cursor/selections/marks); only the layout-dependent scroll position is skipped when invalid. - The editor-state cache now also persists the editor VALUE plus the defaultValue baseline it was cached against (valueSeed). On remount the cached value + history are reused ONLY when the current defaultValue is consistent with what was cached — it equals the cached value (the model caught up / saved that content, e.g. the markdown editor's debounced onChange landed) or the seed (unsaved edits on an unchanged model). If it matches neither, the model changed externally between two mounts sharing a historyKey (a script/sync updated the environment/response, or a .spectral.yaml edit changed a ruleset), so the fresh defaultValue wins and the stale value/history are dropped. Read-only editors never cache a value; they are always authoritative from defaultValue. - The request-pane description and pre/after-response script editors used a volatile React key that remounted them on every revalidation, resetting state and clobbering undo. They now use stable per-request keys (content does not depend on env/response). Also corrects the markdown editor's TabList aria-label (was 'Request scripts tabs'). Covered by an e2e smoke test: both markdown write/preview and pre/after-response script toggles keep undo working after switching back. --- .../tests/smoke/editor-toggle-undo.test.ts | 88 +++++++++++++++++++ packages/insomnia/src/entry.preload.ts | 5 +- .../.client/codemirror/code-editor.tsx | 41 +++++++-- .../.client/codemirror/editor-state-cache.ts | 9 ++ .../src/ui/components/markdown-editor.tsx | 2 +- .../src/ui/components/panes/request-pane.tsx | 18 +++- 6 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 packages/insomnia-smoke-test/tests/smoke/editor-toggle-undo.test.ts diff --git a/packages/insomnia-smoke-test/tests/smoke/editor-toggle-undo.test.ts b/packages/insomnia-smoke-test/tests/smoke/editor-toggle-undo.test.ts new file mode 100644 index 000000000000..6f0d2abee00a --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/editor-toggle-undo.test.ts @@ -0,0 +1,88 @@ +import { expect, type Page } from '@playwright/test'; + +import { test } from '../../playwright/test'; + +// Toggling a mode/sub-tab that hides a CodeEditor (markdown write<->preview, +// pre/after-response scripts) unmounts it while it has no viewport size. Its undo +// history must still be persisted and restored so Cmd/Ctrl+Z keeps working after +// toggling back. See docs/undo-redo-baseline.md. + +const isMac = process.platform === 'darwin'; +const MD_SEL = 'div.editor__container:has(textarea#markdown-editor) .CodeMirror'; +const PRE_SEL = 'div.editor__container:has(textarea[id$="pre-request-script"]) .CodeMirror'; + +const readState = (page: Page, sel: string) => + page.evaluate((s: string) => { + const node = document.querySelector(s) as any; + const cm = node?.CodeMirror; + return { + value: cm?.getValue() as string, + undo: cm?.historySize().undo as number, + }; + }, sel); + +test('markdown editor: toggling write/preview keeps undo history', async ({ page }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + await page.getByRole('tab', { name: 'Docs' }).click(); + + // Scope everything to the Docs panel: the markdown editor's DOM id is not unique + // app-wide, so target the one CodeEditor inside this tabpanel. + const docs = page.getByRole('tabpanel', { name: 'Docs' }); + const mdCm = docs.locator(MD_SEL); + const readCm = () => + mdCm.evaluate((node: any) => ({ + value: node.CodeMirror?.getValue() as string, + undo: node.CodeMirror?.historySize().undo as number, + })); + + // Click into the editor to focus it reliably, then type. + await mdCm.click(); + await page.keyboard.type('hello markdown undo'); + await expect.soft(mdCm).toContainText('hello markdown undo'); + const before = await readCm(); + expect.soft(before.undo).toBeGreaterThan(0); + + // Toggle to Preview (unmounts the editor) and back to Write (remounts it). + const mdTabs = docs.getByRole('tablist', { name: 'Markdown editor tabs' }); + await mdTabs.getByRole('tab', { name: 'Preview' }).click(); + await expect.soft(mdCm).toBeHidden(); + await mdTabs.getByRole('tab', { name: 'Write' }).click(); + await expect.soft(mdCm).toContainText('hello markdown undo'); + + // Value preserved AND the undo stack restored across the remount (was clobbered + // before the fix). + const after = await readCm(); + expect.soft(after.value).toContain('hello markdown undo'); + expect.soft(after.undo).toBe(before.undo); + + // Undo works on the remounted editor: it reverts the edit made before the toggle. + await mdCm.click(); + await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z'); + await expect.poll(async () => (await readCm()).value).not.toContain('hello markdown undo'); +}); + +test('request scripts: toggling pre/after-response keeps undo history', async ({ page }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + await page.getByRole('tab', { name: 'Scripts' }).click(); + + await page.locator(`${PRE_SEL} textarea`).first().focus(); + // Plain text: avoids JS-mode bracket/quote auto-closing that would skew assertions. + await page.keyboard.type('undoMarkerVariable'); + await expect.soft(page.locator(PRE_SEL).first()).toContainText('undoMarkerVariable'); + expect.soft((await readState(page, PRE_SEL)).undo).toBeGreaterThan(0); + + // Toggle to After-response (unmounts the pre-request editor) and back. + const scriptTabs = page.getByRole('tablist', { name: 'Request scripts tabs' }); + await scriptTabs.getByRole('tab', { name: 'After-response' }).click(); + await expect.soft(page.locator(PRE_SEL)).toBeHidden(); + await scriptTabs.getByRole('tab', { name: 'Pre-request' }).click(); + await expect.soft(page.locator(PRE_SEL).first()).toContainText('undoMarkerVariable'); + + const after = await readState(page, PRE_SEL); + expect.soft(after.value).toContain('undoMarkerVariable'); + expect.soft(after.undo).toBeGreaterThan(0); + + await page.locator(`${PRE_SEL} textarea`).first().focus(); + await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z'); + await expect.soft(page.locator(PRE_SEL).first()).not.toContainText('undoMarkerVariable'); +}); diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 5c76ba630e90..b089e9b1e160 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -299,7 +299,10 @@ const main: Window['main'] = { deleteCompiledRuleset: options => invokeWithNormalizedError('deleteCompiledRuleset', options), refreshCompiledRuleset: options => invokeWithNormalizedError('refreshCompiledRuleset', options), writeResponseBodyToFile: options => invokeWithNormalizedError('writeResponseBodyToFile', options), - getAuthHeader: (renderedRequest: RenderedRequest, url: string): Promise<{ header?: RequestHeader; timeline?: ResponseTimelineEntry[] }> => + getAuthHeader: ( + renderedRequest: RenderedRequest, + url: string, + ): Promise<{ header?: RequestHeader; timeline?: ResponseTimelineEntry[] }> => invokeWithNormalizedError('getAuthHeader', renderedRequest, url), getOAuth2Token: ( requestId: string, diff --git a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx index 76d14be046a8..95697e48fa10 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx @@ -378,12 +378,21 @@ export const CodeEditor = memo( const persistState = useCallback(() => { if (historyKey && codeMirror.current) { const scrollInfo = codeMirror.current.getScrollInfo(); - // ignore invalid scroll positions - if (scrollInfo.height <= 0 || scrollInfo.width <= 0) { - return; - } + // A hidden or unmounting editor reports a zero-size viewport, so only + // persist a valid scroll position — but ALWAYS persist history (and + // cursor/selections/marks), which are layout-independent. Bailing here + // would drop the undo stack whenever the editor is toggled away while + // hidden (markdown write<->preview, pre/after-response script tabs). + const scroll = scrollInfo.height > 0 && scrollInfo.width > 0 ? scrollInfo : undefined; setCachedEditorState(historyKey, { - scroll: scrollInfo, + scroll, + // Only cache the value for editable editors, whose content can diverge + // from `defaultValue` (unsaved edits). Read-only editors are always + // authoritative from `defaultValue`. `valueSeed` records the model + // baseline so restore can tell an unchanged model (reuse the cached + // value + history) from an externally-updated one (use fresh defaultValue). + value: readOnly ? undefined : codeMirror.current.getValue(), + valueSeed: readOnly ? undefined : defaultValue ?? '', selections: codeMirror.current.listSelections(), cursor: codeMirror.current.getCursor(), history: codeMirror.current.getHistory(), @@ -401,7 +410,7 @@ export const CodeEditor = memo( }), }); } - }, [historyKey, codeMirror]); + }, [historyKey, codeMirror, readOnly, defaultValue]); const initEditor = useCallback(() => { if (!textAreaRef.current) { @@ -567,11 +576,27 @@ export const CodeEditor = memo( // Restore the state const cachedState = historyKey ? getCachedEditorState(historyKey) : undefined; if (cachedState) { - const { scroll, selections, cursor, history, marks } = cachedState; + const { scroll, selections, cursor, history, marks, value, valueSeed } = cachedState; + // Reuse the cached content + undo stack only when the current model is + // consistent with what was cached, i.e. `defaultValue` equals either: + // - the cached value -> the model caught up to / saved that content + // (e.g. markdown's debounced onChange landed), or + // - the cached seed -> the model is unchanged and the cached value is + // just unsaved edits on top of it. + // If it matches neither, the model changed externally between two mounts + // sharing a historyKey (a script/sync updated the environment/response), + // so the fresh defaultValue wins and the stale value + history are dropped. + // Restore the value first so it stays consistent with the restored undo + // stack; the setHistory below replaces the throwaway history setValue creates. + const currentDefault = defaultValue ?? ''; + const canReuse = value !== undefined && (currentDefault === value || currentDefault === valueSeed); + if (canReuse && value !== codeMirror.current.getValue()) { + codeMirror.current.setValue(value); + } if (scroll) { codeMirror.current.scrollTo(scroll.left, scroll.top); } - if (history) { + if (canReuse && history) { codeMirror.current.setHistory(history); } // NOTE: These won't be visible unless the editor is focused diff --git a/packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts b/packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts index aabf8027b6f4..3329f083cd8f 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/editor-state-cache.ts @@ -6,6 +6,15 @@ import type CodeMirror from 'codemirror'; // fields are only populated by the richer multi-line CodeEditor. export interface CachedEditorState { history: any; + // The editor content at unmount, plus the `defaultValue` baseline it was cached + // against. On remount the value + history are only reused when the current + // `defaultValue` still equals `valueSeed` — i.e. the model has NOT changed + // externally since. This keeps content consistent with the restored history when + // `defaultValue` merely lags (markdown write<->preview toggle), while letting a + // genuinely-updated model win (e.g. a pre-request script or sync changed the + // environment/response between two mounts sharing a historyKey). + value?: string; + valueSeed?: string; scroll?: CodeMirror.ScrollInfo; selections?: CodeMirror.Range[]; cursor?: CodeMirror.Position; diff --git a/packages/insomnia/src/ui/components/markdown-editor.tsx b/packages/insomnia/src/ui/components/markdown-editor.tsx index f94a089e890d..d3334a04ec7e 100644 --- a/packages/insomnia/src/ui/components/markdown-editor.tsx +++ b/packages/insomnia/src/ui/components/markdown-editor.tsx @@ -38,7 +38,7 @@ export const MarkdownEditor = forwardRef( > = ({ environmentId, settings, onPaste }) => - + = ({ environmentId, settings, onPaste }) => - + = ({ environmentId, settings, onPaste }) =>