Skip to content

Commit 07ea3d9

Browse files
fix: relax continued viewer component typing for benchmark
1 parent 1477b52 commit 07ea3d9

File tree

9 files changed

+526
-1
lines changed

9 files changed

+526
-1
lines changed

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
66

7+
## [0.1.3] - 2026-04-07
8+
9+
### Added
10+
11+
- Added a runnable benchmark suite under `apps/benchmark` to compare:
12+
- `react-virtualized-diff`
13+
- `react-diff-viewer`
14+
- `react-diff-viewer-continued`
15+
- `react-diff-view`
16+
- Added benchmark dimensions for `1k / 10k / 50k / 100k` lines and collected metrics:
17+
- FPS
18+
- initial render time
19+
- memory usage (`performance.memory.usedJSHeapSize` in Chromium)
20+
- Added root benchmark command: `pnpm benchmark`.
21+
- Added automated benchmark runner script: `scripts/run-benchmark.mjs`.
22+
23+
### Improved
24+
25+
- Auto-installs Playwright Chromium on first benchmark run when browser binaries are missing.
26+
- Uses per-case timeout handling (`60000ms`) and records timeout cases in result outputs instead of failing the whole benchmark run.
27+
- Cleanly stops benchmark dev server after execution to avoid noisy exit errors.
28+
29+
### Output
30+
31+
- Benchmark results are generated to:
32+
- `benchmark-results/results.json`
33+
- `benchmark-results/results.md`
34+
735
## [0.1.1] - 2026-04-06
836

