1
0
mirror of https://github.com/alexandrev/xslt-lab.git synced 2026-09-13 08:43:16 +00:00

Perf: hand-rolled CodeMirror to slim the critical editor chunk (-29KB gz)

@uiw/react-codemirror's basicSetup statically imports @codemirror/lint,
/autocomplete and /search, so they landed in the critical codemirror
chunk regardless of the runtime basicSetup flags. Since lint and
autocomplete are already loaded on demand via useEditorExtras, they were
being shipped twice — once eager, once deferred.

Replace the @uiw wrapper with a thin EditorView binding (controlled
value with an ExternalChange annotation to avoid onChange echo, plus
compartments for theme / base options / deferred extras) and hand-pick
only the primitives the critical path needs: state, view (lineNumbers,
highlightActiveLine, drawSelection, keymap), commands (history), and
language (syntaxHighlighting, indentOnInput, bracketMatching). lint,
autocomplete and the completions table stay in useEditorExtras.

Critical codemirror chunk 448KB/147KB gz -> 361KB/118KB gz. The
autocomplete + lint code moves to deferred chunks, off first paint.

Behaviour verified in a headless browser: typing/onChange, undo/redo,
auto-close tags, deferred autocomplete (65 options), XML lint markers,
and light/dark theme switch (oneDark applied) all work; no page errors.

LCP/FCP/CLS unchanged (already gated by the static skeleton since the
prior commit). Time-to-interactive-editor on throttled mobile improves
from 2111ms to 1821ms median, with far tighter run-to-run variance.

