Skip to content

ADR-23: Window-Level Virtualization Pattern

🇰🇷 한국어 버전

DateAuthorRepos
2026-01-19@specvitalweb

Context

The spec-view mode experienced severe performance degradation with 1000+ behaviors:

IssueDescription
Computational BottleneckcomputeFilteredDocument() exhibited O(n^3) complexity, recalculating on every render cycle
Insufficient Virtualization ScopeOnly behavior-level virtualization at 100+ threshold; no Domain/Feature level virtualization
Nested Structure ChallengeDeep 4-level hierarchy (Document -> Domain -> Feature -> Behavior) complicated traditional virtualization

Previous Approach Failures

Container-based virtualization using useVirtualizer created critical UX issues:

  • Inconsistent Scroll Experience: Separate scroll containers for each virtualized section broke the unified page scroll pattern
  • CSS Layout Breakdown: position: absolute layout (required for container virtualization) disrupted normal CSS spacing, card structures, and visual grouping

Constraints

ConstraintDescription
React CompilerMust remain compatible with React Compiler; no patterns that break automatic memoization
UX ConsistencySingle page scroll; no nested scroll containers
Visual GroupingDomain/Feature card structure must be preserved
Threshold SensitivityMust handle both small (< 30 items) and large (1000+) datasets

Decision

Adopt window-level virtualization using TanStack Virtual's useWindowVirtualizer with flat array conversion and deferred rendering.

Core Implementation

  1. Flat Array Conversion: flattenSpecDocument() transforms nested hierarchy into a single array with type discriminators
  2. Window-Level Virtualization: useWindowVirtualizer uses the browser window as scroll container, eliminating nested scroll issues
  3. Deferred Rendering: React 18's useDeferredValue prevents UI blocking during filter/search operations
  4. Lowered Threshold: Reduced from 100 to 30 items for earlier optimization activation
  5. CSS-Based Grouping: isLastInDomain flag enables visual separation without breaking layout

Library Selection

@tanstack/react-virtual 3.13.6

Selected over alternatives because:

  • Native useWindowVirtualizer hook for page-level scrolling
  • Already in dependency tree (used in other components)
  • React 19 compatible (uses useSyncExternalStore)
  • Known React Compiler compatibility issue documented and mitigated via "use no memo" (ADR-22)

Options Considered

Option A: Window-Level Virtualization with Flat Array (Selected)

flattenSpecDocument() converts Document -> Domain[] -> Feature[] -> Behavior[] into FlatItem[]. Each FlatItem carries type discriminator (domain-header, feature-header, behavior-row). useWindowVirtualizer virtualizes the flat array using window scroll position.

typescript
type FlatSpecItem = FlatSpecDomainItem | FlatSpecFeatureItem | FlatSpecBehaviorItem;

const flatItems = useMemo(() => flattenSpecDocument(document), [document]);
const virtualizer = useWindowVirtualizer({
  count: flatItems.length,
  estimateSize: (index) => getItemHeight(flatItems[index].type),
  overscan: 5,
});
const deferredItems = useDeferredValue(virtualizer.getVirtualItems());
ProsCons
Single scroll container (window)Flat array transformation adds complexity
Preserves normal CSS layoutType checking overhead in render function
React Compiler compatible (with escape hatch)Requires estimateSize calibration
useDeferredValue keeps UI responsiveMemory overhead from flat array

Option B: Container-Level Virtualization

Multiple useVirtualizer instances, one per Domain/Feature section. Each section has its own scroll container.

ProsCons
Simpler per-section logicMultiple scroll containers (UX fragmentation)
No flat array conversion neededposition: absolute breaks CSS spacing
Card structure collapse

Rejected: UX regression outweighed implementation simplicity. Commit 2c45796 explicitly migrated away from this pattern.

Option C: No Virtualization with Pagination

Paginate Domains/Features (e.g., 10 domains per page). Render all items within current page without virtualization.

ProsCons
Simplest implementationBreaks continuous exploration UX
No virtualization overheadPage navigation friction
Predictable memory usageDoesn't solve O(n^3) computation issue

Rejected: Spec documents are explored as continuous hierarchies; pagination fragments the user experience.

Option D: Server-Side Rendering with Streaming

Server renders visible portion initially. Stream additional content as user scrolls (React Server Components + Suspense).

ProsCons
Eliminates client computationRequires server-side scroll state
Faster initial paintNetwork latency on scroll
Complex SSR/client hydration

Rejected: Over-architected for the problem; client-side virtualization is sufficient.

Consequences

Positive

AreaBenefit
Performance1000+ behaviors render smoothly; only visible items in DOM
UX ConsistencySingle page scroll; unified scroll position
CSS IntegrityNormal flow layout; card structures preserved
ResponsivenessuseDeferredValue prevents input lag during filtering
Threshold Coverage30-item threshold catches more real-world cases

Negative

AreaTrade-offMitigation
ComplexityFlat array transformation logicIsolated in flattenSpecDocument() utility
React CompilerRequires "use no memo" directiveDocumented in ADR-22
Type SafetyRuntime type discrimination in renderExhaustive switch with TypeScript narrowing
Estimation AccuracyestimateSize mismatch causes jitterMeasure actual heights; add padding buffer

Technical Implications

  • Memory Profile: Flat array duplicates structural metadata but reduces DOM nodes from n to ~20 (viewport size)
  • Scroll Restoration: Window scroll position automatically persists across navigation (browser native behavior)
  • Search/Filter Integration: Filter operations update flat array; useDeferredValue defers re-virtualization

Implementation Details

Files Affected

FilePurpose
virtualized-document-view.tsxMain window-level virtualized view
virtualized-behavior-list.tsxContainer-level virtualized behavior list (older approach, threshold 30)
flatten-spec-document.tsHierarchy to flat array conversion
flat-spec-item.tsDiscriminated union types for flat items
use-document-filter.tsDocument filtering with useDeferredValue

Height Estimation Pattern

typescript
const getItemHeight = (item: FlatSpecItem): number => {
  const baseHeight = item.type === "domain-header" ? 80 : item.type === "feature-header" ? 56 : 72;
  return baseHeight + (item.isLastInDomain ? DOMAIN_GAP : 0);
};

ScrollMargin Handling

typescript
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
  setScrollMargin(listRef.current?.offsetTop ?? 0);
}, []);

References

Open-source test coverage insights