diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index 6707ae248..6caa3d9bd 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -45,6 +45,20 @@ export interface QueryEditorRef { interface QueryEditorProps { /** Initial value for the editor. Changes to this prop will update the editor content. */ value: string; + /** + * Which document `value` belongs to, so that switching documents is an event and not a + * string comparison. The tab id in both hosts. + * + * Without it the editor cannot see a switch to a tab whose text happens to equal the + * string it was already holding, and a new tab therefore opens showing the previous + * tab's text (#808): the parent mirrors typing one render behind, so at the moment a + * new empty tab arrives `value` can still be the empty string it started as, and a + * prop that never changed cannot announce anything. + * + * Optional: a host that renders a single document never switches, and omitting it + * leaves the text-only reconciliation below in charge. + */ + documentId?: string; /** Optional callback for value changes. Only called on blur, execute, or explicit sync - NOT on every keystroke. */ onChange?: (val: string) => void; /** Called when content changes in real-time. Use sparingly as it triggers on every keystroke. */ @@ -110,7 +124,17 @@ const getEditorOptions = (showLineNumbers: boolean) => ({ export const QueryEditor = forwardRef( ( - { value, onChange, onContentChange, onExplain, language = "sql", databaseType, schemaContext, capabilities }, + { + value, + documentId, + onChange, + onContentChange, + onExplain, + language = "sql", + databaseType, + schemaContext, + capabilities, + }, ref, ) => { const monaco = useMonacoInstance(); @@ -141,24 +165,62 @@ export const QueryEditor = forwardRef( // value at hydration, so there is no local default left that could overwrite it. const showLineNumbers = useLineNumbersPreference(); - // Track last synced value to detect external changes - const lastSyncedValueRef = useRef(value); - const isInternalChangeRef = useRef(false); - - // Sync editor content when value prop changes externally (e.g., tab switch) + /* + Every text this editor has handed up through `onContentChange` and has not yet seen + come back down as `value`, oldest first, plus the document they belong to. + + The parent mirrors the buffer: it writes each change into the tab's query and feeds + that straight back in as `value`, one render behind. An incoming `value` is + therefore one of two completely different things, and only one of them may touch + the model: + + - OUR OWN text, arriving late. Writing it back rewrites the buffer with an older + string and moves the caret, which is the "typing scrambles, cursor jumps to + line 1" bug (#808). + - Somebody ELSE's text: another tab, a query loaded from history or the saved + list, a generated statement. That has to land. + + An outstanding text is ours by construction, so this tells them apart by identity. + Asking instead whether the buffer has moved since the last sync can only infer it, + and an external write that arrives while the user is typing looks exactly like a + late echo under that reading. + + Two equal strings need no tie-break: if an external write happens to carry text this + editor just sent up, applying it or skipping it leaves the same buffer. + */ + const echoesRef = useRef<{ documentId?: string; values: string[] }>({ documentId, values: [] }); + + // The ONE path that pushes an external value change into the model, now that + // is uncontrolled (defaultValue) and the library's own controlled-value + // effect stays at its early return. useEffect(() => { - if (editorRef.current && value !== lastSyncedValueRef.current) { - const currentEditorValue = editorRef.current.getValue(); - // Only update if the new value is different from current editor content - // This prevents unnecessary updates when we're the source of the change - if (value !== currentEditorValue) { - isInternalChangeRef.current = true; - editorRef.current.setValue(value); - lastSyncedValueRef.current = value; - isInternalChangeRef.current = false; - } + const editor = editorRef.current; + if (!editor) return; + + const echoes = echoesRef.current; + + // A different document is in front of the user now: its text is authoritative + // whatever the buffer holds, and nothing the editor sent up belongs to it. + if (documentId !== echoes.documentId) { + echoesRef.current = { documentId, values: [] }; + if (value !== editor.getValue()) editor.setValue(value); + return; + } + + const echoIndex = echoes.values.indexOf(value); + if (echoIndex !== -1) { + // Ours, arriving late. Drop it and everything older with it: the parent moves + // through our texts in order, so a render carrying one of those cannot follow. + echoes.values.splice(0, echoIndex + 1); + return; } - }, [value]); + + // Nobody here wrote this, so it came from outside: a query loaded from history or + // the saved list, a generated statement. It replaces the buffer, which makes every + // outstanding echo a description of text that no longer exists. + echoes.values = []; + editor.setValue(value); + }, [value, documentId]); // Update editor options when line numbers toggle changes useEffect(() => { @@ -243,7 +305,6 @@ export const QueryEditor = forwardRef( return; } editorRef.current.setValue(formatted); - lastSyncedValueRef.current = formatted; onChange?.(formatted); } catch (e) { logger.warn("Statement formatting failed; the editor text is left as written", { @@ -372,7 +433,6 @@ export const QueryEditor = forwardRef( setValue: (newValue: string) => { if (editorRef.current) { editorRef.current.setValue(newValue); - lastSyncedValueRef.current = newValue; } }, focus: () => editorRef.current?.focus(), @@ -394,7 +454,6 @@ export const QueryEditor = forwardRef( const handleClear = () => { if (editorRef.current) { editorRef.current.setValue(""); - lastSyncedValueRef.current = ""; onChange?.(""); } }; @@ -452,18 +511,22 @@ export const QueryEditor = forwardRef( } }, [monaco, language, schemaCompletionCache]); + // Every model change reaches here: a keystroke, and equally the writes Format, Clear + // and the imperative setValue make, since Monaco reports those through the same + // change event. All of them are this editor's own text, so all of them are recorded + // before they go up, and none of them may come back down into the buffer. const handleEditorChange = (val: string | undefined) => { const newValue = val || ""; - // Only call onContentChange if provided (for real-time sync scenarios) - // This avoids the performance hit of updating parent state on every keystroke - onContentChange?.(newValue); + if (onContentChange) { + echoesRef.current.values.push(newValue); + onContentChange(newValue); + } }; // Sync to parent on blur (when user leaves the editor) const handleEditorBlur = () => { if (editorRef.current) { const currentValue = editorRef.current.getValue(); - lastSyncedValueRef.current = currentValue; onChange?.(currentValue); } }; @@ -472,7 +535,6 @@ export const QueryEditor = forwardRef( // Sync current content to parent before executing if (editorRef.current) { const currentValue = editorRef.current.getValue(); - lastSyncedValueRef.current = currentValue; onChange?.(currentValue); } @@ -572,7 +634,17 @@ export const QueryEditor = forwardRef( height="100%" language={language} theme={editorTheme} - value={value} + // `defaultValue`, not `value`: this editor owns its buffer, and the model is + // never driven by a prop. `@monaco-editor/react`'s controlled-`value` effect + // runs an `executeEdits` over the FULL model range whenever the prop differs + // from the buffer, and the prop is the parent's mirror of our own text, one + // render behind. A keystroke landing inside that window therefore made the + // library rewrite the whole buffer with older text and snap the caret to + // (1,1): the "typing scrambles / cursor jumps" bug (#808), easiest to hit + // where a render is slow. Passing `defaultValue` leaves that effect at its + // `t === void 0` early return, which makes the effect above the single place + // an outside change can reach the model. + defaultValue={value} beforeMount={handleBeforeMount} onChange={handleEditorChange} loading={ diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx index a78512ae9..da03ceb70 100644 --- a/src/components/Studio.tsx +++ b/src/components/Studio.tsx @@ -903,6 +903,7 @@ export default function Studio() { tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })} onExplain={ metadata?.capabilities.supportsExplain diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index 6b72d8856..06efd36a2 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -612,6 +612,7 @@ export function StudioWorkspace({ tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })} language={editorLanguageForTabType(tabMgr.currentTab.type)} databaseType={conn.activeConnection?.type} diff --git a/tests/components/QueryEditor.test.tsx b/tests/components/QueryEditor.test.tsx index a24d06486..4b48e5a5e 100644 --- a/tests/components/QueryEditor.test.tsx +++ b/tests/components/QueryEditor.test.tsx @@ -19,11 +19,19 @@ let mockCursorOffset = 0; let mockGetModelReturn: (() => unknown) | null = null; let mockDeltaDecorations = mock((..._a: unknown[]) => ["deco-1"]); let mockUpdateOptions = mock((..._a: unknown[]) => {}); +// Records the props the mock was last rendered with, so a test can assert the +// component passes `defaultValue` (uncontrolled) rather than `value` (controlled). +let capturedEditorProps: { value?: string; defaultValue?: string } | null = null; +// Every string handed to the editor's setValue, in order. A write of text the buffer +// already holds is not free in real Monaco: it replaces the model's content, which drops +// the undo stack and moves the caret, so "did not write" is worth asserting. +let capturedSetValues: string[] = []; // ── Mock Monaco Editor with React.createElement (not plain objects) ───────── mock.module("@monaco-editor/react", () => ({ default: function MockEditor(props: { value?: string; + defaultValue?: string; onChange?: (value: string | undefined) => void; language?: string; height?: string; @@ -33,14 +41,27 @@ mock.module("@monaco-editor/react", () => ({ beforeMount?: (...args: unknown[]) => void; options?: Record; }) { - const { value, onChange, language, onMount, beforeMount } = props; - const valueRef = React.useRef(value ?? ""); - const [textValue, setTextValue] = React.useState(value ?? ""); + const { value, defaultValue, onChange, language, onMount, beforeMount } = props; + capturedEditorProps = { value, defaultValue }; + // Mirror @monaco-editor/react@4.7.0: the buffer is seeded from `value ?? defaultValue` + // at mount, and thereafter the `value` prop only drives the buffer when it is DEFINED + // (controlled mode). QueryEditor now passes `defaultValue`, leaving `value` undefined, + // so the library's controlled-value effect is a no-op and the component's own + // useEffect([value]) is the single sync path — the shape this mock has to honour or the + // fix cannot be tested. See src/components/QueryEditor.tsx and issue: cursor-jump. + const valueRef = React.useRef(value ?? defaultValue ?? ""); + const [textValue, setTextValue] = React.useState(value ?? defaultValue ?? ""); const mountedRef = React.useRef(false); React.useEffect(() => { - // Only update display state, not valueRef — simulates real Monaco requiring explicit setValue() - setTextValue(value ?? ""); + // Real 4.7.0 controlled-value effect: `t === void 0` early-return, else overwrite the + // buffer when it differs. An uncontrolled editor (value === undefined) never runs it, + // which is exactly why passing `defaultValue` stops the keystroke-clobber. + if (value === undefined) return; + if (value !== valueRef.current) { + valueRef.current = value; + setTextValue(value); + } }, [value]); React.useEffect(() => { @@ -72,8 +93,14 @@ mock.module("@monaco-editor/react", () => ({ const editorMock = { getValue: () => valueRef.current, setValue: (next: string) => { + capturedSetValues.push(next); valueRef.current = next; setTextValue(next); + // Real Monaco reports a programmatic write through the same model-change event + // a keystroke raises, so `onChange` fires for Format, Clear, the imperative + // setValue and the external-change sync alike. The component counts on that: + // it is how the parent's mirror learns about a write it did not make. + onChange?.(next); }, getSelection: () => mockSelectionReturn, getModel: () => @@ -265,6 +292,8 @@ describe("QueryEditor", () => { mockGetModelReturn = null; mockDeltaDecorations = mock((..._a: unknown[]) => ["deco-1"]); mockUpdateOptions = mock((..._a: unknown[]) => {}); + capturedEditorProps = null; + capturedSetValues = []; mockClipboardWriteText = mock((data: string) => { void data; return Promise.resolve(); @@ -805,6 +834,47 @@ describe("QueryEditor", () => { window.removeEventListener("execute-query", handler); }); + test("getEffectiveQuery highlights the resolved statement's range, not the whole buffer", () => { + // The no-selection SQL path builds a monaco.Range spanning only the statement the + // caret is in and hands it to flashHighlight, so the executed statement (not the whole + // buffer) is what flashes. Putting the caret in the SECOND statement exercises the + // range construction with non-zero start/end offsets: getPositionAt maps each offset + // to (line, column), so a range that ended at the buffer's start would prove the + // wrong statement was resolved. Asserting deltaDecorations fired with a range whose + // end is past its start pins that the per-statement range object actually reaches the + // highlighter rather than being dropped for a null. + mockUseMonacoReturn = { + Range: class { + constructor( + public startLineNumber: number, + public startColumn: number, + public endLineNumber: number, + public endColumn: number, + ) {} + }, + }; + // Caret offset inside "SELECT 2" (buffer is "SELECT 1; SELECT 2", so >= 10 lands in + // the second statement). getPositionAt in the mock maps offset -> column offset+1. + mockCursorOffset = 12; + + render(React.createElement(QueryEditor, createDefaultProps({ value: "SELECT 1; SELECT 2" }))); + act(() => { + capturedCommands[0].handler(); + }); + + // flashHighlight received a real range (not null), so it created decorations. + expect(mockDeltaDecorations).toHaveBeenCalled(); + // On the first execute there is nothing to clear, so the sole call is the + // decoration-creating one: deltaDecorations([], [{ range, options }]). Its second + // argument is the non-empty descriptor array carrying the per-statement range. + const createCall = mockDeltaDecorations.mock.calls.find( + (c) => Array.isArray(c[1]) && (c[1] as unknown[]).length > 0, + ); + expect(createCall).toBeDefined(); + const descriptor = (createCall![1] as Array<{ range: unknown }>)[0]; + expect(descriptor.range).toBeDefined(); + }); + test("getEffectiveQuery reads the statement boundary under the connection's dialect", () => { /* The third reader of "where does a statement end", and the one whose answer is what @@ -1033,6 +1103,313 @@ describe("QueryEditor", () => { expect(editor.value).toBe("SECOND"); }); + // ----------------------------------------------------------------------- + // Regression: keystrokes must not be overwritten by a stale value prop + // + // The editor is fed `value` from `onContentChange` on every keystroke (Studio writes + // each keystroke into currentTab.query, which flows back down). If a keystroke lands + // between that state update and the re-render, the `value` prop arrives one keystroke + // STALE. Before the fix, that stale prop drove @monaco-editor/react's controlled-value + // effect, which ran a full-range executeEdits and rewrote the buffer (scrambling text + // and snapping the caret to line 1). The fix passes `defaultValue` instead of `value`, + // so the buffer is uncontrolled and a stale prop is a no-op. These tests pin that. + // ----------------------------------------------------------------------- + + test("a stale value prop does not overwrite newer typed content", () => { + // Parent starts holding "SELECT ", the pre-typing text. It mirrors the buffer through + // `onContentChange`, which is the shape both hosts mount: every intermediate string + // the user types passes through the parent and comes back as `value` a render later. + const props = createDefaultProps({ value: "SELECT ", onContentChange: mock(() => {}) }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + expect(editor.value).toBe("SELECT "); + + // User types: the buffer advances through each intermediate string, well ahead of + // what the parent has re-rendered with. + act(() => { + fireEvent.change(editor, { target: { value: "SELECT *" } }); + fireEvent.change(editor, { target: { value: "SELECT * FROM" } }); + fireEvent.change(editor, { target: { value: "SELECT * FROM t" } }); + }); + expect(editor.value).toBe("SELECT * FROM t"); + + // A re-render arrives carrying an EARLIER keystroke: the parent has only caught up + // that far. This is the race, and applying it would rewrite the buffer with older + // text and move the caret. + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT *" })); + }); + expect(editor.value).toBe("SELECT * FROM t"); + }); + + test("a value the editor already holds is not written back into the model", () => { + // The parent's mirror catching up is not an edit. Writing the identical string back + // would still replace the model's content, which in real Monaco drops the undo stack + // and moves the caret, so the sync has to recognise "nothing to do". + const props = createDefaultProps({ value: "SELECT 1", onContentChange: mock(() => {}) }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + act(() => { + fireEvent.change(editor, { target: { value: "SELECT 1 FROM t" } }); + }); + capturedSetValues = []; + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT 1 FROM t" })); + }); + expect(capturedSetValues).toEqual([]); + expect(editor.value).toBe("SELECT 1 FROM t"); + }); + + test("text this editor sent up earlier still applies when it comes back as an external change", () => { + // Loading the same statement again from history or the saved list is an external + // write that happens to carry text the user typed a moment ago. Once the parent has + // caught up with a later keystroke, that earlier text is no longer outstanding, so it + // must land in the buffer rather than be mistaken for an echo of our own. + const props = createDefaultProps({ value: "", onContentChange: mock(() => {}) }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + act(() => { + fireEvent.change(editor, { target: { value: "SELECT 1" } }); + fireEvent.change(editor, { target: { value: "SELECT 1 AND 2" } }); + }); + // The parent renders with the first of those, so that text is no longer outstanding + // while the second still is. + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT 1" })); + }); + expect(editor.value).toBe("SELECT 1 AND 2"); + + // The user now loads "SELECT 1" from history: the same text, but this time it is + // somebody else's write and has to land. + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT 1 AND 2" })); + }); + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT 1" })); + }); + expect(editor.value).toBe("SELECT 1"); + }); + + test("switching to a tab holding text the editor just sent up still swaps documents", () => { + // Text alone cannot decide this one: the other tab's query is a string this editor + // handed the parent moments ago (two tabs on variants of the same statement), so an + // echo test reads the switch as its own keystroke coming back and leaves the first + // tab's text on screen. Document identity is what separates them. + const props = createDefaultProps({ value: "", documentId: "tab-1", onContentChange: mock(() => {}) }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + act(() => { + fireEvent.change(editor, { target: { value: "SELECT shared" } }); + fireEvent.change(editor, { target: { value: "SELECT shared AND more" } }); + }); + + // Tab 2 holds exactly "SELECT shared", which is still outstanding as an echo of tab 1. + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT shared", documentId: "tab-2" })); + }); + expect(editor.value).toBe("SELECT shared"); + + // And tab 2 keeps its own text as the user edits it, rather than inheriting anything + // left over from tab 1. + act(() => { + fireEvent.change(editor, { target: { value: "SELECT shared two" } }); + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT shared AND more", documentId: "tab-2" })); + }); + expect(editor.value).toBe("SELECT shared AND more"); + }); + + test("a tab returns to its own text after a switch away", () => { + // Switching away writes the other tab's text, and switching back writes this tab's + // text again. The second write carries a string this editor sent up while the user + // was typing in the first tab, so anything the editor still remembers from before the + // switch describes a buffer that no longer exists and must not gate it. + const props = createDefaultProps({ value: "", documentId: "tab-1", onContentChange: mock(() => {}) }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + act(() => { + fireEvent.change(editor, { target: { value: "SELECT tab_one" } }); + }); + // Tab 2 opens empty. + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "", documentId: "tab-2" })); + }); + expect(editor.value).toBe(""); + + // Back to tab 1. + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT tab_one", documentId: "tab-1" })); + }); + expect(editor.value).toBe("SELECT tab_one"); + }); + + test("a second external write lands even when it repeats text typed before the first", () => { + // Two history entries opened one after the other, the second one carrying a string + // the user had typed earlier in this tab. Once an external write has replaced the + // buffer, nothing the editor sent up before it describes what is on screen any more, + // so none of it may gate the next write. + const props = createDefaultProps({ value: "", documentId: "tab-1", onContentChange: mock(() => {}) }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + act(() => { + fireEvent.change(editor, { target: { value: "SELECT typed" } }); + fireEvent.change(editor, { target: { value: "SELECT typed more" } }); + }); + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT from_history" })); + }); + expect(editor.value).toBe("SELECT from_history"); + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT typed" })); + }); + expect(editor.value).toBe("SELECT typed"); + }); + + test("switching to a tab that holds the same text leaves the model untouched", () => { + // Duplicating a tab puts the same statement in both. Rewriting the model with text it + // already holds is not free in real Monaco: it drops the undo stack and moves the + // caret, and the user switching tabs did not edit anything. + const props = createDefaultProps({ value: "SELECT same", documentId: "tab-1", onContentChange: mock(() => {}) }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + capturedSetValues = []; + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, documentId: "tab-2" })); + }); + expect(capturedSetValues).toEqual([]); + expect(editor.value).toBe("SELECT same"); + }); + + test("the editor is uncontrolled: receives defaultValue, not value", () => { + // The mechanism the fix relies on. If `value` were passed, @monaco-editor/react's + // controlled effect would fire on every keystroke echo and could clobber typing; + // `defaultValue` leaves that effect at its `t === void 0` early-return. + capturedEditorProps = null; + capturedSetValues = []; + render(React.createElement(QueryEditor, createDefaultProps({ value: "SELECT 1" }))); + expect(capturedEditorProps).not.toBeNull(); + expect(capturedEditorProps!.value).toBeUndefined(); + expect(capturedEditorProps!.defaultValue).toBe("SELECT 1"); + }); + + test("a genuine external value change (tab switch) still updates the buffer", () => { + // The other side of the fix: switching tabs hands the editor another document, and + // its text has to land in the buffer. + const props = createDefaultProps({ value: "SELECT tab_one", documentId: "tab-1" }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + expect(editor.value).toBe("SELECT tab_one"); + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT tab_two", documentId: "tab-2" })); + }); + expect(editor.value).toBe("SELECT tab_two"); + }); + + test("a new tab opens empty even when the parent has not caught up with the typing", () => { + // The measured failure (#808, second half). The new-tab shortcut is registered on + // `document` so it fires while Monaco holds focus (#745). Press it mid-word and the + // parent is still a render behind, so the empty tab arrives carrying the same empty + // string the editor mounted with: `value` never changes, and text alone cannot say + // that the document under it did. Measured before this: the new tab opened holding + // the previous tab's query, 8 times out of 8 on a throttled CPU. + const props = createDefaultProps({ value: "", documentId: "tab-1", onContentChange: mock(() => {}) }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + act(() => { + fireEvent.change(editor, { target: { value: "SELECT still_typing" } }); + }); + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "", documentId: "tab-2" })); + }); + expect(editor.value).toBe(""); + }); + + test("an external value change applies even while the buffer is ahead of the parent", () => { + // The case a divergence test cannot answer. The new-tab shortcut is registered on + // `document` so it fires while Monaco holds focus (#745), so an external change CAN + // arrive mid-typing, with the parent still one keystroke behind. Telling "our own + // echo, late" from "somebody else wrote this" by comparing the buffer against the + // last synced string reads both as the same thing, and swallowing the external change + // opens the new tab holding the previous tab's text. + const onContentChange = mock(() => {}); + const props = createDefaultProps({ value: "SELECT tab_one", documentId: "tab-1", onContentChange }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + // The user types: the buffer runs ahead, and the parent has not echoed back yet. + act(() => { + fireEvent.change(editor, { target: { value: "SELECT tab_one typed" } }); + }); + expect(editor.value).toBe("SELECT tab_one typed"); + + // A new tab opens while those keystrokes are still in flight: `value` is neither the + // buffer nor anything this editor sent up, so it is an external write and must land. + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "", documentId: "tab-2" })); + }); + expect(editor.value).toBe(""); + }); + + test("an echo arriving out of order does not rewrite the buffer", () => { + // Every intermediate keystroke the parent mirrors comes back as `value`, and under + // batching it can come back late and out of order. Each one is still this editor's + // own text, so none of them may touch the buffer. + const onContentChange = mock(() => {}); + const props = createDefaultProps({ value: "SELECT ", onContentChange }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + act(() => { + fireEvent.change(editor, { target: { value: "SELECT a" } }); + fireEvent.change(editor, { target: { value: "SELECT ab" } }); + fireEvent.change(editor, { target: { value: "SELECT abc" } }); + }); + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT ab" })); + }); + expect(editor.value).toBe("SELECT abc"); + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT abc" })); + }); + expect(editor.value).toBe("SELECT abc"); + }); + + test("a programmatic write is not mistaken for an external change when it echoes back", () => { + // Format, Clear and the imperative setValue all write the model, and real Monaco + // reports those writes through the same change event a keystroke uses, so they echo + // to the parent like any other edit. When that echo returns as `value`, the buffer + // may already have moved on, and re-applying the echo would undo what the user typed + // after the format. + const onContentChange = mock(() => {}); + const props = createDefaultProps({ value: "select 1", onContentChange }); + const { queryByTestId, rerender } = render(React.createElement(QueryEditor, props)); + const editor = queryByTestId("mock-monaco-editor") as HTMLTextAreaElement; + + act(() => { + fireEvent.change(editor, { target: { value: "SELECT 1" } }); // the format's own write + fireEvent.change(editor, { target: { value: "SELECT 1 and typing" } }); + }); + + act(() => { + rerender(React.createElement(QueryEditor, { ...props, value: "SELECT 1" })); + }); + expect(editor.value).toBe("SELECT 1 and typing"); + }); + // ----------------------------------------------------------------------- // Completion provider registration // -----------------------------------------------------------------------