Skip to content

Plan 004: Lazy-load the export dialog and video-encoding stack out of the main editor bundle #87

Description

@git-chad

Plan 004: Lazy-load the export dialog and video-encoding stack out of the main editor bundle

Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. When done, update the status row for this plan
in plans/README.md — unless a reviewer dispatched you and told you they
maintain the index.

Drift check (run first): git diff --stat 84c1d09..HEAD -- src/components/editor/editor-topbar.tsx src/components/editor/editor-export-dialog.tsx src/lib/editor/video-export-encoder.ts src/lib/editor/export.ts
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.

Status

  • Priority: P2
  • Effort: S
  • Risk: LOW
  • Depends on: none
  • Category: perf
  • Planned at: commit 84c1d09, 2026-06-10

Why this matters

The export dialog (editor-export-dialog.tsx, 1788 lines) is statically imported by the always-rendered topbar, and it statically imports the whole export pipeline — including mp4-muxer and webm-muxer — so every visitor pays the download/parse cost of the full video-export stack on initial load, even though it's only needed after clicking Export. Both muxers are pure data-processing libraries with no side effects; they are ideal dynamic-import candidates. Two cheap changes cut this out of the critical path: lazy-load the dialog component, and move the muxer imports behind await import().

Current state

  • src/components/editor/editor-topbar.tsx:39import { EditorExportDialog } from "./editor-export-dialog"; dialog visibility is local state (const [isExportDialogOpen, setIsExportDialogOpen] = useState(false) at line 96, opened at line 463). Find the JSX render site of <EditorExportDialog in the same file before editing.

  • src/lib/editor/video-export-encoder.ts:1-11 — top-level static imports:

    "use client"
    
    import {
      ArrayBufferTarget as Mp4ArrayBufferTarget,
      Muxer as Mp4Muxer,
    } from "mp4-muxer"
    import {
      ArrayBufferTarget as WebMArrayBufferTarget,
      Muxer as WebMMuxer,
    } from "webm-muxer"
  • src/lib/editor/export.ts:3-5 — imports createVideoExportEncoder from @/lib/editor/video-export-encoder; the encoder is constructed inside exportVideo at line 335 (const encoder = await createVideoExportEncoder({ ... }) — already async, good).

  • The repo uses Next.js 16 App Router; this is a client component tree. next/dynamic with ssr: false is the conventional lazy mechanism.

  • Bundle analyzer is available: bun run analyze (sets ANALYZE=true and runs next build).

Repo conventions: double quotes, no semicolons, sorted object keys, kebab-case files.

Commands you will need

Purpose Command Expected on success
Install bun install --frozen-lockfile exit 0
Build runtime pkg bun run build:runtime exit 0
Typecheck bun run typecheck:tsc exit 0
Lint bun run lint exit 0
Tests bun test all pass
Prod build bun run build exit 0
Bundle report bun run analyze build + analyzer output

Scope

In scope:

  • src/components/editor/editor-topbar.tsx
  • src/lib/editor/video-export-encoder.ts
  • plans/README.md (status row)

Out of scope:

  • src/components/editor/editor-export-dialog.tsx internals — no refactor of the dialog itself.
  • src/lib/editor/export.ts — its import of the encoder module is fine once the muxers inside that module are dynamic.
  • editor-timeline-overlay.tsx and other large components — possible follow-ups, not this plan.
  • packages/shader-lab-react/**.

Git workflow

  • Branch: advisor/004-lazy-export-path
  • Commit style: perf: lazy-load export dialog and video muxers
  • App-only change: no changeset required.
  • Do NOT push or open a PR unless the operator instructed it.

Steps

Step 1: Baseline bundle measurement

Run bun run build and record the First Load JS for the editor route (the route under src/app/tools/shader-lab) from Next's build output table.

Verify: build exits 0; number recorded for the comparison in step 4.

Step 2: Dynamically import the muxers

In src/lib/editor/video-export-encoder.ts, remove the two static muxer imports and load them inside the encoder factory:

const [{ ArrayBufferTarget: Mp4ArrayBufferTarget, Muxer: Mp4Muxer }, { ArrayBufferTarget: WebMArrayBufferTarget, Muxer: WebMMuxer }] =
  await Promise.all([import("mp4-muxer"), import("webm-muxer")])

Place this inside createVideoExportEncoder (it is already async — confirm by reading the function). If type-only references to muxer classes remain at module scope, convert them to import type (type imports are erased and don't affect the bundle). If the module's structure makes per-format loading natural (only load the muxer for the chosen format), prefer that; otherwise Promise.all both — they're each small relative to the win of deferring.

Verify: bun run typecheck:tsc → exit 0. grep -n '^import {' src/lib/editor/video-export-encoder.ts shows no value imports from mp4-muxer/webm-muxer (only import type allowed).

Step 3: Lazy-load the export dialog

In src/components/editor/editor-topbar.tsx, replace the static import with:

import dynamic from "next/dynamic"

const EditorExportDialog = dynamic(
  () => import("./editor-export-dialog").then((mod) => mod.EditorExportDialog),
  { ssr: false }
)

Keep the JSX usage identical. To avoid even loading the chunk until first open, additionally guard the render site so the component only mounts after the dialog has been opened once (e.g. keep a hasOpenedExport flag alongside isExportDialogOpen) — only if the current JSX renders the dialog unconditionally; if it's already conditionally rendered on isExportDialogOpen, next/dynamic alone is sufficient.

Verify: bun run typecheck:tsc → exit 0; bun run lint → exit 0.

Step 4: Measure and confirm behavior

Run bun run build and compare the editor route's First Load JS against step 1 — expect a reduction (the dialog + export pipeline chunk should appear as a separate async chunk). Then bun dev: open the editor, click Export, confirm the dialog opens (brief delay on first open is OK), run a short video export (2s, low res) end-to-end for both MP4 and WebM if the UI offers both.

Verify: First Load JS decreased; export still produces a playable file for each format offered.

Test plan

No new unit tests (bundling behavior isn't unit-testable here). Regression protection = bun test (plan 001 suite) + the manual export check in step 4, recorded in the PR description with the before/after First Load JS numbers.

Done criteria

  • No static value imports of mp4-muxer/webm-muxer remain: grep -rn "from \"mp4-muxer\"\|from \"webm-muxer\"" src/ | grep -v "import type" → no matches
  • editor-topbar.tsx uses next/dynamic for EditorExportDialog
  • bun run build exits 0 and editor-route First Load JS is lower than the step-1 baseline (numbers in PR notes)
  • bun test, bun run typecheck:tsc, bun run lint all exit 0
  • No files outside the in-scope list modified (git status)
  • plans/README.md status row updated

STOP conditions

Stop and report back (do not improvise) if:

  • createVideoExportEncoder is not async and making it async ripples beyond video-export-encoder.ts and its single call site in export.ts:335.
  • The dialog uses module-scope side effects that break under dynamic import (e.g. registers something at import time that the topbar needs before open).
  • First Load JS does not decrease after both changes (the assumption that these libs sit in the main chunk would be wrong — report the analyzer output instead of adding more lazy boundaries).

Maintenance notes

  • Follow-up candidates (deliberately deferred): lazy-load editor-timeline-overlay.tsx (1682 lines) and the custom-shader editing UI; the TypeScript transpiler used by custom shaders is already behind import("typescript").
  • Reviewer should scrutinize: no muxer types leaked into the public surface as value imports; export error paths still behave (encoder creation now includes chunk loading, which can fail offline — the dialog's existing error handling should surface it).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions