From b65f75493f72043972f6db9190a0217048e2dde2 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:18:06 +0800 Subject: [PATCH] fix(editor): typing with an IME no longer makes the line sink and snap back (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monaco's hidden textarea becomes a one-row overlay on the composed line during IME composition, but it holds a page of text, so Monaco scrolls it to the caret's row. WebKit scrolls it back to "caret just visible" — 3px short of the row — on every composition update, and the next render scrolls it forward again. That is the jitter: the composed text sinks and snaps back on every keystroke, on every line but the first of the page. Monaco already treats `accessibilitySupport: 'auto'` in a browser as "no screen reader" when deciding whether to write into the textarea (vscode#192278), but not when deciding what to write; it hands that state a page. A build-time patch makes the two decisions agree, so the overlay holds one short line, has no overflow, and gives the browser nothing to scroll. Proposed upstream as microsoft/vscode#333909; the patch and its test go when a Monaco release carries it. Co-Authored-By: Claude Fable 5.1 --- scripts/monacoImePatch.mjs | 74 ++++++++++++++++++++++++++++++++++ scripts/monacoImePatch.test.ts | 45 +++++++++++++++++++++ vite.config.js | 5 ++- 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 scripts/monacoImePatch.mjs create mode 100644 scripts/monacoImePatch.test.ts diff --git a/scripts/monacoImePatch.mjs b/scripts/monacoImePatch.mjs new file mode 100644 index 00000000..08c5f196 --- /dev/null +++ b/scripts/monacoImePatch.mjs @@ -0,0 +1,74 @@ +/** + * The two-condition fix for IME jitter in the editor (#724), applied to the + * bundled Monaco at build time until upstream ships it. + * + * In a browser without `EditContext` — WKWebView, so every macOS build of this + * app — Monaco takes input through a hidden textarea. During IME composition + * that textarea becomes a one-row overlay on the composed line, yet it holds a + * page of text, so Monaco scrolls it to the caret's row. WebKit scrolls it back + * to "caret just visible" on every composition update, 3px short of the row, + * and the next render scrolls it forward again: the composed text sinks and + * snaps back on every keystroke. Monaco's own write path already treats + * `accessibilitySupport: 'auto'` in a browser as "no screen reader" and skips + * render-time writes (vscode#192278); the content path disagreed and handed the + * same state a page. Making the two agree leaves the overlay with one short + * line, no overflow, and nothing for the browser to scroll. + * + * Upstream: microsoft/monaco-editor#4796, fixed by microsoft/vscode#333909. + * Once a Monaco release carries that change, delete this file, the plugin in + * vite.config.js and monacoImePatch.test.ts. Until then the build fails + * loudly the moment either anchor stops matching exactly once — which is + * what a Monaco bump that already has the fix will do. + */ + +/** The file inside monaco-editor's ESM tree that owns the textarea. */ +export const MONACO_TEXTAREA_FILE = 'textAreaEditContext.js'; + +/** + * Each anchor is a whole source line of the installed Monaco, indentation + * included, so it cannot match a similar line in another function. + */ +export const MONACO_IME_PATCH = [ + { + // getScreenReaderContent: what is written into the textarea. + from: ' if (this._accessibilitySupport === 1 /* AccessibilitySupport.Disabled */) {\n', + to: ' if (this._accessibilitySupport !== 2 /* AccessibilitySupport.Enabled */) {\n', + }, + { + // _setAccessibilityOptions: whether the textarea is sized to wrap a page. + from: ' if (wrappingColumn !== -1 && this._accessibilitySupport !== 1 /* AccessibilitySupport.Disabled */) {\n', + to: ' if (wrappingColumn !== -1 && this._accessibilitySupport === 2 /* AccessibilitySupport.Enabled */) {\n', + }, +]; + +/** + * Apply the patch to the source of `MONACO_TEXTAREA_FILE`. Throws, rather than + * returning the input unchanged, when an anchor does not match exactly once: + * a patch that silently stops applying is the defect coming back with the + * comment still promising it is fixed. + * @param {string} code + * @returns {string} + */ +export function patchMonacoTextAreaForIme(code) { + for (const { from, to } of MONACO_IME_PATCH) { + const hits = code.split(from).length - 1; + if (hits !== 1) { + throw new Error( + `monaco IME patch: expected exactly one match for ${JSON.stringify(from.trim())}, found ${hits}. ` + + 'Monaco changed under the patch — check whether microsoft/vscode#333909 shipped, and delete the patch if it did.', + ); + } + code = code.replace(from, to); + } + return code; +} + +/** The Vite plugin: the patch, applied to that one file and nothing else. */ +export const monacoImePatch = { + name: 'monaco-ime-patch', + /** @param {string} code @param {string} id */ + transform(code, id) { + if (!id.endsWith(MONACO_TEXTAREA_FILE)) return null; + return { code: patchMonacoTextAreaForIme(code), map: null }; + }, +}; diff --git a/scripts/monacoImePatch.test.ts b/scripts/monacoImePatch.test.ts new file mode 100644 index 00000000..34335ed7 --- /dev/null +++ b/scripts/monacoImePatch.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { MONACO_IME_PATCH, MONACO_TEXTAREA_FILE, patchMonacoTextAreaForIme } from './monacoImePatch.mjs'; +import { readSource } from './sourceTree.js'; + +// The build-time patch to Monaco that stops the IME composition overlay from +// jittering (#724). See the header of monacoImePatch.mjs for the mechanism. +// +// These are text assertions against the INSTALLED Monaco, and they say so: +// the contract being pinned is that the two source lines the patch anchors on +// still exist exactly once in the file it targets. A Monaco bump that moves or +// rewrites either line — including the bump that finally carries the upstream +// fix, microsoft/vscode#333909 — turns this red, which is the signal to delete +// the patch rather than let it silently stop applying. + +// Through `readSource`, like every other test that reads a file: the anchors +// are written against `\n`, and it is what keeps them matching on a Windows +// checkout (see singleImplementationConvention.test.ts). +const textAreaSource = readSource( + new URL(`../node_modules/monaco-editor/esm/vs/editor/browser/controller/editContext/textArea/${MONACO_TEXTAREA_FILE}`, import.meta.url), +); + +test('both anchors match the installed Monaco exactly once', () => { + for (const { from } of MONACO_IME_PATCH) { + assert.equal(textAreaSource.split(from).length - 1, 1, `anchor ${JSON.stringify(from.trim())}`); + } +}); + +test('the patch turns both conditions into "unless a screen reader is attached"', () => { + const patched = patchMonacoTextAreaForIme(textAreaSource); + for (const { from, to } of MONACO_IME_PATCH) { + assert.equal(patched.includes(from), false, 'the original condition is gone'); + assert.equal(patched.split(to).length - 1, 1, 'the replacement is there once'); + } + // Nothing else moved: the patch is those two lines and no more. + assert.equal(patched.length, textAreaSource.length + MONACO_IME_PATCH.reduce((n, p) => n + p.to.length - p.from.length, 0)); +}); + +test('a Monaco that no longer has the anchors fails the build instead of shipping unpatched', () => { + assert.throws( + () => patchMonacoTextAreaForIme(textAreaSource.replace(MONACO_IME_PATCH[0].from, '')), + /expected exactly one match/, + ); +}); diff --git a/vite.config.js b/vite.config.js index b56ff3f7..3aed248c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,11 +1,14 @@ import { defineConfig } from "vite"; import { sveltekit } from "@sveltejs/kit/vite"; +import { monacoImePatch } from "./scripts/monacoImePatch.mjs"; const host = process.env.TAURI_DEV_HOST; // https://vite.dev/config/ export default defineConfig(async () => ({ - plugins: [sveltekit()], + // See scripts/monacoImePatch.mjs: a build-time patch to Monaco, to delete + // once microsoft/vscode#333909 ships in a release. + plugins: [monacoImePatch, sveltekit()], build: { chunkSizeWarningLimit: 6000, },