Skip to content

Commit f2cc82f

Browse files
Apply PR #35375: fix(app): optimize large review panes
2 parents f6812ff + af14e43 commit f2cc82f

16 files changed

Lines changed: 992 additions & 393 deletions
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import type { Page } from "@playwright/test"
2+
import { benchmark, expect } from "../benchmark"
3+
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
4+
5+
const changedLinesPerFile = 100
6+
const linesPerSide = changedLinesPerFile / 2
7+
const fileCounts = [1, 10, 100, 1_000, 10_000]
8+
const filesPerDirectory = 100
9+
const readyFrames = 3
10+
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
11+
12+
type ReviewPaneScalingSample = {
13+
observedAtMs: number
14+
logicalRows: number
15+
treeRows: number
16+
fileRows: number
17+
diffLines: number
18+
header: string
19+
ready: boolean
20+
}
21+
22+
type ReviewPaneScalingProbe = {
23+
startedAt?: number
24+
firstTreeRowMs?: number
25+
logicalTreeReadyMs?: number
26+
firstDiffRenderMs?: number
27+
stableReadyMs?: number
28+
samples: ReviewPaneScalingSample[]
29+
frameTimesMs: number[]
30+
longTasks: { startTime: number; duration: number }[]
31+
stop: () => void
32+
}
33+
34+
benchmark.describe("performance: review pane scaling", () => {
35+
for (const fileCount of fileCounts) {
36+
const changedLines = fileCount * changedLinesPerFile
37+
38+
benchmark(
39+
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
40+
async ({ page, report }) => {
41+
benchmark.setTimeout(1_200_000)
42+
await page.emulateMedia({ reducedMotion: "reduce" })
43+
44+
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
45+
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
46+
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
47+
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
48+
await setupTimelineBenchmark(page, {
49+
historyTurns: 0,
50+
eventBatch: 1,
51+
newLayoutDesigns: true,
52+
})
53+
await page.route("**/vcs/diff**", (route) =>
54+
route.fulfill({
55+
status: 200,
56+
contentType: "application/json",
57+
headers: { "access-control-allow-origin": "*" },
58+
body: responseBody,
59+
}),
60+
)
61+
62+
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
63+
const metrics = await measureReviewPaneLoad(page, {
64+
expectedFile: reviewFile(0),
65+
expectedRows,
66+
})
67+
const search = await measureBroadReviewSearch(page, fileCount)
68+
69+
expect(metrics.logicalRows).toBe(expectedRows)
70+
expect(metrics.fileRows).toBeGreaterThan(0)
71+
expect(metrics.treeRows).toBeGreaterThan(0)
72+
expect(metrics.diffLines).toBeGreaterThan(0)
73+
expect(search.logicalRows).toBe(fileCount)
74+
expect(search.renderedRows).toBeGreaterThan(0)
75+
report(
76+
{ ...metrics, search },
77+
{
78+
fileCount,
79+
changedLinesPerFile,
80+
changedLines,
81+
additions: changedLines / 2,
82+
deletions: changedLines / 2,
83+
patchLines: changedLines,
84+
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
85+
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
86+
expectedRows,
87+
},
88+
)
89+
},
90+
)
91+
}
92+
})
93+
94+
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
95+
const filter = page.getByRole("searchbox", { name: "Filter files" })
96+
await filter.evaluate((element) => {
97+
element.addEventListener(
98+
"input",
99+
() => {
100+
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
101+
},
102+
{ once: true, capture: true },
103+
)
104+
})
105+
await filter.fill("file-")
106+
107+
return page.evaluate((expectedRows) => {
108+
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
109+
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
110+
let previous = -1
111+
let streak = 0
112+
const sample = () => {
113+
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
114+
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
115+
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
116+
const ready =
117+
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
118+
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
119+
previous = rows.length
120+
if (streak >= 3) {
121+
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
122+
return
123+
}
124+
requestAnimationFrame(sample)
125+
}
126+
requestAnimationFrame(sample)
127+
})
128+
}, expectedRows)
129+
}
130+
131+
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
132+
const changes = Array.from({ length: linesPerSide }, (_, index) => {
133+
const line = String(index).padStart(3, "0")
134+
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
135+
}).join("\n")
136+
let patchBytes = 0
137+
let capped = false
138+
139+
return Array.from({ length: fileCount }, (_, index) => {
140+
const file = reviewFile(index)
141+
const fullPatch = [
142+
`diff --git a/${file} b/${file}`,
143+
`--- a/${file}`,
144+
`+++ b/${file}`,
145+
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
146+
changes,
147+
].join("\n")
148+
if (index === 0 && fullPatch.length > patchByteLimit)
149+
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
150+
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
151+
if (patch === fullPatch) patchBytes += fullPatch.length
152+
else capped = true
153+
return {
154+
file,
155+
patch,
156+
additions: linesPerSide,
157+
deletions: linesPerSide,
158+
status: "modified" as const,
159+
}
160+
})
161+
}
162+
163+
function emptyReviewPatch(file: string) {
164+
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
165+
}
166+
167+
function reviewFile(index: number) {
168+
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
169+
}
170+
171+
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
172+
const toggle = page.getByRole("button", { name: "Toggle review" })
173+
await expect(toggle).toBeVisible()
174+
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
175+
await installReviewPaneScalingProbe(page, input)
176+
await toggle.click()
177+
await page.waitForFunction(
178+
() =>
179+
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
180+
?.stableReadyMs !== undefined,
181+
undefined,
182+
{ timeout: completionTimeoutMs },
183+
)
184+
185+
return page.evaluate(() => {
186+
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
187+
probe.stop()
188+
const startedAt = probe.startedAt!
189+
const final = probe.samples.at(-1)!
190+
const resources = performance
191+
.getEntriesByType("resource")
192+
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
193+
const resource = resources.at(-1)
194+
const longTasks = probe.longTasks.filter(
195+
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
196+
)
197+
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
198+
199+
return {
200+
firstTreeRowMs: probe.firstTreeRowMs ?? null,
201+
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
202+
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
203+
stableReadyMs: probe.stableReadyMs ?? null,
204+
responseStartMs: resource ? resource.responseStart - startedAt : null,
205+
responseEndMs: resource ? resource.responseEnd - startedAt : null,
206+
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
207+
treeRows: final.treeRows,
208+
logicalRows: final.logicalRows,
209+
fileRows: final.fileRows,
210+
diffLines: final.diffLines,
211+
samples: probe.samples.length,
212+
maxFrameGapMs: Math.max(0, ...frameGaps),
213+
longTaskCount: longTasks.length,
214+
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
215+
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
216+
}
217+
})
218+
}
219+
220+
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
221+
await page.evaluate(
222+
({ expectedFile, expectedRows, stableFrames }) => {
223+
let running = true
224+
let readyStreak = 0
225+
const basename = expectedFile.split("/").at(-1)!
226+
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
227+
? new PerformanceObserver((list) => {
228+
probe.longTasks.push(
229+
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
230+
)
231+
})
232+
: undefined
233+
const probe: ReviewPaneScalingProbe = {
234+
samples: [],
235+
frameTimesMs: [],
236+
longTasks: [],
237+
stop: () => {
238+
running = false
239+
longTaskObserver?.disconnect()
240+
},
241+
}
242+
243+
const sample = (time: number) => {
244+
if (!running || probe.startedAt === undefined) return
245+
const panel = document.querySelector<HTMLElement>("#review-panel")
246+
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
247+
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
248+
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
249+
const header =
250+
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
251+
const viewers = panel
252+
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
253+
: []
254+
const diffLines = viewers.reduce(
255+
(sum, viewer) =>
256+
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
257+
0,
258+
)
259+
const observedAtMs = time - probe.startedAt
260+
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
261+
const ready =
262+
logicalRows === expectedRows &&
263+
fileRows.length > 0 &&
264+
header.includes(basename) &&
265+
viewers.length === 1 &&
266+
diffLines > 0
267+
const previous = probe.samples.at(-1)
268+
const stable =
269+
ready &&
270+
previous?.ready === true &&
271+
previous.logicalRows === logicalRows &&
272+
previous.treeRows === rows.length &&
273+
previous.fileRows === fileRows.length &&
274+
previous.diffLines === diffLines &&
275+
previous.header === header
276+
277+
probe.frameTimesMs.push(observedAtMs)
278+
probe.samples.push({
279+
observedAtMs,
280+
logicalRows,
281+
treeRows: rows.length,
282+
fileRows: fileRows.length,
283+
diffLines,
284+
header,
285+
ready,
286+
})
287+
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
288+
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
289+
probe.logicalTreeReadyMs = observedAtMs
290+
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
291+
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
292+
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
293+
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
294+
}
295+
296+
longTaskObserver?.observe({ type: "longtask", buffered: true })
297+
document.addEventListener(
298+
"click",
299+
(event) => {
300+
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
301+
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
302+
probe.startedAt = performance.now()
303+
performance.mark("opencode.review-pane-scaling.click")
304+
requestAnimationFrame(sample)
305+
},
306+
{ capture: true, once: true },
307+
)
308+
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
309+
},
310+
{ ...input, stableFrames: readyFrames },
311+
)
312+
}

packages/app/e2e/regression/review-tab-switch.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
2525
await expectSessionTitle(page, titleA)
2626

2727
await page.getByRole("button", { name: "Toggle review" }).click()
28+
const reviewTab = page.getByRole("tab", { name: /Review/ })
29+
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
30+
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
31+
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
2832
const review = page.locator('#review-panel [data-component="session-review-v2"]')
2933
await expectAppVisible(review)
3034
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))

0 commit comments

Comments
 (0)