Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
});
5 changes: 4 additions & 1 deletion packages/insomnia/src/entry.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -401,7 +410,7 @@ export const CodeEditor = memo(
}),
});
}
}, [historyKey, codeMirror]);
}, [historyKey, codeMirror, readOnly, defaultValue]);

const initEditor = useCallback(() => {
if (!textAreaRef.current) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/insomnia/src/ui/components/markdown-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const MarkdownEditor = forwardRef<CodeEditorHandle, Props>(
>
<TabList
className="flex h-(--line-height-sm) w-full shrink-0 items-center gap-2 overflow-x-auto border-b border-solid border-b-(--hl-md) bg-(--color-bg) px-2"
aria-label="Request scripts tabs"
aria-label="Markdown editor tabs"
>
<Tab
className="flex h-(--line-height-xxs) w-42 shrink-0 cursor-pointer items-center justify-between rounded-md px-2 py-1 text-sm text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-[rgba(var(--color-surprise-rgb),50%)] hover:text-(--color-font-surprise) aria-selected:bg-[rgba(var(--color-surprise-rgb),40%)] aria-selected:text-(--color-font-surprise)"
Expand Down
18 changes: 15 additions & 3 deletions packages/insomnia/src/ui/components/panes/request-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,12 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
</Tab>
</TabList>
<TabPanel className="w-full flex-1" id="pre-request">
<ErrorBoundary key={uniqueKey} errorClassName="tall wide vertically-align font-error pad text-center">
<ErrorBoundary
// Stable per-script key: a volatile key remounts the editor on every
// revalidation, clobbering undo. Scripts don't depend on env/response.
key={`${activeRequest._id}:pre-request-script`}
errorClassName="tall wide vertically-align font-error pad text-center"
>
<RequestScriptEditor
historyKey={`${activeRequest._id}:pre-request-script`}
defaultValue={activeRequest.preRequestScript || ''}
Expand All @@ -381,7 +386,10 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
</ErrorBoundary>
</TabPanel>
<TabPanel className="w-full flex-1" id="after-response">
<ErrorBoundary key={uniqueKey} errorClassName="tall wide vertically-align font-error pad text-center">
<ErrorBoundary
key={`${activeRequest._id}:after-response-script`}
errorClassName="tall wide vertically-align font-error pad text-center"
>
<RequestScriptEditor
historyKey={`${activeRequest._id}:after-response-script`}
defaultValue={activeRequest.afterResponseScript || ''}
Expand All @@ -399,7 +407,11 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
</TabPanel>
<TabPanel className="w-full flex-1 overflow-y-auto" id="docs">
<MarkdownEditor
key={uniqueKey}
// Stable per-request key: a volatile key here remounts the editor on
// every revalidation, resetting its internal state (content, active
// tab) and clobbering undo. Descriptions don't depend on env/response,
// so a stable key is safe.
key={`request-description::${requestId}`}
historyKey={`request-description::${requestId}`}
placeholder="Write a description"
defaultValue={activeRequest.description}
Expand Down
Loading