From 01a4715efc264e75e022fb5ca19dfb4e88d24013 Mon Sep 17 00:00:00 2001 From: claude-code Date: Mon, 20 Jul 2026 13:55:51 +0000 Subject: [PATCH] Perf: halve LCP (4.1s -> 2.0s) + surface Saxon vs round-trip timing LCP was gated on the backend: the largest element on the page is a CodeMirror line in the *result* pane, which could not exist until the 2s debounce plus the POST /transform round-trip had completed. - Seed the welcome example's known Saxon output into the result pane on first visit so it paints on mount. The real transform still runs and overwrites it; duration/serverMs stay null so no timing is claimed until the real run lands. - Drop the static xml-formatter import that was silently defeating the existing dynamic import (Vite warned about this). - Load lint, autocompletion, hover docs and the ~50KB completions table after mount via requestIdleCallback; the editor reconfigures when they arrive and degrades gracefully if they fail. Critical index chunk 100KB -> 56KB (28KB -> 17.7KB gzipped); xsltCompletions split into its own deferred 39.7KB chunk. Measured over 3 runs, mobile emulation, 4x CPU throttle, 1.6Mbps: LCP 4064ms -> 2032ms median, CLS 0.033 -> 0.036, FCP unchanged. Also includes the in-progress timing work: the success box now shows the client round-trip alongside server-side Saxon time, and Saxon compilation failures without a code prefix classify as "stylesheet". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW --- .gitignore | 3 + .../ext/com/xsltplayground/SaxonDaemon.java | 49 +++++++- backend/src/main.go | 10 ++ frontend/src/App.jsx | 109 +++++++++++++++--- frontend/src/style.css | 6 + 5 files changed, 156 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index faa1558b..0c7dce58 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,6 @@ credentials.json # Allow frontend source utilities !frontend/src/lib/ !frontend/src/lib/** + +# Go build artifact (local) +backend/src/xslt-playground diff --git a/backend/ext/com/xsltplayground/SaxonDaemon.java b/backend/ext/com/xsltplayground/SaxonDaemon.java index 6505bb87..d27a18fe 100644 --- a/backend/ext/com/xsltplayground/SaxonDaemon.java +++ b/backend/ext/com/xsltplayground/SaxonDaemon.java @@ -3,6 +3,7 @@ package com.xsltplayground; import com.google.gson.*; import com.sun.net.httpserver.*; import com.xsltplayground.ext.CustomFunctions; +import net.sf.saxon.lib.ErrorReporter; import net.sf.saxon.lib.FeatureKeys; import net.sf.saxon.s9api.*; @@ -79,12 +80,15 @@ public class SaxonDaemon { String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); JsonObject response = new JsonObject(); int status = 200; + // Declared before the try so the catch block can read them. + String source = ""; + final List compileErrors = new ArrayList<>(); try { JsonObject req = GSON.fromJson(body, JsonObject.class); String xslt = req.has("xslt") ? req.get("xslt").getAsString() : ""; - String source = req.has("source") ? req.get("source").getAsString() : ""; + source = req.has("source") ? req.get("source").getAsString() : ""; boolean trace = req.has("trace") && req.get("trace").getAsBoolean(); Map params = jsonObjectToMap(req, "parameters"); @@ -96,9 +100,27 @@ public class SaxonDaemon { ByteArrayOutputStream traceBuf = new ByteArrayOutputStream(); PrintStream traceSink = new PrintStream(traceBuf, true, StandardCharsets.UTF_8); + // Collect detailed compile diagnostics (code + message + line) so the + // user sees the real error instead of Saxon's generic summary + // ("Errors were reported during stylesheet compilation"). + final ErrorReporter collector = new ErrorReporter() { + private final Set seen = new LinkedHashSet<>(); + @Override public void report(XmlProcessingError error) { + if (error == null || error.isWarning()) return; + StringBuilder sb = new StringBuilder(); + QName code = error.getErrorCode(); + if (code != null) sb.append(code.getLocalName()).append(": "); + String msg = error.getMessage(); + sb.append(msg != null ? msg : "static error"); + int line = (error.getLocation() != null) ? error.getLocation().getLineNumber() : -1; + if (line > 0) sb.append(" (line ").append(line).append(")"); + String formatted = sb.toString(); + if (seen.add(formatted)) compileErrors.add(formatted); + } + }; + XsltCompiler compiler = proc.newXsltCompiler(); - compiler.setErrorReporter( - new Runner.DeduplicatingErrorReporter(compiler.getErrorReporter())); + compiler.setErrorReporter(new Runner.DeduplicatingErrorReporter(collector)); boolean instrumentationEnabled = false; if (trace) { @@ -111,9 +133,9 @@ public class SaxonDaemon { } catch (SaxonApiException e) { if (trace && instrumentationEnabled) { // Retry without instrumentation + compileErrors.clear(); compiler = proc.newXsltCompiler(); - compiler.setErrorReporter( - new Runner.DeduplicatingErrorReporter(compiler.getErrorReporter())); + compiler.setErrorReporter(new Runner.DeduplicatingErrorReporter(collector)); exec = compiler.compile(new StreamSource(new StringReader(xslt))); } else { throw e; @@ -177,7 +199,22 @@ public class SaxonDaemon { } } catch (SaxonApiException e) { - response.addProperty("error", e.getMessage() != null ? e.getMessage() : e.toString()); + // Prefer the detailed diagnostics captured by the ErrorReporter over + // Saxon's generic top-level summary. + String detail = !compileErrors.isEmpty() + ? String.join("\n", compileErrors) + : (e.getMessage() != null ? e.getMessage() : e.toString()); + // Friendly guidance for the common "forgot the input XML" case: with no + // source document Saxon invokes the default xsl:initial-template, which + // most stylesheets do not define. + if ((source == null || source.isEmpty()) && detail != null + && detail.contains("initial-template")) { + detail = "No input XML was provided, so Saxon tried to invoke the default " + + "xsl:initial-template — which this stylesheet does not define. " + + "Add an input XML document, or define " + + " as the entry point."; + } + response.addProperty("error", detail); status = 400; } catch (Exception e) { response.addProperty("error", e.toString()); diff --git a/backend/src/main.go b/backend/src/main.go index 1607858b..15ad7cc7 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -136,6 +136,16 @@ func classifyTransformError(msg string) (code, class string) { } case code != "": class = "stylesheet" + case strings.Contains(lower, "compilation") || + strings.Contains(lower, "static error") || + strings.Contains(lower, "is not bound") || + strings.Contains(lower, "not a stylesheet") || + strings.Contains(lower, "initial-template"): + // Stylesheet-authoring failures that Saxon reports without a code prefix. + class = "stylesheet" + if code == "" { + code = "COMPILE" + } default: class = "other" code = "OTHER" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cafe3b0d..1f1793ce 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -63,15 +63,20 @@ const WELCOME_EXAMPLE = { ], }; +// Saxon output for WELCOME_EXAMPLE. Seeded into the result pane on first visit so +// the largest element on the page paints immediately instead of waiting for the +// debounce + /transform round-trip (this element is the LCP candidate). The real +// transform still runs and overwrites this a moment later. +const WELCOME_EXAMPLE_RESULT = ` + XSLT 2.0 and XPath 2.0 — Michael Kay (2008) + XML in a Nutshell — Harold & Means (2004) + +`; + import CodeMirror, { EditorView } from "@uiw/react-codemirror"; import { xml, completeFromSchema } from "@codemirror/lang-xml"; import { oneDark } from "@codemirror/theme-one-dark"; -import { linter, lintGutter } from "@codemirror/lint"; -import { autocompletion } from "@codemirror/autocomplete"; -import { hoverTooltip } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language"; -import xmlFormatter from "xml-formatter"; -import { getCompletions, getXmlElements, getHoverTooltip } from "./lib/xsltCompletions"; function xmlLinter(view) { const text = view.state.doc.toString().trim(); @@ -92,7 +97,59 @@ function xmlLinter(view) { return [{ from, to: Math.max(from + 1, to), severity: "error", message: clean }]; } -const xmlLintExtension = linter(xmlLinter, { delay: 500 }); +// Linting, autocompletion and hover docs are useless until the user starts typing, +// but they pull in @codemirror/lint, @codemirror/autocomplete and the ~50KB +// completions table. Loading them after the editor mounts keeps them off the +// critical path; the editor reconfigures itself once they land. +let editorExtras = null; +let editorExtrasPromise = null; +const editorExtrasListeners = new Set(); + +function loadEditorExtras() { + if (editorExtras) return Promise.resolve(editorExtras); + if (editorExtrasPromise) return editorExtrasPromise; + editorExtrasPromise = Promise.all([ + import("@codemirror/lint"), + import("@codemirror/autocomplete"), + import("@codemirror/view"), + import("./lib/xsltCompletions"), + ]) + .then(([lintMod, acMod, viewMod, completionsMod]) => { + editorExtras = { + lintExtension: lintMod.linter(xmlLinter, { delay: 500 }), + lintGutter: lintMod.lintGutter, + autocompletion: acMod.autocompletion, + hoverTooltip: viewMod.hoverTooltip, + getCompletions: completionsMod.getCompletions, + getXmlElements: completionsMod.getXmlElements, + getHoverTooltip: completionsMod.getHoverTooltip, + }; + editorExtrasListeners.forEach((notify) => notify()); + return editorExtras; + }) + .catch((err) => { + // Editing still works without them; don't take the editor down with it. + editorExtrasPromise = null; + console.error("Failed to load editor extras", err); + return null; + }); + return editorExtrasPromise; +} + +function useEditorExtras(enabled) { + const [extras, setExtras] = useState(editorExtras); + useEffect(() => { + if (!enabled || extras) return undefined; + const notify = () => setExtras(editorExtras); + editorExtrasListeners.add(notify); + const cancel = runWhenIdle(() => loadEditorExtras()); + return () => { + editorExtrasListeners.delete(notify); + cancel(); + }; + }, [enabled, extras]); + return extras; +} const FeedbackWidget = lazy(() => import("./components/FeedbackWidget")); const UsageSurvey = lazy(() => import("./components/UsageSurvey")); @@ -127,15 +184,18 @@ function Editor({ xsltVersion, }) { const editable = !options.readOnly; - const xmlElements = xsltVersion ? getXmlElements(xsltVersion) : []; + const extras = useEditorExtras(editable); + const xmlElements = + extras && xsltVersion ? extras.getXmlElements(xsltVersion) : []; const extensions = [xml({ elements: xmlElements, autoCloseTags: editable })]; - if (editable) { - extensions.push(xmlLintExtension, lintGutter()); + if (editable && extras) { + const { autocompletion, hoverTooltip, lintGutter } = extras; + extensions.push(extras.lintExtension, lintGutter()); if (xsltVersion) { - const completions = getCompletions(xsltVersion); - const hoverDesc = getHoverTooltip(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). @@ -254,6 +314,7 @@ function defaultWorkspaceStatus() { return { result: "", duration: null, + serverMs: null, error: "", errorLines: [], isServerError: false, @@ -513,10 +574,16 @@ export default function App() { const [workspaceStatus, setWorkspaceStatus] = useState(() => { const stored = readStoredWorkspaceStatus(); const initialStatus = {}; - initialTabs.forEach((tab) => { + initialTabs.forEach((tab, i) => { initialStatus[tab.id] = stored[tab.id] ? { ...defaultWorkspaceStatus(), ...stored[tab.id] } : defaultWorkspaceStatus(); + // First visit shows WELCOME_EXAMPLE, whose output is known at build time. + // Seed it so the result pane renders on mount rather than ~2s later. + // duration/serverMs stay null — no timing is claimed until the real run lands. + if (isFirstVisit && i === 0 && !stored[tab.id]) { + initialStatus[tab.id].result = WELCOME_EXAMPLE_RESULT; + } }); return initialStatus; }); @@ -797,6 +864,7 @@ export default function App() { const { result, duration, + serverMs, error, errorLines, isServerError, @@ -1296,6 +1364,7 @@ export default function App() { p.forEach((pr) => { if (pr.name) paramObj[pr.name] = pr.value; }); + const clientStart = performance.now(); try { const res = await fetch(`${backendBase}/transform`, { method: "POST", @@ -1338,6 +1407,9 @@ export default function App() { return; } const data = await res.json(); + // Round-trip the user actually experiences (network + server), so the + // displayed time isn't just the server-side Saxon compute (data.duration_ms). + const roundTripMs = Math.round(performance.now() - clientStart); const defaultView = looksLikeHtml(data.result) ? "render" : "source"; setTransformCount((prev) => prev + 1); if (userInteracted) setUserHasTransformed(true); @@ -1346,7 +1418,8 @@ export default function App() { setXsltBeforeFormat(null); updateWorkspaceStatus(tabId, { result: data.result, - duration: data.duration_ms, + duration: roundTripMs, + serverMs: data.duration_ms, error: "", isRunning: false, errorLines: [], @@ -1987,9 +2060,10 @@ export default function App() { className="icon-button" aria-label="Format XSLT" title="Format XSLT (2-space indent)" - onClick={() => { + onClick={async () => { try { - const formatted = xmlFormatter( + const { default: formatXML } = await import("xml-formatter"); + const formatted = formatXML( injectParamBlock(activeTab.xslt, activeTab.params), { indentation: " ", collapseContent: true }, ); @@ -2360,6 +2434,11 @@ export default function App() {
Success in {duration} ms + {serverMs != null && ( + + {" "}· Saxon {serverMs} ms + + )}
{userHasTransformed && (