FRD: Lazy-Load PodTerminal Runtime Dependencies
| Field | Value |
|---|---|
| ID | FRD-049 |
| Owner | David Holmes |
| Status | Draft |
| Last Updated | 2026-05-26 |
| Target Release | v2.1.0 |
| Type | Refactor |
| Complexity | M |
Document Summary
The PodTerminal component tree eagerly imports @xterm/xterm (~200 kB), @xterm/addon-fit (~15 kB), and react-virtuoso (~143 kB), contributing approximately 358 kB to the initial bundle. This FRD defines the work to lazy-load these dependencies via dynamic import so the PodTerminal emits a separate chunk loaded only when the component is rendered.
Introduction
Overview
Storybook build analysis and application bundle analysis show a ~358 kB chunk containing xterm and react-virtuoso that is eagerly loaded even when the PodTerminal is not rendered. For applications and Storybook stories that do not use the terminal, this is wasted bandwidth and parse time. Lazy-loading these dependencies behind a dynamic import() boundary eliminates this cost for non-terminal consumers.
Goals
- Move
@xterm/xterm,@xterm/addon-fit, andreact-virtuosobehind dynamicimport(). - PodTerminal and related components emit a separate chunk.
- Non-terminal Storybook stories and application routes do not load the terminal chunk.
- Terminal functionality remains identical; no user-visible behavior change.
Non-Goals
- Replacing xterm or react-virtuoso with alternative libraries.
- Reducing the size of the terminal chunk itself (tree-shaking or library replacement).
- Changing PodTerminal’s API or props.
Scope
In Scope
| Item | Description |
|---|---|
pod-terminal-live-session.tsx | Convert static imports of xterm and addon-fit to dynamic imports. |
pod-terminal-virtualized-transcript.tsx | Convert static import of react-virtuoso to dynamic import. |
| Lazy wrapper component | Create a React.lazy() wrapper that loads the terminal tree on demand. |
| Loading fallback | Show a shape-matched skeleton while the terminal chunk loads. |
| Storybook stories | Terminal stories lazy-load the real component. |
Out of Scope
| Item | Reason |
|---|---|
| xterm version upgrade | Separate concern; no version change in this work. |
| Terminal feature changes | Pure bundling refactor. |
| Other heavy dependencies | Recharts lazy-loading is covered by FRD-050. |
Users and Pain Points
| User | Pain Point |
|---|---|
| Application user (non-terminal page) | Loads ~358 kB of JavaScript they never use, increasing page load time. |
| Storybook user (non-terminal stories) | Storybook initial load includes the terminal chunk, slowing navigation to first story. |
| Developer | Large eager chunk increases build time and makes bundle analysis harder. |
Definitions
| Term | Definition |
|---|---|
| Dynamic import | A JavaScript import() expression that loads a module asynchronously at runtime, enabling code splitting. |
| Code splitting | A bundler technique that separates code into multiple chunks loaded on demand rather than all at once. |
| React.lazy | A React API that wraps a dynamically-imported component, enabling it to be rendered with a <Suspense> fallback. |
| Shape-matched skeleton | A loading placeholder that approximates the dimensions and layout of the real component. |
Current State
The PodTerminal component tree consists of:
pod-terminal-live-session.tsx(182 lines) — imports@xterm/xtermand@xterm/addon-fitat the top level.pod-terminal-virtualized-transcript.tsx— importsreact-virtuosoat the top level.
Both are statically imported, meaning the bundler includes them in the main chunk (or a shared chunk that loads eagerly). Bundle analysis shows:
@xterm/xterm: ~200 kB (minified)@xterm/addon-fit: ~15 kB (minified)react-virtuoso: ~143 kB (minified)- Total: ~358 kB loaded regardless of whether the terminal is used.
Proposed Solution
Dynamic imports in component files
Replace static imports with dynamic imports inside the component initialization:
// Beforeimport { Terminal } from '@xterm/xterm';import { FitAddon } from '@xterm/addon-fit';
// Afterconst { Terminal } = await import('@xterm/xterm');const { FitAddon } = await import('@xterm/addon-fit');For react-virtuoso, use the same pattern in the transcript component.
React.lazy wrapper
Create src/components/ui/pod-terminal-lazy.tsx:
import { lazy, Suspense } from 'react';import { PodTerminalSkeleton } from './pod-terminal-skeleton';
const PodTerminalLiveSession = lazy(() => import('./pod-terminal-live-session'));
export function PodTerminal(props: PodTerminalProps) { return ( <Suspense fallback={<PodTerminalSkeleton />}> <PodTerminalLiveSession {...props} /> </Suspense> );}Skeleton component
Create pod-terminal-skeleton.tsx that renders a dark rectangle matching the terminal’s default dimensions, with a pulsing cursor placeholder. This avoids layout shift during lazy load.
Story updates
Terminal stories import from pod-terminal-lazy.tsx rather than directly from the live session component.
Requirements
| ID | Priority | Requirement |
|---|---|---|
| LAZY-T-01 | P0 | @xterm/xterm and @xterm/addon-fit are loaded via dynamic import only when PodTerminal renders. |
| LAZY-T-02 | P0 | react-virtuoso is loaded via dynamic import only when the virtualized transcript renders. |
| LAZY-T-03 | P0 | Non-terminal pages and stories do not load the terminal chunk. |
| LAZY-T-04 | P0 | Terminal functionality is unchanged after lazy loading. |
| LAZY-T-05 | P1 | A shape-matched skeleton displays during chunk loading. |
| LAZY-T-06 | P1 | The terminal chunk is less than 400 kB (headroom for future growth). |
Functional Requirements
PodTerminal(the lazy wrapper) renders a skeleton immediately, then swaps to the real terminal once the chunk loads.- Props passed to
PodTerminalare forwarded toPodTerminalLiveSessionwithout loss. - The terminal connects and displays output correctly after lazy load completes.
- If the chunk fails to load (network error), the
Suspenseboundary surfaces an error. An error boundary wrapping the terminal should catch this and display a retry UI. - Multiple PodTerminal instances on the same page share the same chunk (no duplicate loading).
Non-Functional Requirements
| Category | Requirement |
|---|---|
| Bundle savings | The main chunk size decreases by at least 300 kB after this change. |
| Load latency | The terminal chunk loads in under 500ms on a typical broadband connection (assumes chunk served from CDN). |
| No layout shift | The skeleton matches the terminal’s dimensions to avoid CLS. |
| Backward compatibility | The lazy wrapper exports the same props interface as the original component. |
API/Interface Requirements
The public API does not change. The lazy wrapper component accepts the same props as PodTerminalLiveSession. If the terminal is exported from the barrel, the barrel export points to the lazy wrapper, not the raw component.
Accessibility Requirements
- The skeleton should have
role="progressbar"andaria-label="Loading terminal"so screen readers announce the loading state. - The error state (chunk load failure) should be announced to screen readers.
- Once loaded, the terminal’s existing accessibility behavior is unchanged.
Content and Documentation Requirements
- Update any documentation referencing PodTerminal imports to use the lazy wrapper.
- Add a developer note in the component’s TSDoc explaining the lazy-load boundary.
Dependencies
| Dependency | Type | Notes |
|---|---|---|
@xterm/xterm | Existing (npm) | Moved behind dynamic import. |
@xterm/addon-fit | Existing (npm) | Moved behind dynamic import. |
react-virtuoso | Existing (npm) | Moved behind dynamic import. |
pod-terminal-live-session.tsx | Internal | Source component wrapped by lazy loader. |
pod-terminal-virtualized-transcript.tsx | Internal | Source component with react-virtuoso. |
Risks and Tradeoffs
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Dynamic import adds a visible loading delay before the terminal appears. | Medium | Medium | Skeleton provides visual continuity. Preload hint (<link rel="modulepreload">) can be added for pages that always show a terminal. |
| Network failure prevents chunk loading. | Low | Medium | Error boundary with retry button. |
| Dynamic import breaks SSR if terminal is server-rendered. | Low | Low | Terminal is client-only; no SSR concern. React.lazy is client-only by design. |
| Bundle analyzer output changes, making before/after comparison harder. | Low | Low | Record chunk sizes before and after in the PR description. |
Open Questions
- Should the lazy wrapper preload the chunk on hover/focus of a navigation element that leads to the terminal page? Leaning toward yes for app pages, not needed for Storybook.
- Should
react-virtuosobe lazy-loaded only within the terminal, or should a shared lazy boundary cover all virtuoso usage? Leaning toward terminal-only for now; other uses can add their own boundaries. - Should the skeleton include a blinking cursor animation, or is a static dark rectangle sufficient?
Acceptance Criteria
-
@xterm/xterm,@xterm/addon-fit, andreact-virtuosoare not in the main/shared chunk. - A separate terminal chunk exists in the build output containing these dependencies.
- Non-terminal Storybook stories load without fetching the terminal chunk (verified via network tab).
- Terminal renders correctly after lazy loading.
- Skeleton displays during chunk load.
- Main chunk size decreases by at least 300 kB.
-
pnpm typecheckpasses. -
pnpm vitest run --project unitpasses. -
pnpm build-storybookcompletes without errors.
LLM Handoff Instructions
When implementing this FRD:
- Read
src/components/ui/pod-terminal-live-session.tsxand identify all static imports from@xterm/xtermand@xterm/addon-fit. - Read
src/components/ui/pod-terminal-virtualized-transcript.tsxand identify the static import fromreact-virtuoso. - In
pod-terminal-live-session.tsx, move the xterm imports inside anasyncinitialization function or use top-levelawait import()in the component’s setup logic. The component must handle the async nature (e.g., initialize the terminal in auseEffectthat awaits the imports). - Similarly, convert the react-virtuoso import in the transcript component.
- Create
src/components/ui/pod-terminal-lazy.tsxwith aReact.lazy()wrapper andSuspenseboundary. - Create
src/components/ui/pod-terminal-skeleton.tsx— a simple dark-background div matching the terminal’s default size, withrole="progressbar"andaria-label. - Update terminal stories to import from the lazy wrapper.
- Run
pnpm build-storybookand verify the terminal chunk is separate (checkdist/for chunk names). - Run
pnpm typecheckandpnpm vitest run --project unit.
Decision Log
| Date | Decision | Rationale |
|---|---|---|
| 2026-05-26 | Use React.lazy and Suspense for the lazy boundary. | Standard React pattern for component-level code splitting. Works with all bundlers. |
| 2026-05-26 | Shape-matched skeleton over spinner. | Avoids layout shift and provides better perceived performance. |
| 2026-05-26 | Lazy-load at the terminal component level, not the page level. | More granular; benefits any page that conditionally renders a terminal. |
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-05-26 | David Holmes | Initial draft. |