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( export function toCodeMirrorCompletion(
source: string,
result: ConfigSourceCompletionResult | null, result: ConfigSourceCompletionResult | null,
): CompletionResult | null { ): CompletionResult | null {
if (!result) return null; if (!result) return null;
return { return {
from: utf8ByteOffsetToUtf16(source, result.from), from: result.from,
options: result.items.map((item) => ({ options: result.items.map((item) => ({
label: item.label, label: item.label,
type: item.kind, 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, utf16Offset,
explicit, explicit,
}); });
return toCodeMirrorCompletion(source, result); return toCodeMirrorCompletion(result);
} }
format(source: string): Promise<string> { format(source: string): Promise<string> {
return this.#request({ kind: "format", source }); 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"> <script lang="ts">
import { untrack } from 'svelte'; 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 { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state'; import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; 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)' }, '.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(() => { $effect(() => {
if (!host || untrack(() => view)) return; if (!host || untrack(() => view)) return;
const initialValue = untrack(() => value); const initialValue = untrack(() => value);
@@ -86,11 +105,16 @@
highlightActiveLine(), highlightActiveLine(),
decodal({ highlight: false }), decodal({ highlight: false }),
syntaxHighlighting(syntaxTheme), syntaxHighlighting(syntaxTheme),
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => { autocompletion({
const doc = context.state.doc.toString(); override: [
return await handleComplete(doc, context.pos, context.explicit); async (context: CompletionContext) => {
}] })] : []), if (!handleComplete) return null;
keymap.of([]), const doc = context.state.doc.toString();
return await handleComplete(doc, context.pos, context.explicit);
},
],
}),
keymap.of(completionKeymap),
fixedSchemaWrapperCompartment.of( fixedSchemaWrapperCompartment.of(
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [], initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
), ),
@@ -98,7 +122,16 @@
EditorState.readOnly.of(initialReadonly), EditorState.readOnly.of(initialReadonly),
EditorView.editable.of(!initialReadonly), EditorView.editable.of(!initialReadonly),
]), ]),
EditorView.domEventHandlers({
focus: (_event, editor) => {
scheduleCompletion(editor);
return false;
},
}),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.selectionSet && !update.docChanged) {
scheduleCompletion(update.view);
}
if (update.docChanged) handleChange(update.state.doc.toString()); if (update.docChanged) handleChange(update.state.doc.toString());
}), }),
theme, theme,
@@ -123,6 +156,7 @@
EditorView.editable.of(!nextReadonly), EditorView.editable.of(!nextReadonly),
]), ]),
}); });
if (!nextReadonly) scheduleCompletion(editor);
}); });
$effect(() => { $effect(() => {
@@ -1,3 +1,5 @@
import { CODEMIRROR_VITE_DEDUPE } from "../../src/lib/workspace/config-source/vite-dedupe.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void; test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: URL): Promise<string>; readTextFile(path: URL): Promise<string>;
@@ -7,6 +9,23 @@ function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message); 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 () => { Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
const source = await Deno.readTextFile( const source = await Deno.readTextFile(
new URL( new URL(
@@ -72,6 +91,13 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
!source.includes("--border-subtle"), !source.includes("--border-subtle"),
"CodeMirror theme must use workspace tokens that actually exist", "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( assert(
source.includes("fixedSchemaWrapperCompartment.reconfigure") && source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
source.includes("fixedSchemaWrapperExtension()") && 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", () => { Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => {
const source = "let 名 = tru"; const result = toCodeMirrorCompletion({
const result = toCodeMirrorCompletion(source, { from: "let 名 = ".length,
from: new TextEncoder().encode("let 名 = ").length,
items: [ items: [
{ {
label: "true", 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 !== null, "WASM completion should produce a CodeMirror result");
assert( assert(
result.from === "let 名 = ".length, result.from === "let 名 = ".length,
"byte offsets should become UTF-16 offsets", "WASM UTF-16 offsets should be preserved for CodeMirror",
); );
assert( assert(
result.options.length === 1, result.options.length === 1,
+5
View File
@@ -1,9 +1,14 @@
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { CODEMIRROR_VITE_DEDUPE } from "./src/lib/workspace/config-source/vite-dedupe";
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
resolve: {
dedupe: CODEMIRROR_VITE_DEDUPE,
},
server: { server: {
host: "localhost", host: "localhost",
port: 5173, port: 5173,