Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/studio/src/components/LazyPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
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<never>(() => undefined));
const host = await render(
<LazyPanel label="source editor">
<Pending />
</LazyPanel>,
);

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(
<LazyPanel label="source editor">
<Rejected />
</LazyPanel>,
);

const alert = host.querySelector('[role="alert"]');
expect(alert?.textContent).toContain("Couldn’t load source editor.");
expect(alert?.querySelector("button")?.textContent).toBe("Retry");
});
});
63 changes: 63 additions & 0 deletions packages/studio/src/components/LazyPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<LazyPanelProps, LazyPanelErrorBoundaryState> {
state: LazyPanelErrorBoundaryState = { failed: false };

static getDerivedStateFromError(): LazyPanelErrorBoundaryState {
return { failed: true };
}

render() {
if (!this.state.failed) return this.props.children;

return (
<div
role="alert"
className="flex h-full min-h-0 w-full flex-1 flex-col items-center justify-center gap-3 text-center text-xs text-red-300"
>
<span>Couldn’t load {this.props.label}.</span>
<button
type="button"
onClick={() => window.location.reload()}
className="rounded border border-neutral-700 px-3 py-1.5 text-neutral-300 transition-colors hover:bg-neutral-800 hover:text-white"
>
Retry
</button>
</div>
);
}
}

export function LazyPanel({ children, label }: LazyPanelProps) {
return (
<LazyPanelErrorBoundary label={label}>
<Suspense
fallback={
<div
role="status"
className="flex h-full min-h-0 w-full flex-1 items-center justify-center text-xs text-neutral-500"
>
Loading {label}…
</div>
}
>
{children}
</Suspense>
</LazyPanelErrorBoundary>
);
}
4 changes: 2 additions & 2 deletions packages/studio/src/components/StudioLeftSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -133,7 +133,7 @@ export function StudioLeftSidebar({
Loading {editingFile.path}…
</div>
) : (
<SourceEditor
<LazySourceEditor
content={editingFile.content}
filePath={editingFile.path}
onChange={handleContentChange}
Expand Down
15 changes: 15 additions & 0 deletions packages/studio/src/components/editor/LazySourceEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { lazy } from "react";
import { LazyPanel } from "../LazyPanel";
import type { SourceEditorProps } from "./SourceEditor";

const SourceEditor = lazy(() =>
import("./SourceEditor").then((module) => ({ default: module.SourceEditor })),
);

export function LazySourceEditor(props: SourceEditorProps) {
return (
<LazyPanel label="source editor">
<SourceEditor {...props} />
</LazyPanel>
);
}
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/SourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function detectLanguage(filePath: string): string {
return map[ext] ?? "html";
}

interface SourceEditorProps {
export interface SourceEditorProps {
content: string;
filePath?: string;
language?: string;
Expand Down
23 changes: 16 additions & 7 deletions packages/studio/src/components/storyboard/StoryboardLoaded.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -162,11 +169,13 @@ export function StoryboardLoaded({
</div>
</div>
) : (
<StoryboardSourceEditor
files={sourceFiles}
onSaved={reload}
onDirtyChange={setSourceDirty}
/>
<LazyPanel label="storyboard source editor">
<StoryboardSourceEditor
files={sourceFiles}
onSaved={reload}
onDirtyChange={setSourceDirty}
/>
</LazyPanel>
)}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -214,7 +214,7 @@ export function StoryboardSourceEditor({
{file.loading ? (
<div className="p-4 text-sm text-neutral-500">Loading {activePath}…</div>
) : (
<SourceEditor
<LazySourceEditor
content={file.content}
language="markdown"
filePath={activePath}
Expand Down
110 changes: 110 additions & 0 deletions packages/studio/tests/e2e/studio-bundle-budget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Byte budget for the studio's eagerly-loaded JavaScript.
*
* Reads the built `dist/index.html`, resolves every script the browser must
* fetch before it can render anything, gzips them, and fails above
* `eagerEntryChunkGzipBytes` from src/lib/studioLoadBudgets.ts. No browser and
* no dev server, so it is cheap enough to run on every studio PR.
*
* "Eagerly loaded" deliberately means the entry module PLUS the chunks it
* statically imports (which Vite emits as `<link rel="modulepreload">`), 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(/<script\b[^>]*>/gi)].flatMap(([tag]) => {
const src = attribute(tag, "src");
return attribute(tag, "type")?.toLowerCase() === "module" && src ? [src] : [];
});
const preloads = [...html.matchAll(/<link\b[^>]*>/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;
}
}
Loading
Loading