Skip to content

Shrink the standalone bundle: real minification, externalize the worker, lazy editor stack, schema dedupe #1437

Description

@dqnykamp

Problem

@doenet/standalone ships a single 28.9 MB doenet-standalone.js (plus 3.3 MB CSS). Hosts that embed many activities in iframes (PreTeXt books, doenetml-iframe/assignment-viewer pages) re-parse it once per iframe; measured cost is ~184 MB per embedded viewer, and pages with many activities reach multiple GB (#874).

Bundle composition findings:

  • ~11 MB is the entire worker bundle embedded as a raw string (?raw import in packages/doenetml/src/doenetml-inline-worker.ts:8-11, turned into a blob URL at module-import time). The string is retained in every realm's heap forever, and ~4.9 MB of it is base64-encoded WASM.
  • The editor stack (@doenet/codemirror 5.7 MB built — including the 2.3 MB LSP worker string — prettier via the pretty-printer, diagnostics UI, context help) is statically reachable from the entry through the DoenetEditor export, so pure-viewer hosts pay for it. Note: a viewer-only entry point alone does not help, because the <codeEditor> component's renderer imports EditorViewer directly (Viewer/renderers/codeEditor.tsx:8); the fix must be a lazy boundary. Evidence that the boundary already almost exists: the code-split codeEditor renderer chunk is only 4.8 KB — the editor weight got hoisted into the main chunk by the static export.
  • The component schema is bundled 5 times and each copy is eagerly decompressed at module scope (~2.5 MB heap each): @doenet/codemirror and others bundle @doenet/static-assets into their dists instead of externalizing it (as @doenet/lsp-tools already does).
  • Vite lib-mode ES builds skip whitespace minification: running full esbuild minify over the current bundle yields 27.6 → 22.8 MB.

Proposed changes (separable steps)

  • Real minification for the standalone build (or build it app-mode instead of lib-mode). Measured −4.8 MB for free.
  • Externalize the worker: fetch the worker file (published alongside the bundle on the CDN) with fetch(...).then(r => r.blob()) → blob URL, lazily on first viewer mount, instead of embedding the source string. −11 MB from the main file, −11 MB retained heap per realm, no double parse. Keep the inlined variant for hosts that need a truly single file (VS Code).
  • Editor-stack lazy boundary: React.lazy-import EditorViewer in both the codeEditor renderer and DoenetEditor/renderDoenetEditorToContainer, and re-enable code splitting for standalone (drop inlineDynamicImports; module chunks resolve relatively from jsDelivr). The editor chunk then loads only when a document contains <codeEditor> or the host calls the editor API.
  • Schema dedupe: externalize @doenet/static-assets in the codemirror build; decompress lazily. (Largely subsumed by the editor split for viewer hosts, still worth it for editor hosts.)
  • Coordinate the mathjs shrink (tree-shake + externalize in math-expressions PRs #69/#70, blocked on Investigate removing numeric dependency: migrate eigs/lusolve to native mathjs #1415) — mathjs with embedded docs currently appears in both the worker bundle and the main-thread bundle.

Expected impact

For viewer-only documents (the common embedding case) the eagerly-parsed payload drops from ~29 MB to roughly 10–12 MB, plus ~15–20 MB less retained heap per iframe realm. Combines with lazy iframes/coordination (#874, PR #880) and the per-worker costs tracked in #1428#1432.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    Fields

    Priority

    Medium

    Effort

    Medium

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions