From 6bfedaeaf1a40cc15afefdca3c70ef66ea34bc91 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Wed, 8 Jul 2026 23:59:57 +0300 Subject: [PATCH] perf: lazy-load the CodeMirror editor off the playground critical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playground entry chunk statically imported CodeMirror, forcing ~365 kB onto the critical path and inflating Total Blocking Time — the largest single lever in the Lighthouse performance score. The editor now loads via dynamic import() after the shell paints, in its own lazy chunk, mirroring how the Three.js renderer already loads. The play entry chunk drops from 372 kB to 7.85 kB (122 kB to 3.3 kB gzip); both heavyweights now load after first paint. editorApi is nullable until the chunk resolves: recompile() captures it once and self-guards, and the user-action handlers (library pick, New, Share) optional- chain, so a click during the ~tens-of-ms load window safely no-ops. --- playground/src/main.ts | 46 ++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/playground/src/main.ts b/playground/src/main.ts index a81d806..982b39a 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -11,7 +11,7 @@ import { parse } from "posecode-parser"; import { inject } from "@vercel/analytics"; import type { Viewer } from "posecode-render"; import { buildNiceShareHash, resolveSharedSource } from "./nice-share.js"; -import { createPosecodeEditor, type PosecodeEditor } from "./editor.js"; +import type { PosecodeEditor } from "./editor.js"; import { PRESETS } from "./presets.js"; import { renderWarnings } from "./warnings.js"; import llmPrompt from "../../spec/llm-authoring.md?raw"; @@ -21,7 +21,10 @@ inject(); const $ = (id: string): T => document.getElementById(id) as T; -let editorApi: PosecodeEditor; // assigned at boot, once the initial doc is known +// CodeMirror is heavy too, so the editor is lazy-loaded after first paint (its +// own chunk). Until that resolves, `editorApi` is null; recompile() and the +// user-action handlers guard on it (a click in that ~tens-of-ms window no-ops). +let editorApi: PosecodeEditor | null = null; const warnings = $("warnings"); const canvas = $("canvas"); const playpause = $("playpause"); @@ -156,7 +159,11 @@ function scheduleRecompile(): void { function recompile(): void { window.clearTimeout(debounce); // cancel any pending run; we're compiling now - const source = editorApi.getValue(); + // Captured once so control-flow narrowing survives the calls below; a null + // editor (still loading) means there's nothing to compile yet. + const ed = editorApi; + if (!ed) return; + const source = ed.getValue(); if (!source.trim()) { // A deliberately blank editor (the "New" flow): a paste target, not an // error state. Show a hint instead of parse errors and stop the playback. @@ -181,10 +188,10 @@ function recompile(): void { updateReps(); buildRibbonAndMarkers(); phaseRanges = computePhaseRanges( - editorApi.getValue(), + ed.getValue(), (tl?.segments ?? []).map((s) => s.name), ); - editorApi.highlightPhase(null); // next onPhase paints the active block + ed.highlightPhase(null); // next onPhase paints the active block } } @@ -326,7 +333,7 @@ function loadPreset(id: string): void { if (!preset) return; currentPresetId = preset.id; libCurrent.textContent = preset.label; - editorApi.setValue(preset.source); + editorApi?.setValue(preset.source); recompile(); setMobileView("viewer"); // on phones, jump to the figure after picking } @@ -345,10 +352,10 @@ renderLibraryList(); $("new-doc").addEventListener("click", () => { currentPresetId = null; libCurrent.textContent = "New movement"; - editorApi.setValue(""); + editorApi?.setValue(""); recompile(); // swaps the status row to the blank-editor hint immediately setMobileView("editor"); // the paste target, front and center on phones - editorApi.focus(); + editorApi?.focus(); }); // --- Mobile Editor/Viewer toggle (no-op visually on desktop, where both show) --- @@ -402,6 +409,7 @@ copyBtn.addEventListener("click", () => copyPrompt(copyBtn)); // Snapshot the current document into a URL hash, reflect it in the address bar // (so it's bookmarkable), and copy the full link to the clipboard. async function shareLink(): Promise { + if (!editorApi) return; // editor still loading; nothing to snapshot yet try { const hash = buildNiceShareHash(editorApi.getValue()); const url = `${location.origin}${location.pathname}${hash}`; @@ -483,17 +491,21 @@ if (sharedSource) { libCurrent.textContent = PRESETS[0]!.label; } -editorApi = createPosecodeEditor($("editor"), { - doc: initialDoc, - onChange: scheduleRecompile, +// Boot the two heavyweights (CodeMirror editor + Three.js renderer) after the +// shell paints, each in its own lazy chunk, so neither is on the critical path. +// They load independently; recompile() self-guards until both are ready, and +// whichever resolves last triggers the parse-and-animate pass. + +// Editor: mount CodeMirror into the shell, then compile so warnings surface. +void import("./editor.js").then(({ createPosecodeEditor }) => { + editorApi = createPosecodeEditor($("editor"), { + doc: initialDoc, + onChange: scheduleRecompile, + }); + recompile(); }); -// Parse + surface warnings immediately so the editor is useful at first paint, -// even though the 3D figure appears a beat later once the renderer chunk loads. -recompile(); -// Boot the renderer after first paint: dynamic import keeps Three.js off the -// critical path (its own chunk), mirroring the landing page. Once the viewer -// exists we wire its callbacks and recompile so the current doc animates. +// Renderer: keep Three.js off the critical path, mirroring the landing page. void import("posecode-render").then(({ createViewer }) => { viewer = createViewer(canvas); // Exposed for capture/e2e tooling (frame capture drives README GIFs).