Drops the @uiw/react-codemirror dependency; promotes the four
@codemirror/* primitives to direct dependencies. @monaco-editor/react
stays (only referenced by a test mock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
This commit is contained in:
2026-07-21 15:49:37 +00:00
parent f916372572
commit 9c6f81a043
4 changed files with 197 additions and 143 deletions
+5 -80
View File
@@ -9,11 +9,14 @@
"version": "0.2.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@monaco-editor/react": "^4.7.0",
"@uiw/react-codemirror": "^4.25.9",
"firebase": "^9.23.0",
"ga-4-react": "^0.1.281",
"react": "^18.2.0",
@@ -300,6 +303,7 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -416,17 +420,6 @@
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
@@ -2224,59 +2217,6 @@
"undici-types": "~7.8.0"
}
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.9",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz",
"integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/commands": ">=6.0.0",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@uiw/react-codemirror": {
"version": "4.25.9",
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz",
"integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "4.25.9",
"codemirror": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.11.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"codemirror": ">=6.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz",
@@ -2588,21 +2528,6 @@
"wrap-ansi": "^7.0.0"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+4 -1
View File
@@ -10,11 +10,14 @@
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.5",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@monaco-editor/react": "^4.7.0",
"@uiw/react-codemirror": "^4.25.9",
"firebase": "^9.23.0",
"ga-4-react": "^0.1.281",
"react": "^18.2.0",
+180 -61
View File
@@ -73,10 +73,39 @@ const WELCOME_EXAMPLE_RESULT = `<?xml version="1.0" encoding="UTF-8"?><catalog t
</catalog>
`;
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
// Hand-picked CodeMirror setup instead of @uiw/react-codemirror. Its default
// basicSetup statically pulls @codemirror/autocomplete, /lint and /search into
// the critical chunk (we already load lint+autocomplete on demand, so they were
// shipping twice). Importing only the primitives below keeps ~31KB gzip of
// editor code off the first paint. See useEditorExtras for the deferred half.
import { EditorState, Compartment, Annotation } from "@codemirror/state";
import {
EditorView,
lineNumbers,
highlightActiveLine,
drawSelection,
keymap,
} from "@codemirror/view";
import { history, historyKeymap, defaultKeymap, indentWithTab } from "@codemirror/commands";
import {
syntaxHighlighting,
defaultHighlightStyle,
indentOnInput,
bracketMatching,
syntaxTree,
} from "@codemirror/language";
import { xml, completeFromSchema } from "@codemirror/lang-xml";
import { oneDark } from "@codemirror/theme-one-dark";
import { syntaxTree } from "@codemirror/language";
// Marks doc changes we pushed programmatically from the `value` prop, so the
// updateListener doesn't echo them back through onChange (feedback loop).
const ExternalChange = Annotation.define();
// Fixed sizing/appearance applied to every instance.
const cmSizeTheme = EditorView.theme({
"&": { height: "100%", fontSize: "13px" },
".cm-scroller": { height: "100% !important" },
});
function xmlLinter(view) {
const text = view.state.doc.toString().trim();
@@ -166,6 +195,68 @@ function runWhenIdle(callback, timeout = 2000) {
return () => window.clearTimeout(id);
}
// Theme extension for a given app theme. oneDark carries its own highlight
// style; light mode uses the default one.
function cmThemeExt(theme) {
return theme === "vs-dark"
? oneDark
: syntaxHighlighting(defaultHighlightStyle);
}
// Static, always-critical extensions derived from options. Kept in a compartment
// so a rare options change reconfigures in place rather than recreating the view.
function cmBaseExt(options, editable) {
const ext = [
xml({ autoCloseTags: editable }),
history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
];
if (options.lineNumbers !== "off") ext.push(lineNumbers());
if (editable) {
ext.push(
drawSelection(),
highlightActiveLine(),
indentOnInput(),
bracketMatching(),
);
}
if (options.wordWrap) ext.push(EditorView.lineWrapping);
ext.push(EditorState.readOnly.of(!editable), EditorView.editable.of(editable));
return ext;
}
// The on-demand half: lint + autocomplete + hover docs. Empty until
// useEditorExtras resolves; only meaningful for editable editors.
function cmExtrasExt(extras, editable, xsltVersion) {
if (!editable || !extras) return [];
const { autocompletion, hoverTooltip, lintGutter } = extras;
const ext = [extras.lintExtension, lintGutter()];
if (xsltVersion) {
const completions = extras.getCompletions(xsltVersion);
const hoverDesc = extras.getHoverTooltip(xsltVersion);
const xmlElements = extras.getXmlElements(xsltVersion);
// xsl:* elements and XPath functions. Skip when the cursor sits on an
// attribute name, letting the schema source handle it.
const xsltSource = (ctx) => {
const node = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
if (node.name === "AttributeName") return null;
const word = ctx.matchBefore(/[\w:()-]+/);
if (!word && !ctx.explicit) return null;
return {
from: word ? word.from : ctx.pos,
options: completions,
validFor: /^[\w:()-]*$/,
};
};
const attrSource = completeFromSchema(xmlElements, []);
ext.push(
autocompletion({ override: [xsltSource, attrSource] }),
hoverTooltip((view, pos) => hoverDesc.resolve(view, pos), { hoverTime: 300 }),
);
}
return ext;
}
function Editor({
height,
value,
@@ -179,74 +270,102 @@ function Editor({
eager,
// eslint-disable-next-line no-unused-vars
language,
// eslint-disable-next-line no-unused-vars
onMount,
xsltVersion,
}) {
const editable = !options.readOnly;
const extras = useEditorExtras(editable);
const xmlElements =
extras && xsltVersion ? extras.getXmlElements(xsltVersion) : [];
const extensions = [xml({ elements: xmlElements, autoCloseTags: editable })];
if (editable && extras) {
const { autocompletion, hoverTooltip, lintGutter } = extras;
extensions.push(extras.lintExtension, lintGutter());
if (xsltVersion) {
const completions = extras.getCompletions(xsltVersion);
const hoverDesc = extras.getHoverTooltip(xsltVersion);
// Custom source: xsl:* elements and XPath functions.
// Skip when cursor is on an attribute name (let xmlCompletionSource handle it).
const xsltSource = (ctx) => {
const node = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
if (node.name === "AttributeName") return null;
const word = ctx.matchBefore(/[\w:()-]+/);
if (!word && !ctx.explicit) return null;
return {
from: word ? word.from : ctx.pos,
options: completions,
validFor: /^[\w:()-]*$/,
};
};
const attrSource = completeFromSchema(xmlElements, []);
extensions.push(
autocompletion({ override: [xsltSource, attrSource] }),
hoverTooltip((view, pos) => hoverDesc.resolve(view, pos), { hoverTime: 300 }),
);
}
const containerRef = useRef(null);
const viewRef = useRef(null);
const compartments = useRef(null);
if (!compartments.current) {
compartments.current = {
theme: new Compartment(),
base: new Compartment(),
extras: new Compartment(),
};
}
if (options.wordWrap) extensions.push(EditorView.lineWrapping);
// Keep the latest callbacks reachable from CodeMirror listeners without
// reconfiguring the view every time a parent re-renders with new closures.
const cbRef = useRef({});
cbRef.current = { onChange, onFocus, onBlur };
// Create the view once.
useEffect(() => {
const c = compartments.current;
const state = EditorState.create({
doc: value ?? "",
extensions: [
cmSizeTheme,
syntaxHighlighting(defaultHighlightStyle),
c.theme.of(cmThemeExt(theme)),
c.base.of(cmBaseExt(options, editable)),
c.extras.of(cmExtrasExt(extras, editable, xsltVersion)),
EditorView.updateListener.of((vu) => {
if (
vu.docChanged &&
!vu.transactions.some((tr) => tr.annotation(ExternalChange))
) {
cbRef.current.onChange?.(vu.state.doc.toString());
}
}),
EditorView.domEventHandlers({
focus: () => cbRef.current.onFocus?.(),
blur: () => cbRef.current.onBlur?.(),
}),
],
});
const view = new EditorView({ state, parent: containerRef.current });
viewRef.current = view;
onMount?.(view);
return () => {
view.destroy();
viewRef.current = null;
};
// Mount-only; live prop changes are handled by the reconfigure effects below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync the controlled value without stomping the cursor on our own edits.
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const next = value ?? "";
if (next !== view.state.doc.toString()) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: next },
annotations: [ExternalChange.of(true)],
});
}
}, [value]);
// Reconfigure compartments when the inputs that shape them change.
useEffect(() => {
viewRef.current?.dispatch({
effects: compartments.current.theme.reconfigure(cmThemeExt(theme)),
});
}, [theme]);
useEffect(() => {
viewRef.current?.dispatch({
effects: compartments.current.base.reconfigure(cmBaseExt(options, editable)),
});
// options is a fresh literal each render; depend on the fields we read.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editable, options.lineNumbers, options.wordWrap]);
useEffect(() => {
viewRef.current?.dispatch({
effects: compartments.current.extras.reconfigure(
cmExtrasExt(extras, editable, xsltVersion),
),
});
}, [extras, editable, xsltVersion]);
const style = height ? { height, overflow: "hidden" } : undefined;
return (
<div style={style} {...wrapperProps}>
<CodeMirror
value={value ?? ""}
onChange={(val) => onChange?.(val)}
extensions={extensions}
theme={theme === "vs-dark" ? oneDark : "light"}
height={height || "100%"}
editable={!options.readOnly}
basicSetup={{
lineNumbers: options.lineNumbers !== "off",
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
highlightActiveLine: !options.readOnly,
highlightSelectionMatches: false,
}}
onFocus={onFocus}
onBlur={onBlur}
style={{ height: "100%", fontSize: "13px" }}
/>
</div>
);
return <div style={style} {...wrapperProps} ref={containerRef} />;
}
function debounce(fn, delay) {
+8 -1
View File
@@ -26,7 +26,14 @@ export default defineConfig({
output: {
manualChunks: {
react: ["react", "react-dom"],
codemirror: ["@uiw/react-codemirror", "@codemirror/lang-xml", "@codemirror/theme-one-dark"],
codemirror: [
"@codemirror/state",
"@codemirror/view",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lang-xml",
"@codemirror/theme-one-dark",
],
},
},
},