Skip to content

perf(web): shrink main bundle to <1 MB and fix ineffective dynamic import (LUM-1939)#32211

Merged
ashleeradka merged 3 commits into
mainfrom
claude/compassionate-cori-8CAZS
May 27, 2026
Merged

perf(web): shrink main bundle to <1 MB and fix ineffective dynamic import (LUM-1939)#32211
ashleeradka merged 3 commits into
mainfrom
claude/compassionate-cori-8CAZS

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

Linear: https://linear.app/vellum/issue/LUM-1939

Summary

  • 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)React.lazy inside document-viewer-container.tsx; DocumentViewerPage route also switched to lazy.
  • lucide-react icon 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 off SF_SYMBOL_TO_LUCIDE / a curated suggestion-icon map; unmapped names fall back to Sparkles exactly as before.
  • EMOJI_CATALOG (~150 kB) — moved to emoji-catalog-data.ts and loaded on first : trigger via a new useEmojiSearch hook. The popup stays hidden during the brief load and starts returning matches as soon as the module resolves.
  • ShareFeedbackModal, VercelTokenDialog, CommandPalette, SubagentDetailPanel, WeatherForecastDisplayReact.lazy + Suspense at their conditional render sites (the trigger that opens them is always the user clicking something or a runtime condition firing).
  • modelSupportsVision in chat-route-content was importing the full LLM model catalog only to return a fail-open true; inlined the constant. Settings pages still use the catalog via their own (already lazy) routes.

Test plan

  • bun run build — main index-*.js is 998 kB (< 1 MB target); no INEFFECTIVE_DYNAMIC_IMPORT warning
  • bunx tsc --noEmit
  • bun run lint
  • bun test for emoji-catalog.test.ts, use-text-popup.test.ts — all pass (tests now import the data file directly)
  • Manual: open a document surface → tiptap editor loads, comments still work
  • Manual: open Cmd+K command palette → loads and renders
  • Manual: open subagent detail panel (chat + mobile overlay) → loads and renders
  • Manual: type :smile in composer → emoji picker shows after first load
  • Manual: render a list/table surface with SF Symbol icons → icons appear unchanged
  • Manual: home page with suggested prompts → icons appear unchanged

https://claude.ai/code/session_01NjQuUAiXsS4DVEmKPsb1uE


Generated by Claude Code

…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.
@linear
Copy link
Copy Markdown

linear Bot commented May 27, 2026

LUM-1939

@ashleeradka ashleeradka marked this pull request as ready for review May 27, 2026 02:06
vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 27, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate — see latest review.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 27, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate — see latest review.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 27, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 load
  • SubagentDetailPanel (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.ts now exports sfSymbolToLucideIcon() (returns component directly, not name + lookup)
  • home-suggestion-pill-bar.tsx gets a curated 46-icon explicit import set with Sparkles fallback

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.

@ashleeradka
Copy link
Copy Markdown
Contributor Author

@devin review

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread apps/web/src/routes.tsx
Comment thread apps/web/src/domains/chat/chat-page.tsx
Comment thread apps/web/src/domains/home/home-suggestion-pill-bar.tsx
@vex-assistant-bot vex-assistant-bot Bot dismissed stale reviews from themself May 27, 2026 02:12

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.
@ashleeradka
Copy link
Copy Markdown
Contributor Author

@devin review

Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lazychat-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 upgradeRecord<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 inlineactiveProfileModel?.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 syntaxlazy: { Component: () => import(...).then(m => m.DocumentViewerPage) } — this is RR7 per-property lazy loading. TypeScript passes, build passes. ✅

Minor observations

  • MobileSubagentDetailOverlay's LazyBoundary uses fallback={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_NAME map is currently sized to cover the daemon's bounded hardcoded catalog. Any future daemon addition of a new icon name will silently fall back to Sparkles until 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.

@ashleeradka ashleeradka merged commit 2e10faf into main May 27, 2026
7 checks passed
@ashleeradka ashleeradka deleted the claude/compassionate-cori-8CAZS branch May 27, 2026 03:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants