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:
Generated
+5
-80
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user