937
### Added

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,38 @@ export function Example() {
8282
8383
---
8484

85+
## Benchmark suite
86+
87+
A runnable benchmark harness is included to compare:
88+
89+
- `react-virtualized-diff` (this project)
90+
- `react-diff-viewer`
91+
- `react-diff-viewer-continued`
92+
- `react-diff-view`
93+
94+
Metrics collected for each dataset size (`1k / 10k / 50k / 100k` lines):
95+
96+
- FPS (average during auto-scroll)
97+
- Initial render time (ms)
98+
- Memory usage (`usedJSHeapSize` in Chromium)
99+
- Per benchmark case timeout: `60000 ms` (timeout cases are recorded in results instead of failing the run)
100+
101+
Run:
102+
103+
```bash
104+
pnpm install
105+
pnpm benchmark
106+
```
107+
108+
> If Playwright Chromium is missing, the script will auto-run `pnpm exec playwright install chromium` once.
109+
110+
> `react-diff-viewer-continued` is optional in the benchmark app. If missing locally, benchmark falls back to `react-diff-viewer` for that case.
111+
112+
Output files:
113+
114+
- `benchmark-results/results.json`
115+
- `benchmark-results/results.md`
116+
85117
## Monorepo structure
86118

87119
```text

apps/benchmark/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Diff Benchmark</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

apps/benchmark/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "benchmark-app",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"preview": "vite preview",
10+
"typecheck": "tsc -p tsconfig.json --noEmit",
11+
"lint": "echo 'No lint configured yet'",
12+
"test": "echo 'No tests yet'"
13+
},
14+
"dependencies": {
15+
"react": "^18.3.1",
16+
"react-diff-view": "^3.2.2",
17+
"react-diff-viewer": "^3.1.1",
18+
"react-dom": "^18.3.1",
19+
"react-virtualized-diff": "workspace:*",
20+
"react-diff-viewer-continued": "^3.2.6"
21+
},
22+
"devDependencies": {
23+
"@types/react": "^18.3.12",
24+
"@types/react-dom": "^18.3.1",
25+
"@vitejs/plugin-react": "^4.3.4",
26+
"typescript": "^5.8.2",
27+
"vite": "^6.2.2"
28+
}
29+
}

apps/benchmark/src/main.tsx

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import React from 'react';
2+
import ReactDOM from 'react-dom/client';
3+
import ReactDiffViewer from 'react-diff-viewer';
4+
import { Diff, Hunk, parseDiff } from 'react-diff-view';
5+
import 'react-diff-view/style/index.css';
6+
import { DiffViewer } from 'react-virtualized-diff';
7+
8+
type LibraryName = 'virtualized-diff-viewer' | 'react-diff-viewer' | 'react-diff-viewer-continued' | 'react-diff-view';
9+
10+
type BenchmarkParams = {
11+
lib: LibraryName;
12+
lines: number;
13+
height: number;
14+
};
15+
16+
type BenchmarkResult = {
17+
lib: LibraryName;
18+
lines: number;
19+
initialRenderTimeMs: number;
20+
averageFps: number;
21+
memoryBytes: number | null;
22+
userAgent: string;
23+
};
24+
25+
type DiffViewerLikeComponent = React.ComponentType<any>;
26+
27+
declare global {
28+
interface Window {
29+
__BENCHMARK_DONE__?: boolean;
30+
__BENCHMARK_RESULT__?: BenchmarkResult;
31+
}
32+
}
33+
34+
const CONTINUED_PKG = 'react-diff-viewer-continued';
35+
36+
const defaultParams: BenchmarkParams = {
37+
lib: 'virtualized-diff-viewer',
38+
lines: 1000,
39+
height: 720,
40+
};
41+
42+
function parseParams(): BenchmarkParams {
43+
const params = new URLSearchParams(window.location.search);
44+
const lib = (params.get('lib') as LibraryName | null) ?? defaultParams.lib;
45+
const lines = Number(params.get('lines') ?? defaultParams.lines);
46+
const height = Number(params.get('height') ?? defaultParams.height);
47+
48+
const normalizedLib: LibraryName =
49+
lib === 'react-diff-view' || lib === 'react-diff-viewer' || lib === 'react-diff-viewer-continued' || lib === 'virtualized-diff-viewer'
50+
? lib
51+
: defaultParams.lib;
52+
53+
return {
54+
lib: normalizedLib,
55+
lines: Number.isFinite(lines) && lines > 0 ? lines : defaultParams.lines,
56+
height: Number.isFinite(height) && height > 0 ? height : defaultParams.height,
57+
};
58+
}
59+
60+
function generateTexts(lines: number): { oldText: string; newText: string; unifiedDiff: string } {
61+
const oldLines: string[] = [];
62+
const newLines: string[] = [];
63+
64+
for (let i = 0; i < lines; i += 1) {
65+
const base = `line ${i + 1} : ${'x'.repeat(40)} ${(i % 7).toString(16)}`;
66+
oldLines.push(base);
67+
68+
if (i % 20 === 0) {
69+
newLines.push(`${base} [updated]`);
70+
continue;
71+
}
72+
73+
if (i % 125 === 0) {
74+
continue;
75+
}
76+
77+
newLines.push(base);
78+
79+
if (i % 90 === 0) {
80+
newLines.push(`inserted after ${i + 1} : ${'y'.repeat(24)}`);
81+
}
82+
}
83+
84+
const oldText = oldLines.join('\n');
85+
const newText = newLines.join('\n');
86+
87+
const unifiedDiff = [
88+
'diff --git a/benchmark.txt b/benchmark.txt',
89+
'--- a/benchmark.txt',
90+
'+++ b/benchmark.txt',
91+
`@@ -1,${oldLines.length} +1,${newLines.length} @@`,
92+
...oldLines.map((line) => `-${line}`),
93+
...newLines.map((line) => `+${line}`),
94+
].join('\n');
95+
96+
return { oldText, newText, unifiedDiff };
97+
}
98+
99+
function App() {
100+
const params = React.useMemo(parseParams, []);
101+
const containerRef = React.useRef<HTMLDivElement>(null);
102+
const [payload] = React.useState(() => generateTexts(params.lines));
103+
const [continuedViewer, setContinuedViewer] = React.useState<DiffViewerLikeComponent>(() => ReactDiffViewer);
104+
105+
React.useEffect(() => {
106+
let mounted = true;
107+
108+
import(/* @vite-ignore */ CONTINUED_PKG)
109+
.then((mod: unknown) => {
110+
if (!mounted) return;
111+
const loaded = ((mod as { default?: DiffViewerLikeComponent }).default ?? mod) as DiffViewerLikeComponent;
112+
if (loaded) {
113+
setContinuedViewer(() => loaded);
114+
}
115+
})
116+
.catch(() => {
117+
console.warn(`[benchmark] Optional dependency not found: ${CONTINUED_PKG}. Falling back to react-diff-viewer.`);
118+
});
119+
120+
return () => {
121+
mounted = false;
122+
};
123+
}, []);
124+
125+
React.useEffect(() => {
126+
const markDone = async () => {
127+
const initialRenderTimeMs = performance.now();
128+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
129+
130+
const container = containerRef.current;
131+
let frameCount = 0;
132+
const durationMs = 2500;
133+
const start = performance.now();
134+
let rafId = 0;
135+
136+
const tick = (now: number) => {
137+
frameCount += 1;
138+
if (container) {
139+
const maxScroll = container.scrollHeight - container.clientHeight;
140+
if (maxScroll > 0) {
141+
const progress = Math.min(1, (now - start) / durationMs);
142+
container.scrollTop = maxScroll * progress;
143+
}
144+
}
145+
if (now - start < durationMs) {
146+
rafId = requestAnimationFrame(tick);
147+
} else {
148+
const averageFps = (frameCount * 1000) / durationMs;
149+
const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory;
150+
const result: BenchmarkResult = {
151+
lib: params.lib,
152+
lines: params.lines,
153+
initialRenderTimeMs,
154+
averageFps,
155+
memoryBytes: memory?.usedJSHeapSize ?? null,
156+
userAgent: navigator.userAgent,
157+
};
158+
window.__BENCHMARK_RESULT__ = result;
159+
window.__BENCHMARK_DONE__ = true;
160+
}
161+
};
162+
163+
rafId = requestAnimationFrame(tick);
164+
165+
return () => cancelAnimationFrame(rafId);
166+
};
167+
168+
markDone();
169+
}, [params.lib, params.lines]);
170+
171+
let viewer: React.ReactNode;
172+
173+
if (params.lib === 'virtualized-diff-viewer') {
174+
viewer = <DiffViewer original={payload.oldText} modified={payload.newText} height={params.height} />;
175+
} else if (params.lib === 'react-diff-viewer') {
176+
viewer = (
177+
<ReactDiffViewer
178+
oldValue={payload.oldText}
179+
newValue={payload.newText}
180+
splitView
181+
showDiffOnly={false}
182+
/>
183+
);
184+
} else if (params.lib === 'react-diff-viewer-continued') {
185+
const ContinuedViewer = continuedViewer;
186+
viewer = (
187+
<ContinuedViewer
188+
oldValue={payload.oldText}
189+
newValue={payload.newText}
190+
splitView
191+
showDiffOnly={false}
192+
/>
193+
);
194+
} else {
195+
const files = parseDiff(payload.unifiedDiff);
196+
const file = files[0];
197+
viewer = file ? (
198+
<Diff viewType="split" diffType={file.type} hunks={file.hunks}>
199+
{(hunks) => hunks.map((hunk) => <Hunk key={hunk.content} hunk={hunk} />)}
200+
</Diff>
201+
) : null;
202+
}
203+
204+
return (
205+
<main style={{ padding: 12, fontFamily: 'Inter, sans-serif' }}>
206+
<h1 style={{ margin: 0, marginBottom: 12 }}>Diff benchmark runner</h1>
207+
<div
208+
ref={containerRef}
209+
style={{
210+
border: '1px solid #ddd',
211+
borderRadius: 6,
212+
overflow: 'auto',
213+
height: params.height,
214+
background: '#fff',
215+
}}
216+
>
217+
{viewer}
218+
</div>
219+
</main>
220+
);
221+
}
222+
223+
ReactDOM.createRoot(document.getElementById('root')!).render(
224+
<React.StrictMode>
225+
<App />
226+
</React.StrictMode>,
227+
);

apps/benchmark/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"jsx": "react-jsx",
5+
"types": ["vite/client"]
6+
},
7+
"include": ["src"]
8+
}

apps/benchmark/vite.config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineConfig } from 'vite';
2+
import react from '@vitejs/plugin-react';
3+
4+
export default defineConfig({
5+
plugins: [react()],
6+
server: {
7+
host: true,
8+
port: 4174,
9+
},
10+
preview: {
11+
host: true,
12+
port: 4174,
13+
},
14+
});

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
"build": "pnpm -r build",
88
"test": "pnpm -r test",
99
"lint": "pnpm -r lint",
10-
"typecheck": "pnpm -r typecheck"
10+
"typecheck": "pnpm -r typecheck",
11+
"benchmark": "node ./scripts/run-benchmark.mjs"
1112
},
1213
"volta": {
1314
"node": "20.11.1",
1415
"pnpm": "10.0.0"
16+
},
17+
"devDependencies": {
18+
"playwright": "^1.54.2"
1519
}
1620
}

0 commit comments

Comments
 (0)