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>
163 lines
5.7 KiB
TypeScript
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);
|
|
});
|
|
});
|