Skip to content
Merged
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
74 changes: 74 additions & 0 deletions scripts/monacoImePatch.mjs
Original file line number Diff line number Diff line change
@@ -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 };
},
};
45 changes: 45 additions & 0 deletions scripts/monacoImePatch.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
5 changes: 4 additions & 1 deletion vite.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
Expand Down
Loading