Skip to content

FRD: Lazy-Load PodTerminal Runtime Dependencies

FieldValue
IDFRD-049
OwnerDavid Holmes
StatusDraft
Last Updated2026-05-26
Target Releasev2.1.0
TypeRefactor
ComplexityM

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, and react-virtuoso behind dynamic import().
  • 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

ItemDescription
pod-terminal-live-session.tsxConvert static imports of xterm and addon-fit to dynamic imports.
pod-terminal-virtualized-transcript.tsxConvert static import of react-virtuoso to dynamic import.
Lazy wrapper componentCreate a React.lazy() wrapper that loads the terminal tree on demand.
Loading fallbackShow a shape-matched skeleton while the terminal chunk loads.
Storybook storiesTerminal stories lazy-load the real component.

Out of Scope

ItemReason
xterm version upgradeSeparate concern; no version change in this work.
Terminal feature changesPure bundling refactor.
Other heavy dependenciesRecharts lazy-loading is covered by FRD-050.

Users and Pain Points

UserPain 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.
DeveloperLarge eager chunk increases build time and makes bundle analysis harder.

Definitions

TermDefinition
Dynamic importA JavaScript import() expression that loads a module asynchronously at runtime, enabling code splitting.
Code splittingA bundler technique that separates code into multiple chunks loaded on demand rather than all at once.
React.lazyA React API that wraps a dynamically-imported component, enabling it to be rendered with a <Suspense> fallback.
Shape-matched skeletonA 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/xterm and @xterm/addon-fit at the top level.
  • pod-terminal-virtualized-transcript.tsx — imports react-virtuoso at 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:

// Before
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
// After
const { 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

IDPriorityRequirement
LAZY-T-01P0@xterm/xterm and @xterm/addon-fit are loaded via dynamic import only when PodTerminal renders.
LAZY-T-02P0react-virtuoso is loaded via dynamic import only when the virtualized transcript renders.
LAZY-T-03P0Non-terminal pages and stories do not load the terminal chunk.
LAZY-T-04P0Terminal functionality is unchanged after lazy loading.
LAZY-T-05P1A shape-matched skeleton displays during chunk loading.
LAZY-T-06P1The terminal chunk is less than 400 kB (headroom for future growth).

Functional Requirements

  1. PodTerminal (the lazy wrapper) renders a skeleton immediately, then swaps to the real terminal once the chunk loads.
  2. Props passed to PodTerminal are forwarded to PodTerminalLiveSession without loss.
  3. The terminal connects and displays output correctly after lazy load completes.
  4. If the chunk fails to load (network error), the Suspense boundary surfaces an error. An error boundary wrapping the terminal should catch this and display a retry UI.
  5. Multiple PodTerminal instances on the same page share the same chunk (no duplicate loading).

Non-Functional Requirements

CategoryRequirement
Bundle savingsThe main chunk size decreases by at least 300 kB after this change.
Load latencyThe terminal chunk loads in under 500ms on a typical broadband connection (assumes chunk served from CDN).
No layout shiftThe skeleton matches the terminal’s dimensions to avoid CLS.
Backward compatibilityThe 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" and aria-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

DependencyTypeNotes
@xterm/xtermExisting (npm)Moved behind dynamic import.
@xterm/addon-fitExisting (npm)Moved behind dynamic import.
react-virtuosoExisting (npm)Moved behind dynamic import.
pod-terminal-live-session.tsxInternalSource component wrapped by lazy loader.
pod-terminal-virtualized-transcript.tsxInternalSource component with react-virtuoso.

Risks and Tradeoffs

RiskLikelihoodImpactMitigation
Dynamic import adds a visible loading delay before the terminal appears.MediumMediumSkeleton provides visual continuity. Preload hint (&lt;link rel="modulepreload"&gt;) can be added for pages that always show a terminal.
Network failure prevents chunk loading.LowMediumError boundary with retry button.
Dynamic import breaks SSR if terminal is server-rendered.LowLowTerminal is client-only; no SSR concern. React.lazy is client-only by design.
Bundle analyzer output changes, making before/after comparison harder.LowLowRecord chunk sizes before and after in the PR description.

Open Questions

  1. 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.
  2. Should react-virtuoso be 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.
  3. Should the skeleton include a blinking cursor animation, or is a static dark rectangle sufficient?

Acceptance Criteria

  • @xterm/xterm, @xterm/addon-fit, and react-virtuoso are 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 typecheck passes.
  • pnpm vitest run --project unit passes.
  • pnpm build-storybook completes without errors.

LLM Handoff Instructions

When implementing this FRD:

  1. Read src/components/ui/pod-terminal-live-session.tsx and identify all static imports from @xterm/xterm and @xterm/addon-fit.
  2. Read src/components/ui/pod-terminal-virtualized-transcript.tsx and identify the static import from react-virtuoso.
  3. In pod-terminal-live-session.tsx, move the xterm imports inside an async initialization function or use top-level await import() in the component’s setup logic. The component must handle the async nature (e.g., initialize the terminal in a useEffect that awaits the imports).
  4. Similarly, convert the react-virtuoso import in the transcript component.
  5. Create src/components/ui/pod-terminal-lazy.tsx with a React.lazy() wrapper and Suspense boundary.
  6. Create src/components/ui/pod-terminal-skeleton.tsx — a simple dark-background div matching the terminal’s default size, with role="progressbar" and aria-label.
  7. Update terminal stories to import from the lazy wrapper.
  8. Run pnpm build-storybook and verify the terminal chunk is separate (check dist/ for chunk names).
  9. Run pnpm typecheck and pnpm vitest run --project unit.

Decision Log

DateDecisionRationale
2026-05-26Use React.lazy and Suspense for the lazy boundary.Standard React pattern for component-level code splitting. Works with all bundlers.
2026-05-26Shape-matched skeleton over spinner.Avoids layout shift and provides better perceived performance.
2026-05-26Lazy-load at the terminal component level, not the page level.More granular; benefits any page that conditionally renders a terminal.

Document History

VersionDateAuthorChanges
0.12026-05-26David HolmesInitial draft.