From 59ec1736822f537ac5d9357e0451afb95a1ec573 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 6 Oct 2026 22:34:44 +0200 Subject: [PATCH] fix(google-docs): do not show suggestions when a click only moves the caret In Google Docs, a click into existing text opened the suggestion menu. An arrow key did the same. The user did not type. Root cause: the adapter reads the Docs model every 200 ms. It asks for suggestions when the new snapshot is not the same as the last one. A click (pointerdown) and each key call dismiss(), and dismiss() sets the stored snapshot to null. The next poll then had no snapshot to compare, so it read the moved caret as a change and asked for suggestions. Fix: a new "edited" flag. An edit sets it: a keystroke, an input, a paste (queueEdit), the end of an IME composition, and our own write (so the next-word menu still shows after an accepted word). dismiss() clears it. A read uses it one time. A read without an edit or an explicit request (force) asks for no suggestions. Tests: - unit (GoogleDocsAdapter.test.ts): a click and a poll ask for no suggestions; an edit does. It fails without the fix. - integration (google-docs.e2e.test.ts): pointerdown plus a caret move, and an arrow key plus a caret move, request no suggestions, and the menu stays closed. Without the fix, the menu opens. - e2e (full.e2e.test.ts): for 24 editor setups (textarea, input, contenteditable, React-controlled fields, CKEditor 4/5, Quill, Lexical, ProseMirror, Slate, TinyMCE, Gutenberg, Draft.js, Trix, Froala, Summernote, Tiptap, RoosterJS and the Notion-like page), in menu and inline mode: a click into the text, arrow keys, and a click back into the field show no suggestion. All 48 cases pass in Chrome. These editors did not have the bug. When a click asks for suggestions on purpose, the test fails. - The coverage matrix has a new behavior, caret_move_shows_no_suggestion. Co-Authored-By: Claude Opus 5.5 --- .../google-docs/GoogleDocsAdapter.ts | 13 +- tests/GoogleDocsAdapter.test.ts | 50 ++++++ tests/e2e/coverage-matrix.json | 21 +++ tests/e2e/full.e2e.test.ts | 159 ++++++++++++++++++ tests/e2e/google-docs.e2e.test.ts | 38 +++++ 5 files changed, 280 insertions(+), 1 deletion(-) diff --git a/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts b/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts index e16203cc3..df3d05a01 100644 --- a/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts +++ b/src/adapters/chrome/content-script/google-docs/GoogleDocsAdapter.ts @@ -193,6 +193,10 @@ export class GoogleDocsAdapter { // Insertion keystrokes seen since grammar last caught up with a quiet document. Replay // trusts a text diff only as far as this accounts for it; a paste zeroes it. private typed = 0; + // An edit since the last dismiss. Only an edit (or an explicit request) asks for + // suggestions: a click or an arrow key that moves the caret also reads as "changed", + // because dismiss() forgets the snapshot. + private edited = false; private visible = false; private reviewActive = false; private readonly reviewSourceListeners = new Set<() => void>(); @@ -217,6 +221,7 @@ export class GoogleDocsAdapter { private readonly compositionEnd = () => { this.reviewSourceChanged(); this.composing = false; + this.edited = true; this.scheduleRefresh("insert", 60); }; private readonly navigationListener = (event: Event) => { @@ -529,7 +534,9 @@ export class GoogleDocsAdapter { this.updateKeyState(); return; } - if (snapshot.anchor !== snapshot.focus && !force) { + const edited = this.edited; + this.edited = false; + if ((snapshot.anchor !== snapshot.focus || !edited) && !force) { this.clearVisual(); return; } @@ -699,6 +706,8 @@ export class GoogleDocsAdapter { else if (reply.status === "unverified") this.uncertain = tracked; this.snapshot = null; this.view.status(reply.status); + // What follows an accepted word or a correction is still typing. + this.edited = true; if (!this.uncertain) void this.refresh(); } private learn(acceptance: Acceptance | null): string { @@ -839,6 +848,7 @@ export class GoogleDocsAdapter { if (triggers.includes("paste")) this.typed = 0; else if (triggers.includes("insertChar")) this.typed += 1; this.pendingAction = action; + this.edited = true; // The host applies the keystroke later in this same dispatch, so the earliest // correct moment to read it back is the next task, not a fixed settle delay. // Waiting longer only lets the following keystroke cancel this pass and take the @@ -928,6 +938,7 @@ export class GoogleDocsAdapter { this.bridge.cancel(); this.clearVisual(); this.snapshot = null; + this.edited = false; this.failureStatus = null; if (this.refreshTimer !== null) clearTimeout(this.refreshTimer); if (this.idleTimer !== null) clearTimeout(this.idleTimer); diff --git a/tests/GoogleDocsAdapter.test.ts b/tests/GoogleDocsAdapter.test.ts index 105b62e70..71e4fb831 100644 --- a/tests/GoogleDocsAdapter.test.ts +++ b/tests/GoogleDocsAdapter.test.ts @@ -46,3 +46,53 @@ test("the menu header keeps the predicted language when the layout renders it ag expect(render.mock.calls.map((call) => (call as unknown[])[3])).toEqual(["de_DE", "de_DE"]); }); + +// Regression: a click (or an arrow key) that only moved the caret opened the menu. The +// dismiss on pointerdown forgot the snapshot, so the next poll read looked like an edit. +test("a caret move without an edit asks for no suggestions; an edit does", async () => { + const frame = document.createElement("iframe"); + frame.className = "docs-texteventtarget-iframe"; + frame.tabIndex = 0; + document.body.append(frame); + frame.contentDocument!.body.innerHTML = '
'; + frame.focus(); + + const adapter = new GoogleDocsAdapter({ + lang: "en_US", + minWordLengthToPredict: 1, + getPrediction: () => undefined, + enabledGrammarRules: [], + } as unknown as SuggestionManagerOptions); + const internals = adapter as unknown as { + snapshot: unknown; + bridge: { read: () => Promise }; + prediction: { schedule: (...args: unknown[]) => void }; + navigationListener: (event: Event) => void; + queueEdit: (action: string, triggers: string[]) => void; + refresh: () => Promise; + }; + const at = (caret: number) => ({ + scope: "s", + text: "hello world", + windowStart: 0, + documentLength: 11, + caret, + anchor: caret, + focus: caret, + token: `t${caret}`, + }); + internals.snapshot = at(11); + internals.bridge.read = async () => ({ status: "ready", snapshot: at(3) }); + const schedule = jest.fn(); + internals.prediction.schedule = schedule; + + internals.navigationListener(new Event("pointerdown")); + await internals.refresh(); + expect(schedule).not.toHaveBeenCalled(); + + internals.queueEdit("insert", ["insertChar"]); + internals.bridge.read = async () => ({ status: "ready", snapshot: at(4) }); + await internals.refresh(); + expect(schedule).toHaveBeenCalledTimes(1); + adapter.dispose(); +}); diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index a6d2d325d..5fc511de8 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -4767,6 +4767,27 @@ } ] }, + { + "id": "caret_move_shows_no_suggestion", + "description": "A click or an arrow key that only moves the caret over existing text shows no suggestion menu and no inline ghost. Only an edit or an explicit request asks for suggestions. In Google Docs, the dismiss on a click forgets the snapshot, and the next poll must not read the moved caret as an edit.", + "coverage": [ + { + "layer": "unit", + "file": "tests/GoogleDocsAdapter.test.ts", + "test": "a caret move without an edit asks for no suggestions; an edit does" + }, + { + "layer": "integration", + "file": "tests/e2e/google-docs.e2e.test.ts", + "test": "a click or an arrow key that only moves the caret requests no suggestions" + }, + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "A click or an arrow key that only moves the caret shows no suggestion in $name (inline: $inline)" + } + ] + }, { "id": "stale_predictions_after_caret_move", "description": "Predictions are for one caret place. When the page moves the caret without a text change (for example after focus), a prediction response for the old place is not shown, and a visible contenteditable menu is dismissed, so Space or Tab cannot write a stale word at the new place.", diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index d91ca69c5..d8c5a8e07 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -7802,6 +7802,165 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { suiteTimeout(60000, 90000), ); + test.each( + [ + ...caretEditors, + { + name: "Notion-like page", + options: { reviewEditor: "notion" as const }, + selector: "", + surface: undefined, + setup: undefined, + fixture: "notion" as const, + }, + ].flatMap((entry) => [false, true].map((inline) => ({ ...entry, inline }))), + )( + "A click or an arrow key that only moves the caret shows no suggestion in $name (inline: $inline)", + async ({ options, selector: fieldSelector, surface: surfaceKind, setup, fixture, inline }) => { + await setGrammarRules(worker, []); + await setSetting(worker, KEY_LANGUAGE, "en_US"); + await setSetting(worker, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSetting(worker, KEY_INLINE_SUGGESTION, inline); + await notifyConfigChange(browser, worker); + await gotoTestPage(page, options); + await page.bringToFront(); + const editor = fixture ? await reviewEditorFixture(fixture) : null; + const selector = editor?.editable ?? fieldSelector; + const surface: Page | Frame = editor + ? editor.surface + : surfaceKind === "tinymce" + ? await waitUntil( + "TinyMCE editing frame", + async () => (await (await page.$("#test-tinymce_ifr"))?.contentFrame()) ?? false, + { timeoutMs: INPUT_READY_TIMEOUT_MS }, + ) + : surfaceKind === "gutenberg" + ? await waitUntil( + "editor frame", + async () => + page + .frames() + .find( + (frame) => + frame.name() === "editor-canvas" || frame.url().startsWith("blob:"), + ) ?? false, + { timeoutMs: INPUT_READY_TIMEOUT_MS }, + ) + : page; + await setup?.(surface); + await waitForInputReady(surface, selector); + // The Notion-like page drops a key that comes before it selects the block. + if (fixture === "notion") { + await clickNotionBlockEnd(1); + await page.keyboard.type(" "); + } else if (fixture) { + await placeCaretAfter(surface, selector, "dog."); + await page.keyboard.type(" "); + } else { + await surface.$eval(selector, (element) => { + (element as HTMLElement).focus(); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) + element.select(); + else element.ownerDocument.getSelection()!.selectAllChildren(element); + }); + await sleep(250); + await page.keyboard.press("Backspace"); + } + await page.keyboard.type("Hello there world wo", { delay: 20 }); + /** A suggestion menu or an inline ghost is on screen. */ + const shown = async () => + (await hasVisibleSuggestions(surface)) || + (await surface.evaluate(() => + Boolean(document.querySelector(".ft-suggestion-inline")?.textContent), + )); + await waitUntil("suggestion after typing", shown, { + timeoutMs: SUGGESTION_TIMEOUT_MS, + }); + + /** Page coordinates of the middle of `needle` in the field. */ + const pointIn = async (needle: string) => { + const point = await surface.$eval( + selector, + (element, text) => { + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + const index = element.value.indexOf(text); + const style = getComputedStyle(element); + const context = document.createElement("canvas").getContext("2d")!; + context.font = style.font; + const rect = element.getBoundingClientRect(); + const x = + rect.left + + parseFloat(style.borderLeftWidth) + + parseFloat(style.paddingLeft) + + context.measureText(element.value.slice(0, index + text.length / 2)).width - + element.scrollLeft; + const line = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2; + const y = + element instanceof HTMLInputElement + ? rect.top + rect.height / 2 + : rect.top + + parseFloat(style.borderTopWidth) + + parseFloat(style.paddingTop) + + line / 2; + return { x, y }; + } + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const index = node.textContent!.indexOf(text); + if (index < 0) continue; + const range = element.ownerDocument.createRange(); + range.setStart(node, index + Math.floor(text.length / 2)); + range.collapse(true); + const rect = range.getClientRects()[0] ?? range.getBoundingClientRect(); + return { x: rect.left, y: rect.top + rect.height / 2 }; + } + return null; + }, + needle, + ); + if (!point) throw new Error(`No text ${needle}`); + const offset = + surface === page ? null : await (await (surface as Frame).frameElement())?.boundingBox(); + return { x: point.x + (offset?.x ?? 0), y: point.y + (offset?.y ?? 0) }; + }; + /** True when a menu shows within `ms` after `action`. */ + const menuAfter = async (action: () => Promise, ms = 1500) => { + await action(); + const until = Date.now() + ms; + while (Date.now() < until) { + if (await shown()) return true; + await sleep(50); + } + return false; + }; + const opened: string[] = []; + // A click into the text while the menu is open. + let point = await pointIn("there"); + if (await menuAfter(() => page.mouse.click(point.x, point.y))) + opened.push("click while the menu is open"); + // Arrow keys over existing text. + if ( + await menuAfter(async () => { + for (const key of ["ArrowLeft", "ArrowLeft", "ArrowRight"] as const) + await page.keyboard.press(key); + }) + ) + opened.push("arrow keys"); + // Leave the field, then click back into the middle of a word. + await page.mouse.click(5, 5); + await sleep(300); + point = await pointIn("Hello"); + if (await menuAfter(() => page.mouse.click(point.x, point.y))) + opened.push("click into the field from outside"); + // A second click in the field moves the caret again. + point = await pointIn("world"); + if (await menuAfter(() => page.mouse.click(point.x, point.y))) + opened.push("second click in the field"); + expect(opened).toEqual([]); + }, + suiteTimeout(30000, 50000), + ); + test( "Gutenberg native slash menu keeps priority during block transformation", async () => { diff --git a/tests/e2e/google-docs.e2e.test.ts b/tests/e2e/google-docs.e2e.test.ts index a0df209c9..3fd773eed 100644 --- a/tests/e2e/google-docs.e2e.test.ts +++ b/tests/e2e/google-docs.e2e.test.ts @@ -187,6 +187,44 @@ describe("Google Docs cross-world fixture (not live Docs)", () => { expect((await model()).pastes).toBe(1); expect((await evaluate("events")).filter((v) => v === "accepted")).toHaveLength(1); }); + // Regression: a click (or an arrow key) that only moved the caret opened the menu. The + // dismiss forgot the snapshot, so the next poll read the moved caret as an edit. + test("a click or an arrow key that only moves the caret requests no suggestions", async () => { + await evaluate('predictions=["hello"]'); + await page.keyboard.type("hello wor", { delay: TYPING_DELAY_MS }); + const menuShown = () => + evaluate("document.querySelector('iframe').hasAttribute('data-ft-docs-key-state')"); + await waitUntil("completion", menuShown); + const moveCaret = (caret: number) => + page.evaluate((at) => { + const f = window as unknown as { + model: { text: string }; + setModel: (text: string, a: number, f: number) => void; + }; + f.setModel(f.model.text, at, at); + }, caret); + const requested = () => evaluate("requests.length"); + const before = await requested(); + // A click: pointerdown in the input frame, then Docs moves the caret. + await page + .frames()[1] + .evaluate(() => + document.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, composed: true })), + ); + await moveCaret(2); + // Several 200ms polls. + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(await menuShown()).toBe(false); + expect(await requested()).toBe(before); + await page.keyboard.press("ArrowRight"); + await moveCaret(3); + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(await menuShown()).toBe(false); + expect(await requested()).toBe(before); + // An edit still asks. + await page.keyboard.type("l", { delay: TYPING_DELAY_MS }); + await waitUntil("a request after the edit", async () => (await requested()) > before); + }); test("visible suggestions remain usable across repeated snapshot refreshes", async () => { await seed("hel", ["hello"]); // Model-driven refreshes, not a fixed sleep: exceed the bridge's eight-token cache.