fix: deduplicate CodeMirror completion state

This commit is contained in:
2026-09-01 22:03:25 +09:00
parent 816fa96e07
commit 21317123a4
7 changed files with 84 additions and 42 deletions
@@ -13,12 +13,11 @@ type ConfigSourceCompletionItem = {
};
export function toCodeMirrorCompletion(
source: string,
result: ConfigSourceCompletionResult | null,
): CompletionResult | null {
if (!result) return null;
return {
from: utf8ByteOffsetToUtf16(source, result.from),
from: result.from,
options: result.items.map((item) => ({
label: item.label,
type: item.kind,
@@ -27,31 +26,3 @@ export function toCodeMirrorCompletion(
})),
};
}
function utf8ByteOffsetToUtf16(source: string, byteOffset: number): number {
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
throw new RangeError(
"completion byte offset must be a non-negative integer",
);
}
let bytes = 0;
let utf16 = 0;
for (const character of source) {
if (bytes === byteOffset) return utf16;
const codePoint = character.codePointAt(0)!;
bytes += codePoint <= 0x7f
? 1
: codePoint <= 0x7ff
? 2
: codePoint <= 0xffff
? 3
: 4;
utf16 += character.length;
if (bytes > byteOffset) {
throw new RangeError("completion byte offset splits a UTF-8 code point");
}
}
if (bytes === byteOffset) return utf16;
throw new RangeError("completion byte offset is outside the source");
}
@@ -79,7 +79,7 @@ export class ConfigSourceToolchain {
utf16Offset,
explicit,
});
return toCodeMirrorCompletion(source, result);
return toCodeMirrorCompletion(result);
}
format(source: string): Promise<string> {
return this.#request({ kind: "format", source });
@@ -0,0 +1,7 @@
export const CODEMIRROR_VITE_DEDUPE = [
"@codemirror/autocomplete",
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
];
@@ -1,6 +1,13 @@
<script lang="ts">
import { untrack } from 'svelte';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import {
autocompletion,
completionKeymap,
completionStatus,
startCompletion,
type CompletionContext,
type CompletionResult,
} from '@codemirror/autocomplete';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
@@ -69,6 +76,18 @@
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
});
function scheduleCompletion(editor: EditorView) {
queueMicrotask(() => {
if (
editor.hasFocus &&
!editor.state.facet(EditorState.readOnly) &&
completionStatus(editor.state) === null
) {
startCompletion(editor);
}
});
}
$effect(() => {
if (!host || untrack(() => view)) return;
const initialValue = untrack(() => value);
@@ -86,11 +105,16 @@
highlightActiveLine(),
decodal({ highlight: false }),
syntaxHighlighting(syntaxTheme),
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
const doc = context.state.doc.toString();
return await handleComplete(doc, context.pos, context.explicit);
}] })] : []),
keymap.of([]),
autocompletion({
override: [
async (context: CompletionContext) => {
if (!handleComplete) return null;
const doc = context.state.doc.toString();
return await handleComplete(doc, context.pos, context.explicit);
},
],
}),
keymap.of(completionKeymap),
fixedSchemaWrapperCompartment.of(
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
),
@@ -98,7 +122,16 @@
EditorState.readOnly.of(initialReadonly),
EditorView.editable.of(!initialReadonly),
]),
EditorView.domEventHandlers({
focus: (_event, editor) => {
scheduleCompletion(editor);
return false;
},
}),
EditorView.updateListener.of((update) => {
if (update.selectionSet && !update.docChanged) {
scheduleCompletion(update.view);
}
if (update.docChanged) handleChange(update.state.doc.toString());
}),
theme,
@@ -123,6 +156,7 @@
EditorView.editable.of(!nextReadonly),
]),
});
if (!nextReadonly) scheduleCompletion(editor);
});
$effect(() => {
@@ -1,3 +1,5 @@
import { CODEMIRROR_VITE_DEDUPE } from "../../src/lib/workspace/config-source/vite-dedupe.ts";
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: URL): Promise<string>;
@@ -7,6 +9,23 @@ function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Vite deduplicates CodeMirror stateful packages", () => {
for (
const packageName of [
"@codemirror/autocomplete",
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
]
) {
assert(
CODEMIRROR_VITE_DEDUPE.includes(packageName),
`Vite must deduplicate ${packageName}`,
);
}
});
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
const source = await Deno.readTextFile(
new URL(
@@ -72,6 +91,13 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
!source.includes("--border-subtle"),
"CodeMirror theme must use workspace tokens that actually exist",
);
assert(
source.includes("keymap.of(completionKeymap)") &&
source.includes("startCompletion(editor)") &&
source.includes("update.selectionSet && !update.docChanged") &&
source.includes("completionStatus(editor.state) === null"),
"completion should be explicitly available and start when an editable cursor moves into an empty schema position",
);
assert(
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
source.includes("fixedSchemaWrapperExtension()") &&
@@ -67,10 +67,9 @@ Deno.test("toolchain converts reactive-like proxies to plain Worker messages", a
);
});
Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirror", () => {
const source = "let 名 = tru";
const result = toCodeMirrorCompletion(source, {
from: new TextEncoder().encode("let 名 = ").length,
Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => {
const result = toCodeMirrorCompletion({
from: "let 名 = ".length,
items: [
{
label: "true",
@@ -84,7 +83,7 @@ Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirro
assert(result !== null, "WASM completion should produce a CodeMirror result");
assert(
result.from === "let 名 = ".length,
"byte offsets should become UTF-16 offsets",
"WASM UTF-16 offsets should be preserved for CodeMirror",
);
assert(
result.options.length === 1,
+5
View File
@@ -1,9 +1,14 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { CODEMIRROR_VITE_DEDUPE } from "./src/lib/workspace/config-source/vite-dedupe";
export default defineConfig({
plugins: [sveltekit()],
resolve: {
dedupe: CODEMIRROR_VITE_DEDUPE,
},
server: {
host: "localhost",
port: 5173,