From 3003452310e0d569554e9a9bc3457709b7e2473d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 1 Aug 2026 00:35:43 +0200 Subject: [PATCH] perf(studio): defer the source editor and markdown preview off first paint The studio shipped one 3.7 MB eager entry chunk: 1,126,473 B gzip against the 600 KiB eagerEntryChunkGzipBytes budget. CodeMirror and the markdown renderer were the two largest blocks on that path and neither is needed to paint the shell. - React.lazy the source editor (@codemirror/*, @lezer/*) and the storyboard source view (marked, dompurify), copying the deferred-dependency shape of the mediabunny dynamic import in player/lib/mediaProbe.ts. - Add LazyPanel: one Suspense + error boundary whose fallback fills the panel's slot (h-full w-full flex-1) so opening the editor cannot shift the layout, and whose rejected-chunk state renders a visible retry instead of a blank panel. - manualChunks splits the React runtime and the remaining vendor code out of the app chunk so app-only deploys stop invalidating them for repeat visitors. - tests/e2e/studio-bundle-budget.mjs gates the built artifact against eagerEntryChunkGzipBytes with no browser. It measures the entry module plus its modulepreloaded chunks, not the entry file alone: the entry file alone now gzips to 444,432 B and would report a false pass while the browser still downloads 860,966 B before first paint. Eager gzip drops 1,126,473 B -> 860,966 B (-23.6%). Still 40% over the 600 KiB budget, so the gate fails by design; the remainder is a Node-only AST stack (@babel/parser, esprima, acorn, recast, ast-types) pulled into the browser bundle through @hyperframes/sdk, which is outside this unit. --- packages/studio/package.json | 3 +- .../studio/src/components/LazyPanel.test.tsx | 56 +++++++++ packages/studio/src/components/LazyPanel.tsx | 63 ++++++++++ .../src/components/StudioLeftSidebar.tsx | 4 +- .../components/editor/LazySourceEditor.tsx | 15 +++ .../src/components/editor/SourceEditor.tsx | 2 +- .../storyboard/StoryboardLoaded.tsx | 23 ++-- .../storyboard/StoryboardSourceEditor.tsx | 4 +- .../studio/tests/e2e/studio-bundle-budget.mjs | 110 ++++++++++++++++++ .../tests/e2e/studio-bundle-budget.test.ts | 83 +++++++++++++ packages/studio/vite.config.ts | 28 +++++ 11 files changed, 378 insertions(+), 13 deletions(-) create mode 100644 packages/studio/src/components/LazyPanel.test.tsx create mode 100644 packages/studio/src/components/LazyPanel.tsx create mode 100644 packages/studio/src/components/editor/LazySourceEditor.tsx create mode 100644 packages/studio/tests/e2e/studio-bundle-budget.mjs create mode 100644 packages/studio/tests/e2e/studio-bundle-budget.test.ts diff --git a/packages/studio/package.json b/packages/studio/package.json index c59a03f4ff..35064a333e 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -54,7 +54,8 @@ "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts", "test:timeline-default": "bun run test:timeline-virtualization", "test:studio-load": "STUDIO_LOAD_ARM=dev bun tests/e2e/studio-load.mjs", - "test:studio-load-embedded": "STUDIO_LOAD_ARM=embedded bun tests/e2e/studio-load.mjs" + "test:studio-load-embedded": "STUDIO_LOAD_ARM=embedded bun tests/e2e/studio-load.mjs", + "test:studio-load-bundle-budget": "vite build && node tests/e2e/studio-bundle-budget.mjs" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", diff --git a/packages/studio/src/components/LazyPanel.test.tsx b/packages/studio/src/components/LazyPanel.test.tsx new file mode 100644 index 0000000000..1b3ab94629 --- /dev/null +++ b/packages/studio/src/components/LazyPanel.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom +import { act, lazy, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LazyPanel } from "./LazyPanel"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + vi.restoreAllMocks(); +}); + +async function render(node: ReactNode): Promise { + const host = document.createElement("div"); + root = createRoot(host); + await act(async () => root?.render(node)); + return host; +} + +describe("LazyPanel", () => { + it("keeps the panel slot occupied while a chunk loads", async () => { + const Pending = lazy(() => new Promise(() => undefined)); + const host = await render( + + + , + ); + + const status = host.querySelector('[role="status"]'); + expect(status?.textContent).toBe("Loading source editor…"); + expect(status?.classList.contains("h-full")).toBe(true); + expect(status?.classList.contains("flex-1")).toBe(true); + }); + + it("surfaces a rejected chunk load as a visible retry state", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const Rejected = lazy(() => Promise.reject(new Error("chunk unavailable"))); + const host = await render( + + + , + ); + + const alert = host.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("Couldn’t load source editor."); + expect(alert?.querySelector("button")?.textContent).toBe("Retry"); + }); +}); diff --git a/packages/studio/src/components/LazyPanel.tsx b/packages/studio/src/components/LazyPanel.tsx new file mode 100644 index 0000000000..9b6b65457c --- /dev/null +++ b/packages/studio/src/components/LazyPanel.tsx @@ -0,0 +1,63 @@ +import { Component, Suspense, type ReactNode } from "react"; + +/** + * Suspense + error boundary for a `React.lazy` panel. The fallback fills the + * panel's slot (`h-full w-full flex-1`) so a deferred panel can't collapse the + * layout while its chunk is in flight, and a rejected chunk load renders a + * visible, recoverable error instead of an empty box. + */ +interface LazyPanelProps { + children: ReactNode; + label: string; +} + +interface LazyPanelErrorBoundaryState { + failed: boolean; +} + +class LazyPanelErrorBoundary extends Component { + state: LazyPanelErrorBoundaryState = { failed: false }; + + static getDerivedStateFromError(): LazyPanelErrorBoundaryState { + return { failed: true }; + } + + render() { + if (!this.state.failed) return this.props.children; + + return ( +
+ Couldn’t load {this.props.label}. + +
+ ); + } +} + +export function LazyPanel({ children, label }: LazyPanelProps) { + return ( + + + Loading {label}… + + } + > + {children} + + + ); +} diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index 4ca96ee692..979d454730 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -1,5 +1,5 @@ import { useCallback, type RefObject } from "react"; -import { SourceEditor } from "./editor/SourceEditor"; +import { LazySourceEditor } from "./editor/LazySourceEditor"; import { LeftSidebar, type LeftSidebarHandle } from "./sidebar/LeftSidebar"; import { MediaPreview } from "./MediaPreview"; import { isMediaFile } from "../utils/mediaTypes"; @@ -133,7 +133,7 @@ export function StudioLeftSidebar({ Loading {editingFile.path}… ) : ( - + import("./SourceEditor").then((module) => ({ default: module.SourceEditor })), +); + +export function LazySourceEditor(props: SourceEditorProps) { + return ( + + + + ); +} diff --git a/packages/studio/src/components/editor/SourceEditor.tsx b/packages/studio/src/components/editor/SourceEditor.tsx index 03e9c6cffe..aca275d1bf 100644 --- a/packages/studio/src/components/editor/SourceEditor.tsx +++ b/packages/studio/src/components/editor/SourceEditor.tsx @@ -56,7 +56,7 @@ function detectLanguage(filePath: string): string { return map[ext] ?? "html"; } -interface SourceEditorProps { +export interface SourceEditorProps { content: string; filePath?: string; language?: string; diff --git a/packages/studio/src/components/storyboard/StoryboardLoaded.tsx b/packages/studio/src/components/storyboard/StoryboardLoaded.tsx index 14d9b0cc0d..1e44b05dea 100644 --- a/packages/studio/src/components/storyboard/StoryboardLoaded.tsx +++ b/packages/studio/src/components/storyboard/StoryboardLoaded.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; +import { lazy, useEffect, useMemo, useState } from "react"; import type { StoryboardResponse } from "../../hooks/useStoryboard"; import { Button } from "../ui/Button"; +import { LazyPanel } from "../LazyPanel"; import { StoryboardDirection } from "./StoryboardDirection"; import { StoryboardGrid } from "./StoryboardGrid"; import { StoryboardScriptPanel } from "./StoryboardScriptPanel"; -import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor"; +import type { SourceFile } from "./StoryboardSourceEditor"; import { StoryboardFrameFocus } from "./StoryboardFrameFocus"; import { StoryboardReviewGuide } from "./StoryboardReviewGuide"; import { @@ -15,6 +16,12 @@ import { useFrameComments, type CommentsSubmitState } from "./useFrameComments"; type SubView = "board" | "source"; +const StoryboardSourceEditor = lazy(() => + import("./StoryboardSourceEditor").then((module) => ({ + default: module.StoryboardSourceEditor, + })), +); + export interface StoryboardLoadedProps { projectId: string; data: StoryboardResponse; @@ -162,11 +169,13 @@ export function StoryboardLoaded({ ) : ( - + + + )} ); diff --git a/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx b/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx index 5e305ed954..e1426cf37e 100644 --- a/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx +++ b/packages/studio/src/components/storyboard/StoryboardSourceEditor.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { marked } from "marked"; import DOMPurify from "dompurify"; -import { SourceEditor } from "../editor/SourceEditor"; +import { LazySourceEditor } from "../editor/LazySourceEditor"; import { useFileManagerContext } from "../../contexts/FileManagerContext"; export interface SourceFile { @@ -214,7 +214,7 @@ export function StoryboardSourceEditor({ {file.loading ? (
Loading {activePath}…
) : ( - `), not + * just the entry file. Measuring the entry file alone would let the + * `manualChunks` config in vite.config.ts move bytes out of the number without + * moving them off the critical path: on the current build the entry file alone + * gzips to 444 KB and would report a pass, while the browser actually downloads + * 861 KB before first paint. + * + * Usage: bun tests/e2e/studio-bundle-budget.mjs [distDir] + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; +import { STUDIO_LOAD_BUDGETS } from "../../src/lib/studioLoadBudgets.ts"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_DIST = resolve(HERE, "../../dist"); + +function attribute(tag, name) { + const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i")); + return match?.[1] ?? match?.[2] ?? match?.[3] ?? null; +} + +/** + * @param {string} html contents of dist/index.html + * @returns {{ entries: string[], preloads: string[] }} asset URLs, as authored + */ +export function findEagerAssets(html) { + const entries = [...html.matchAll(/]*>/gi)].flatMap(([tag]) => { + const src = attribute(tag, "src"); + return attribute(tag, "type")?.toLowerCase() === "module" && src ? [src] : []; + }); + const preloads = [...html.matchAll(/]*>/gi)].flatMap(([tag]) => { + const href = attribute(tag, "href"); + return attribute(tag, "rel")?.toLowerCase() === "modulepreload" && href ? [href] : []; + }); + return { entries, preloads }; +} + +function resolveAsset(distDir, url) { + const pathname = decodeURIComponent(new URL(url, "https://studio.invalid").pathname); + const filePath = resolve(distDir, `.${pathname}`); + if (relative(distDir, filePath).startsWith("..")) { + throw new Error(`Asset resolves outside dist: ${url}`); + } + if (!existsSync(filePath)) { + throw new Error(`index.html references a missing asset: ${filePath}`); + } + return filePath; +} + +function line(name, measured, budget, passed, unit = "") { + const status = passed ? "PASS" : "FAIL"; + return `[StudioBundleBudget] ${status} ${name} measured=${measured}${unit} budget=${budget}${unit}`; +} + +/** + * @param {string} distDir directory holding the built index.html + * @param {number} budgetGzipBytes + * @returns {{ passed: boolean, report: string, gzipBytes: number }} + */ +export function checkEagerBundleBudget(distDir, budgetGzipBytes) { + const indexPath = resolve(distDir, "index.html"); + if (!existsSync(indexPath)) { + throw new Error(`No built artifact at ${indexPath} — run \`vite build\` in packages/studio`); + } + + const { entries, preloads } = findEagerAssets(readFileSync(indexPath, "utf8")); + const lines = [line("eagerEntryModuleCount", entries.length, 1, entries.length === 1)]; + if (entries.length !== 1) { + return { passed: false, report: lines.join("\n"), gzipBytes: 0 }; + } + + let gzipBytes = 0; + for (const url of [entries[0], ...preloads]) { + const bytes = gzipSync(readFileSync(resolveAsset(distDir, url))).byteLength; + gzipBytes += bytes; + lines.push(`[StudioBundleBudget] eager ${url} gzip=${bytes}B`); + } + + const passed = gzipBytes <= budgetGzipBytes; + lines.push(line("eagerEntryChunkGzipBytes", gzipBytes, budgetGzipBytes, passed, "B")); + return { passed, report: lines.join("\n"), gzipBytes }; +} + +// Run the gate only when invoked directly, so the vitest suite can import the helpers. +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + try { + const { passed, report } = checkEagerBundleBudget( + resolve(process.argv[2] ?? DEFAULT_DIST), + STUDIO_LOAD_BUDGETS.eagerEntryChunkGzipBytes, + ); + console.log(report); + if (!passed) process.exitCode = 1; + } catch (error) { + console.error( + `[StudioBundleBudget] FAIL ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/packages/studio/tests/e2e/studio-bundle-budget.test.ts b/packages/studio/tests/e2e/studio-bundle-budget.test.ts new file mode 100644 index 0000000000..262dcb0d99 --- /dev/null +++ b/packages/studio/tests/e2e/studio-bundle-budget.test.ts @@ -0,0 +1,83 @@ +import { randomBytes } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { STUDIO_LOAD_BUDGETS } from "../../src/lib/studioLoadBudgets"; +// @ts-expect-error -- plain .mjs gate script, no type declarations +import { checkEagerBundleBudget, findEagerAssets } from "./studio-bundle-budget.mjs"; + +const ENTRY_TAG = ''; +const PRELOAD_TAG = ''; +const STYLE_TAG = ''; +const BUDGET = STUDIO_LOAD_BUDGETS.eagerEntryChunkGzipBytes; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } +}); + +/** Incompressible bytes, so each fixture's gzip size tracks its declared size. */ +function fixtureDist(entryBytes: number, preloadBytes?: number): string { + const distDir = mkdtempSync(join(tmpdir(), "studio-bundle-budget-")); + temporaryDirectories.push(distDir); + mkdirSync(join(distDir, "assets")); + const preload = preloadBytes == null ? "" : PRELOAD_TAG; + writeFileSync(join(distDir, "index.html"), `${ENTRY_TAG}${preload}${STYLE_TAG}`); + writeFileSync(join(distDir, "assets/index.js"), randomBytes(entryBytes)); + if (preloadBytes != null) { + writeFileSync(join(distDir, "assets/vendor.js"), randomBytes(preloadBytes)); + } + return distDir; +} + +describe("studio bundle budget", () => { + it("reads exactly one eagerly-loaded entry module plus its modulepreloads", () => { + const { entries, preloads } = findEagerAssets(`${ENTRY_TAG}${PRELOAD_TAG}${STYLE_TAG}`); + expect(entries).toEqual(["/assets/index.js"]); + // The stylesheet link must not be mistaken for a preloaded module. + expect(preloads).toEqual(["/assets/vendor.js"]); + }); + + it("fails when index.html has more than one eager entry module", () => { + const distDir = fixtureDist(64); + writeFileSync(join(distDir, "index.html"), `${ENTRY_TAG}${ENTRY_TAG}`); + + const { passed, report } = checkEagerBundleBudget(distDir, BUDGET); + + expect(passed).toBe(false); + expect(report).toContain("FAIL eagerEntryModuleCount measured=2 budget=1"); + }); + + it("fails, naming the measured and budgeted values, on an over-budget artifact", () => { + const { passed, report, gzipBytes } = checkEagerBundleBudget( + fixtureDist(BUDGET + 64 * 1024), + BUDGET, + ); + + expect(passed).toBe(false); + expect(report).toContain( + `FAIL eagerEntryChunkGzipBytes measured=${gzipBytes}B budget=${BUDGET}B`, + ); + }); + + it("counts modulepreloaded chunks, so a manualChunks split cannot game the budget", () => { + const total = BUDGET + 64 * 1024; + const whole = checkEagerBundleBudget(fixtureDist(total), BUDGET); + const split = checkEagerBundleBudget(fixtureDist(total / 2, total / 2), BUDGET); + + // Same eager payload, two chunks instead of one — the verdict must not change. + expect(split.gzipBytes).toBeGreaterThan(whole.gzipBytes * 0.99); + expect(split.passed).toBe(false); + }); + + it("passes when the whole eager graph fits the budget", () => { + const { passed, report } = checkEagerBundleBudget(fixtureDist(1_024, 1_024), 5_000_000); + + expect(passed).toBe(true); + expect(report).toContain("PASS eagerEntryChunkGzipBytes"); + }); +}); diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 19ecac37ac..73b809fbd5 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -199,6 +199,34 @@ export default defineConfig({ build: { outDir: "dist", emptyOutDir: true, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes("/node_modules/@codemirror/") || id.includes("/node_modules/@lezer/")) { + return "source-editor"; + } + if ( + id.includes("/node_modules/crelt/") || + id.includes("/node_modules/style-mod/") || + id.includes("/node_modules/w3c-keyname/") + ) { + return "source-editor"; + } + if (id.includes("/node_modules/marked/") || id.includes("/node_modules/dompurify/")) { + return "markdown"; + } + if (id.includes("/node_modules/mediabunny/")) return "media-probe"; + if ( + id.includes("/node_modules/react/") || + id.includes("/node_modules/react-dom/") || + id.includes("/node_modules/scheduler/") + ) { + return "react-vendor"; + } + if (id.includes("/node_modules/")) return "vendor"; + }, + }, + }, }, optimizeDeps: { include: ["bpm-detective"],