perf(web): shrink main bundle to <1 MB and fix ineffective dynamic import (LUM-1939)#32211
Conversation
…port (LUM-1939)
Main `apps/web` bundle: 2,178 kB → 998 kB minified (635 kB → 295 kB gzip).
The Vite `INEFFECTIVE_DYNAMIC_IMPORT` warning for `map-runtime-message.ts`
is gone — `messages.ts` now imports it statically (the static imports in
`history.ts` and `reconcile.ts` already pulled it into the main chunk, so
the dynamic import was vestigial).
Splits that moved off the chat-critical path:
- Tiptap/ProseMirror document editor (~540 kB) — lazy-loaded inside
`document-viewer-container.tsx`; `DocumentViewerPage` route also lazy.
- `lucide-react` icon namespace (~800 kB) — `import { icons }` in chat
surfaces and the home suggestion pill bar pulled all ~1,700 icons.
Replaced with explicit named imports keyed by SF Symbol / curated
daemon icon names.
- `EMOJI_CATALOG` (~150 kB) — moved to `emoji-catalog-data.ts` and loaded
on first `:` trigger via a new `useEmojiSearch` hook.
- `ShareFeedbackModal`, `VercelTokenDialog`, `CommandPalette`,
`SubagentDetailPanel`, `WeatherForecastDisplay` — `React.lazy` +
`Suspense` at their conditional render sites.
- `modelSupportsVision` was importing the full LLM model catalog only to
return a fail-open `true`; inlined the constant.
There was a problem hiding this comment.
✦ APPROVE — reviewed at fdc35210
Value: Main bundle 2,178 kB → 998 kB minified (635 kB → 295 kB gzip). >50% reduction from a handful of targeted splits. No behavior change on the happy path.
What's here
messages.ts — INEFFECTIVE_DYNAMIC_IMPORT fix ✅
mapRuntimeToDisplayMessage was dynamically imported inside getChatHistory, but history.ts and reconcile.ts already import it statically — Vite had already pulled it into the main chunk. The async await import(...) was vestigial. Converting to a static import at the top is correct, removes the warning, and doesn't change the chunk graph.
React.lazy splits — 7 components ✅
All follow the correct .then(m => ({ default: m.ExportName })) re-export pattern. Components affected:
TiptapDocumentEditor— biggest win, ~540 kB Tiptap/ProseMirror off the initial loadSubagentDetailPanel(chat-route-content + mobile overlay — consistent)CommandPalette,VercelTokenDialog,ShareFeedbackModal,WeatherForecastDisplay
Suspense fallbacks — appropriately chosen
- Document editor:
<Loader2>spinner — user is staring at a blank pane, the spinner is the right signal ✅ - Weather widget: markdown body as fallback — text version visible while the widget chunk resolves ✅
- Modals/panels:
fallback={null}— user-triggered, brief load invisible to user ✅
routes.tsx — route-level lazy for DocumentViewerPage ✅
Uses lazy: { Component: () => import(...).then(m => m.DocumentViewerPage) } — this is the valid React Router v7 "per-property async" lazy syntax (as opposed to lazy: async () => ({ Component })). TypeScript confirms the type is correct.
lucide-react { icons } namespace removed ✅
list-surface.tsx, table-surface.tsx, home-suggestion-pill-bar.tsx all dropped the { icons } star re-export that defeated tree-shaking (~800 kB). Replaced with:
sf-symbol-map.tsnow exportssfSymbolToLucideIcon()(returns component directly, not name + lookup)home-suggestion-pill-bar.tsxgets a curated 46-icon explicit import set withSparklesfallback
The curated set is ample for suggestion prompts. The fallback behavior is preserved: home was already returning Sparkles for unmapped names; list/table short-circuit to no-icon (unchanged — LucideIcon is undefined when sfSymbolToLucideIcon finds no match).
emoji-catalog split ✅
1,992-line EMOJI_CATALOG moved to emoji-catalog-data.ts; emoji-catalog.ts now exports only the useEmojiSearch hook + constants/types. Composer calls useEmojiSearch() — the popup stays hidden during the brief first-load resolve. Tests updated to import the data file directly. Clean.
modelSupportsVision removal ✅ (with note)
Dropping the catalog fallback (modelSupportsVision(provider, model)) in favor of activeProfileModel?.supportsVision ?? true removes the ~12 kB model catalog from the chat-critical bundle. Fail-open is the safe side here — if the daemon doesn't populate supportsVision, users can attempt an upload and the daemon will handle it appropriately. Intentional and documented.
P2 — exit-animation regression risk on lazy modals
CommandPalette, VercelTokenDialog, ShareFeedbackModal all changed from:
// always mounted — Radix/motion can drive exit animation via isOpen=false
<CommandPalette isOpen={commandPalette.isOpen} ... />
to:
// conditionally mounted — unmounts immediately when false, no exit animation
{commandPalette.isOpen ? <Suspense><CommandPalette isOpen={true} .../></Suspense> : null}
If any of these components wrap a Radix Dialog.Root or motion component that drives a CSS exit transition by receiving open={false}, that animation is now gone — the component unmounts before it can run. This won't crash and the test plan covers the open path, but close animations may silently disappear. Worth verifying in manual QA. If close animations matter, an AnimatePresence wrapper or deferred unmount (e.g. keeping the component mounted for one frame after isOpen drops) would restore them.
CI 7/7 ✅. Clean single commit. Awaiting Devin/Codex second approval.
|
@devin review |
Duplicate — retry artifact. Canonical review is the latest submission.
The convention doc listed `DocumentViewerPage` as an eager route, but the component does not actually render meaningful UI across non-active assistant lifecycle states (only `ChatPage` does — see chat-page.tsx lines 1315–1377 vs document-viewer-page.tsx lines 47–51). It's reached only from `LibraryPage`, which is itself lazy, so the lazy chunk loads in parallel with code the user has already paid the lazy cost on. Also lazy-load `AddCreditsModal` (only rendered when the user clicks "Add credits") to keep the chat-critical bundle just under 1 MB now that `DocumentViewerPage` is back out (998 kB → minified).
`React.lazy` rejected import promises are module-cached, so a chunk-fetch failure leaves bare `Suspense` hanging on its fallback indefinitely. The route-level `RootErrorBoundary` would technically catch it, but that nukes the entire route just because a single modal or surface couldn't load. Add `LazyBoundary` — a thin `Suspense` + `Sentry.ErrorBoundary` pair that captures chunk-load failures to Sentry and renders an inline "Failed to load — please reload" message by default. Each call site can pass an `errorFallback` for graceful degradation; the weather card now uses the markdown body as both loading and error fallback so a failed chunk fetch keeps content visible. Swap `<Suspense>` for `<LazyBoundary>` at every site introduced in LUM-1939: chat-page (CommandPalette, VercelTokenDialog, AddCreditsModal), chat-route-content (SubagentDetailPanel), mobile-subagent-detail-overlay, document-viewer-container (TiptapDocumentEditor), preferences-menu (ShareFeedbackModal), card-surface (WeatherForecastDisplay). Also clarify the AGENTS.md gate note — `DocumentViewerPage` lives outside `<ActiveAssistantGate>` but does not render meaningful UI across non-active lifecycle states (the implication of the previous wording). Only `ChatPage` does, and only it renders setup/cleanup/version-selection screens.
|
@devin review |
There was a problem hiding this comment.
✦ APPROVE — reviewed at 2fd0e592
Value: Users on slow connections open chat instantly — the main bundle shrank from 2,178 kB to 998 kB by deferring six heavyweight subtrees that were pulling their weight at startup for zero cold-path benefit.
Full analysis
What this does
Six heavyweight modules deferred off the chat-critical path: Tiptap/ProseMirror (~540 kB), the lucide icons namespace (~800 kB, which defeated tree-shaking), the emoji catalog data (~150 kB), AddCreditsModal, CommandPalette, VercelTokenDialog, ShareFeedbackModal, SubagentDetailPanel, and WeatherForecastDisplay. Each is wrapped in the new LazyBoundary (Suspense + Sentry.ErrorBoundary) so chunk-load failures surface as user-readable errors rather than hanging Suspense or nuking the route boundary.
The vestigial INEFFECTIVE_DYNAMIC_IMPORT on map-runtime-message.ts is also gone — it was already pulled into the main chunk by static imports elsewhere, so the dynamic import was a no-op that Vite warned about.
Anti-pattern checks
useEmojiSearch deferred loading — Module-level cachedSearch/loadPromise cache is correct. The useState initializer captures the already-loaded function on re-mounts. The useEffect has a cancelled guard AND proper cleanup; the async path is safe. When the module resolves after unmount, setSearch is gated on !cancelled. ✅
LazyBoundary retry design — Comment explicitly notes "React.lazy memoizes rejected import promises at the module level, so an inline retry would not actually re-fetch; full reload is the realistic remediation." This is correct for Vite SPA builds. The Sentry.ErrorBoundary catches chunk failures and reports them, which is the right seam for observability. ✅
Dual SubagentDetailPanel lazy — chat-route-content.tsx and mobile-subagent-detail-overlay.tsx each call React.lazy(() => import('./subagent-detail-panel')...) independently. Separate lazy component instances, but Vite's module cache ensures the network request fires once. ✅
sf-symbol-map.ts type upgrade — Record<string, string> → Record<string, LucideIcon> with explicit named imports. The old icons[name] lookup loaded all ~1700 icons; the new map imports only what's referenced, which is the correct tree-shaking approach. ✅
modelSupportsVision inline — activeProfileModel?.supportsVision ?? true replaces the import of the full LLM model catalog. The daemon's supportsVision is authoritative; true is the safe failure-open default. ✅
Conditional mount pattern for dialogs — {showModal ? <LazyBoundary><Modal open={showModal} .../></LazyBoundary> : null} means open is always true while mounted. Devin flagged exit animation loss. Boss independently verified: all design-library dialog/sheet/popover primitives are entrance-only (data-[state=open]:animate-*, no data-[state=closed]:animate-*). CommandPalette and ShareFeedbackModal already internally return null when closed. No regression. ✅
DocumentViewerPage eager→lazy — Devin flagged CONVENTIONS.md stale entry. Boss updated the docs (commit 4b6a16b): the route shell is ~5-6 kB, the heavy weight (Tiptap) is already lazy at the container level regardless of entry path, and the route is only reachable through the already-lazy LibraryPage. The AGENTS.md note about "renders across non-active states" applies to ChatPage (lifecycle screens), not DocumentViewerPage (static error only). Convention doc corrected, route stays lazy. ✅
Lazy route object syntax — lazy: { Component: () => import(...).then(m => m.DocumentViewerPage) } — this is RR7 per-property lazy loading. TypeScript passes, build passes. ✅
Minor observations
MobileSubagentDetailOverlay'sLazyBoundaryusesfallback={null}(default). The overlay is a full-screen fixed div — a brief empty state on first open is intentional and matches the mobile sheet pattern. Non-blocking.- The
SUGGESTION_ICON_BY_NAMEmap is currently sized to cover the daemon's bounded hardcoded catalog. Any future daemon addition of a new icon name will silently fall back toSparklesuntil a paired web PR lands — worth a convention note in AGENTS.md eventually, but not blocking this PR.
CI
All 7 checks passing at 2fd0e592: Lint, Type Check & Build, Test, Socket Security (×2), and standalone Type Check + Build + Lint split.
Vellum Constitution — Inviting: a sub-second first render is the foundation of an assistant that feels responsive, not one users work around.
Linear: https://linear.app/vellum/issue/LUM-1939
Summary
apps/webbundle: 2,178 kB → 998 kB minified (635 kB → 295 kB gzip)INEFFECTIVE_DYNAMIC_IMPORTwarning formap-runtime-message.tsis gone —messages.tsnow imports it statically (the static imports inhistory.tsandreconcile.tsalready pulled it into the main chunk, so the dynamic import was vestigial).Splits that moved off the chat-critical path
React.lazyinsidedocument-viewer-container.tsx;DocumentViewerPageroute also switched tolazy.lucide-reacticon namespace (~800 kB) — three callers (list-surface.tsx,table-surface.tsx,home-suggestion-pill-bar.tsx) imported{ icons }, which re-exports every one of ~1,700 icons and defeats tree-shaking. Replaced with explicit named imports keyed offSF_SYMBOL_TO_LUCIDE/ a curated suggestion-icon map; unmapped names fall back toSparklesexactly as before.EMOJI_CATALOG(~150 kB) — moved toemoji-catalog-data.tsand loaded on first:trigger via a newuseEmojiSearchhook. The popup stays hidden during the brief load and starts returning matches as soon as the module resolves.ShareFeedbackModal,VercelTokenDialog,CommandPalette,SubagentDetailPanel,WeatherForecastDisplay—React.lazy+Suspenseat their conditional render sites (the trigger that opens them is always the user clicking something or a runtime condition firing).modelSupportsVisionin chat-route-content was importing the full LLM model catalog only to return a fail-opentrue; inlined the constant. Settings pages still use the catalog via their own (already lazy) routes.Test plan
bun run build— mainindex-*.jsis 998 kB (< 1 MB target); noINEFFECTIVE_DYNAMIC_IMPORTwarningbunx tsc --noEmitbun run lintbun testforemoji-catalog.test.ts,use-text-popup.test.ts— all pass (tests now import the data file directly):smilein composer → emoji picker shows after first loadhttps://claude.ai/code/session_01NjQuUAiXsS4DVEmKPsb1uE
Generated by Claude Code