Skip to content

Commit 6bfedae

Browse files
committed
perf: lazy-load the CodeMirror editor off the playground critical path
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.
1 parent 851010f commit 6bfedae

1 file changed

Lines changed: 29 additions & 17 deletions

File tree

playground/src/main.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { parse } from "posecode-parser";
1111
import { inject } from "@vercel/analytics";
1212
import type { Viewer } from "posecode-render";
1313
import { buildNiceShareHash, resolveSharedSource } from "./nice-share.js";
14-
import { createPosecodeEditor, type PosecodeEditor } from "./editor.js";
14+
import type { PosecodeEditor } from "./editor.js";
1515
import { PRESETS } from "./presets.js";
1616
import { renderWarnings } from "./warnings.js";
1717
import llmPrompt from "../../spec/llm-authoring.md?raw";
@@ -21,7 +21,10 @@ inject();
2121
const $ = <T extends HTMLElement>(id: string): T =>
2222
document.getElementById(id) as T;
2323

24-
let editorApi: PosecodeEditor; // assigned at boot, once the initial doc is known
24+
// CodeMirror is heavy too, so the editor is lazy-loaded after first paint (its
25+
// own chunk). Until that resolves, `editorApi` is null; recompile() and the
26+
// user-action handlers guard on it (a click in that ~tens-of-ms window no-ops).
27+
let editorApi: PosecodeEditor | null = null;
2528
const warnings = $<HTMLDivElement>("warnings");
2629
const canvas = $<HTMLCanvasElement>("canvas");
2730
const playpause = $<HTMLButtonElement>("playpause");
@@ -156,7 +159,11 @@ function scheduleRecompile(): void {
156159

157160
function recompile(): void {
158161
window.clearTimeout(debounce); // cancel any pending run; we're compiling now
159-
const source = editorApi.getValue();
162+
// Captured once so control-flow narrowing survives the calls below; a null
163+
// editor (still loading) means there's nothing to compile yet.
164+
const ed = editorApi;
165+
if (!ed) return;
166+
const source = ed.getValue();
160167
if (!source.trim()) {
161168
// A deliberately blank editor (the "New" flow): a paste target, not an
162169
// error state. Show a hint instead of parse errors and stop the playback.
@@ -181,10 +188,10 @@ function recompile(): void {
181188
updateReps();
182189
buildRibbonAndMarkers();
183190
phaseRanges = computePhaseRanges(
184-
editorApi.getValue(),
191+
ed.getValue(),
185192
(tl?.segments ?? []).map((s) => s.name),
186193
);
187-
editorApi.highlightPhase(null); // next onPhase paints the active block
194+
ed.highlightPhase(null); // next onPhase paints the active block
188195
}
189196
}
190197

@@ -326,7 +333,7 @@ function loadPreset(id: string): void {
326333
if (!preset) return;
327334
currentPresetId = preset.id;
328335
libCurrent.textContent = preset.label;
329-
editorApi.setValue(preset.source);
336+
editorApi?.setValue(preset.source);
330337
recompile();
331338
setMobileView("viewer"); // on phones, jump to the figure after picking
332339
}
@@ -345,10 +352,10 @@ renderLibraryList();
345352
$<HTMLButtonElement>("new-doc").addEventListener("click", () => {
346353
currentPresetId = null;
347354
libCurrent.textContent = "New movement";
348-
editorApi.setValue("");
355+
editorApi?.setValue("");
349356
recompile(); // swaps the status row to the blank-editor hint immediately
350357
setMobileView("editor"); // the paste target, front and center on phones
351-
editorApi.focus();
358+
editorApi?.focus();
352359
});
353360

354361
// --- Mobile Editor/Viewer toggle (no-op visually on desktop, where both show) ---
@@ -402,6 +409,7 @@ copyBtn.addEventListener("click", () => copyPrompt(copyBtn));
402409
// Snapshot the current document into a URL hash, reflect it in the address bar
403410
// (so it's bookmarkable), and copy the full link to the clipboard.
404411
async function shareLink(): Promise<void> {
412+
if (!editorApi) return; // editor still loading; nothing to snapshot yet
405413
try {
406414
const hash = buildNiceShareHash(editorApi.getValue());
407415
const url = `${location.origin}${location.pathname}${hash}`;
@@ -483,17 +491,21 @@ if (sharedSource) {
483491
libCurrent.textContent = PRESETS[0]!.label;
484492
}
485493

486-
editorApi = createPosecodeEditor($("editor"), {
487-
doc: initialDoc,
488-
onChange: scheduleRecompile,
494+
// Boot the two heavyweights (CodeMirror editor + Three.js renderer) after the
495+
// shell paints, each in its own lazy chunk, so neither is on the critical path.
496+
// They load independently; recompile() self-guards until both are ready, and
497+
// whichever resolves last triggers the parse-and-animate pass.
498+
499+
// Editor: mount CodeMirror into the shell, then compile so warnings surface.
500+
void import("./editor.js").then(({ createPosecodeEditor }) => {
501+
editorApi = createPosecodeEditor($("editor"), {
502+
doc: initialDoc,
503+
onChange: scheduleRecompile,
504+
});
505+
recompile();
489506
});
490-
// Parse + surface warnings immediately so the editor is useful at first paint,
491-
// even though the 3D figure appears a beat later once the renderer chunk loads.
492-
recompile();
493507

494-
// Boot the renderer after first paint: dynamic import keeps Three.js off the
495-
// critical path (its own chunk), mirroring the landing page. Once the viewer
496-
// exists we wire its callbacks and recompile so the current doc animates.
508+
// Renderer: keep Three.js off the critical path, mirroring the landing page.
497509
void import("posecode-render").then(({ createViewer }) => {
498510
viewer = createViewer(canvas);
499511
// Exposed for capture/e2e tooling (frame capture drives README GIFs).

0 commit comments

Comments
 (0)