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

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
This commit is contained in:
2026-07-20 13:55:51 +00:00
parent 22c5209939
commit 01a4715efc
5 changed files with 156 additions and 21 deletions
+3
View File
@@ -206,3 +206,6 @@ credentials.json
# Allow frontend source utilities
!frontend/src/lib/
!frontend/src/lib/**
# Go build artifact (local)
backend/src/xslt-playground
@@ -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<String> 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<String, String> 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<String> 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 "
+ "<xsl:template name=\"xsl:initial-template\"> as the entry point.";
}
response.addProperty("error", detail);
status = 400;
} catch (Exception e) {
response.addProperty("error", e.toString());
+10
View File
@@ -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"
+94 -15
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?><catalog total="3">
<item>XSLT 2.0 and XPath 2.0 — Michael Kay (2008)</item>
<item>XML in a Nutshell — Harold &amp; Means (2004)</item>
</catalog>
`;
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() {
<div className="success-area">
<div className="success-box" role="status" aria-live="polite">
Success in {duration} ms
{serverMs != null && (
<span className="success-server-time" title="Server-side Saxon compile + transform time (excludes network)">
{" "}· Saxon {serverMs} ms
</span>
)}
</div>
{userHasTransformed && (
<button
+6
View File
@@ -364,6 +364,12 @@ a:focus-visible {
padding: 0.5rem;
}
.success-server-time {
opacity: 0.65;
font-weight: 400;
font-size: 0.85em;
}
/* Share button — integrates into parent bar color (green success, red error) */
.share-transform-btn {
display: inline-flex;