Files
Codewalkers/apps/web/src/components/review/ReviewSidebar.test.tsx
Lukas May 0323b42667 feat: virtualize ReviewSidebar file list for >50 items with scroll preservation
Adds windowed rendering to FilesView in ReviewSidebar.tsx using
react-window 2.x (List component). File lists with more than 50 rows
render only visible items, keeping the DOM lean for large diffs.

- Install react-window 2.x and @types/react-window in apps/web
- Flatten directory-grouped file tree into a typed Row[] array via useMemo
- Use VariableSizeList-equivalent react-window 2.x List with rowHeight fn
  (32px for dir-headers, 40px for file rows); falls back to plain DOM
  render for ≤50 rows to avoid overhead on small diffs
- Directories are collapsible: clicking the dir-header toggles collapse,
  removing its file rows from the Row[] and from the virtual list
- Preserve sidebar scroll offset across Files ↔ Commits tab switches via
  filesScrollOffsetRef passed from ReviewSidebar into FilesView
- Clicking a file calls listRef.scrollToRow({ index, align: "smart" })
  to keep the clicked row visible in the virtual list
- Root-level files (directory === "") render without a dir-header,
  preserving existing behavior
- Add resolve.dedupe for react/react-dom in vitest.config.ts to prevent
  duplicate-React errors after local workspace package installation
- Add 6 Vitest + RTL tests covering: large-list DOM count, small-list
  fallback, collapse, re-expand, tab-switch smoke, root-level files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 19:50:53 +01:00

163 lines
5.7 KiB
TypeScript

// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ReviewSidebar } from './ReviewSidebar';
import type { FileDiff, ReviewComment, CommitInfo } from './types';
// Mock ResizeObserver — not provided by happy-dom.
// Must be a class (react-window 2.x uses `new ResizeObserver(...)`).
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', MockResizeObserver);
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeFile(path: string): FileDiff {
return {
oldPath: path,
newPath: path,
hunks: [],
additions: 1,
deletions: 0,
changeType: 'modified',
};
}
function makeFiles(count: number, prefix = 'src/components/'): FileDiff[] {
return Array.from({ length: count }, (_, i) =>
makeFile(`${prefix}file${String(i).padStart(4, '0')}.ts`),
);
}
const NO_COMMENTS: ReviewComment[] = [];
const NO_COMMITS: CommitInfo[] = [];
function renderSidebar(files: FileDiff[]) {
return render(
<ReviewSidebar
files={files}
comments={NO_COMMENTS}
onFileClick={vi.fn()}
selectedCommit={null}
activeFiles={files}
commits={NO_COMMITS}
onSelectCommit={vi.fn()}
/>,
);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('ReviewSidebar FilesView virtualization', () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
it('renders only a subset of DOM rows when file count > 50', () => {
const files = makeFiles(200);
renderSidebar(files);
const fileRows = document.querySelectorAll('[data-testid="file-row"]');
// Virtualization keeps DOM rows << total count
expect(fileRows.length).toBeLessThan(100);
expect(fileRows.length).toBeGreaterThan(0);
});
it('renders all file rows when file count <= 50 (non-virtualized path)', () => {
const files = makeFiles(10);
renderSidebar(files);
const fileRows = document.querySelectorAll('[data-testid="file-row"]');
expect(fileRows.length).toBe(10);
});
it('removes file rows from DOM when a directory is collapsed', async () => {
// Use 60 files so we hit the >50 virtualized path
const files = makeFiles(60, 'src/components/');
renderSidebar(files);
// Initially, files should be rendered (at least some via virtualization)
const dirHeader = screen.getByRole('button', { name: /src\/components\// });
expect(dirHeader).toBeInTheDocument();
const rowsBefore = document.querySelectorAll('[data-testid="file-row"]').length;
expect(rowsBefore).toBeGreaterThan(0);
// Collapse the directory
await act(async () => {
fireEvent.click(dirHeader);
});
// After collapse, no file rows should be rendered for that directory
const rowsAfter = document.querySelectorAll('[data-testid="file-row"]').length;
expect(rowsAfter).toBe(0);
});
it('restores file rows when a collapsed directory is expanded again', async () => {
const files = makeFiles(60, 'src/components/');
renderSidebar(files);
const dirHeader = screen.getByRole('button', { name: /src\/components\// });
// Collapse
await act(async () => {
fireEvent.click(dirHeader);
});
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(0);
// Expand again
const freshDirHeader = screen.getByRole('button', { name: /src\/components\// });
await act(async () => {
fireEvent.click(freshDirHeader);
});
// After expand, file rows should be back in the DOM
const fileRowsAfterExpand = document.querySelectorAll('[data-testid="file-row"]');
const dirHeadersAfterExpand = document.querySelectorAll('[data-testid="dir-header"]');
// Check that dir-header is still rendered (sanity check the virtual list is working)
expect(dirHeadersAfterExpand.length).toBeGreaterThan(0);
expect(fileRowsAfterExpand.length).toBeGreaterThan(0);
});
it('preserves scroll position when switching from Files tab to Commits tab and back', async () => {
// This verifies the tab switch does not crash and re-mounts correctly
const files = makeFiles(200);
renderSidebar(files);
// Initial state: file rows visible
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
// Switch to Commits tab
const commitsTab = screen.getByTitle('Commits');
await act(async () => {
fireEvent.click(commitsTab);
});
// Files tab content should be gone
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBe(0);
// Switch back to Files tab
const filesTab = screen.getByTitle('Files');
await act(async () => {
fireEvent.click(filesTab);
});
// File rows should be back
expect(document.querySelectorAll('[data-testid="file-row"]').length).toBeGreaterThan(0);
});
it('root-level files (no subdirectory) render without a directory header', () => {
const files = makeFiles(10, ''); // No prefix = root-level files
renderSidebar(files);
const fileRows = document.querySelectorAll('[data-testid="file-row"]');
expect(fileRows.length).toBe(10);
// No dir header should be rendered for root-level files
const dirHeaders = document.querySelectorAll('[data-testid="dir-header"]');
expect(dirHeaders.length).toBe(0);
});
});