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:
@@ -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());
|
||||
|
||||
@@ -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
@@ -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 & 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user