Skip to content

Commit 44f8301

Browse files
authored
perf(review): defer offscreen diff mounts (#20469)
1 parent 9a1c9ae commit 44f8301

1 file changed

Lines changed: 96 additions & 9 deletions

File tree

packages/ui/src/components/session-review.tsx

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file"
1313
import { useI18n } from "../context/i18n"
1414
import { getDirectory, getFilename } from "@opencode-ai/util/path"
1515
import { checksum } from "@opencode-ai/util/encode"
16-
import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
17-
import { onCleanup } from "solid-js"
16+
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
1817
import { createStore } from "solid-js/store"
1918
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
2019
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -26,6 +25,7 @@ import { createLineCommentController } from "./line-comment-annotations"
2625
import type { LineCommentEditorProps } from "./line-comment"
2726

2827
const MAX_DIFF_CHANGED_LINES = 500
28+
const REVIEW_MOUNT_MARGIN = 300
2929

3030
export type SessionReviewDiffStyle = "unified" | "split"
3131

@@ -69,7 +69,7 @@ export interface SessionReviewProps {
6969
split?: boolean
7070
diffStyle?: SessionReviewDiffStyle
7171
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
72-
onDiffRendered?: () => void
72+
onDiffRendered?: VoidFunction
7373
onLineComment?: (comment: SessionReviewLineComment) => void
7474
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
7575
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
@@ -137,11 +137,14 @@ type SessionReviewSelection = {
137137
export const SessionReview = (props: SessionReviewProps) => {
138138
let scroll: HTMLDivElement | undefined
139139
let focusToken = 0
140+
let frame: number | undefined
140141
const i18n = useI18n()
141142
const fileComponent = useFileComponent()
142143
const anchors = new Map<string, HTMLElement>()
144+
const nodes = new Map<string, HTMLDivElement>()
143145
const [store, setStore] = createStore({
144146
open: [] as string[],
147+
visible: {} as Record<string, boolean>,
145148
force: {} as Record<string, boolean>,
146149
selection: null as SessionReviewSelection | null,
147150
commenting: null as SessionReviewSelection | null,
@@ -154,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => {
154157
const open = () => props.open ?? store.open
155158
const files = createMemo(() => props.diffs.map((diff) => diff.file))
156159
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
160+
const grouped = createMemo(() => {
161+
const next = new Map<string, SessionReviewComment[]>()
162+
for (const comment of props.comments ?? []) {
163+
const list = next.get(comment.file)
164+
if (list) {
165+
list.push(comment)
166+
continue
167+
}
168+
next.set(comment.file, [comment])
169+
}
170+
return next
171+
})
157172
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
158173
const hasDiffs = () => files().length > 0
159174

160-
const handleChange = (open: string[]) => {
161-
props.onOpenChange?.(open)
162-
if (props.open !== undefined) return
163-
setStore("open", open)
175+
const syncVisible = () => {
176+
frame = undefined
177+
if (!scroll) return
178+
179+
const root = scroll.getBoundingClientRect()
180+
const top = root.top - REVIEW_MOUNT_MARGIN
181+
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
182+
const openSet = new Set(open())
183+
const next: Record<string, boolean> = {}
184+
185+
for (const [file, el] of nodes) {
186+
if (!openSet.has(file)) continue
187+
const rect = el.getBoundingClientRect()
188+
if (rect.bottom < top || rect.top > bottom) continue
189+
next[file] = true
190+
}
191+
192+
const prev = untrack(() => store.visible)
193+
const prevKeys = Object.keys(prev)
194+
const nextKeys = Object.keys(next)
195+
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
196+
setStore("visible", next)
197+
}
198+
199+
const queue = () => {
200+
if (frame !== undefined) return
201+
frame = requestAnimationFrame(syncVisible)
202+
}
203+
204+
const pinned = (file: string) =>
205+
props.focusedComment?.file === file ||
206+
props.focusedFile === file ||
207+
selection()?.file === file ||
208+
commenting()?.file === file ||
209+
opened()?.file === file
210+
211+
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
212+
queue()
213+
const next = props.onScroll
214+
if (!next) return
215+
if (Array.isArray(next)) {
216+
const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
217+
fn(data, event)
218+
return
219+
}
220+
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
221+
}
222+
223+
onCleanup(() => {
224+
if (frame === undefined) return
225+
cancelAnimationFrame(frame)
226+
})
227+
228+
createEffect(() => {
229+
props.open
230+
files()
231+
queue()
232+
})
233+
234+
const handleChange = (next: string[]) => {
235+
props.onOpenChange?.(next)
236+
if (props.open === undefined) setStore("open", next)
237+
queue()
164238
}
165239

166240
const handleExpandOrCollapseAll = () => {
@@ -274,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
274348
viewportRef={(el) => {
275349
scroll = el
276350
props.scrollRef?.(el)
351+
queue()
277352
}}
278-
onScroll={props.onScroll as any}
353+
onScroll={handleScroll}
279354
classList={{
280355
[props.classes?.root ?? ""]: !!props.classes?.root,
281356
}}
@@ -291,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
291366
const item = createMemo(() => diffs().get(file)!)
292367

293368
const expanded = createMemo(() => open().includes(file))
369+
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
294370
const force = () => !!store.force[file]
295371

296-
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
372+
const comments = createMemo(() => grouped().get(file) ?? [])
297373
const commentedLines = createMemo(() => comments().map((c) => c.selection))
298374

299375
const beforeText = () => (typeof item().before === "string" ? item().before : "")
@@ -381,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
381457

382458
onCleanup(() => {
383459
anchors.delete(file)
460+
nodes.delete(file)
461+
queue()
384462
})
385463

386464
const handleLineSelected = (range: SelectedLineRange | null) => {
@@ -465,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
465543
ref={(el) => {
466544
wrapper = el
467545
anchors.set(file, el)
546+
nodes.set(file, el)
547+
queue()
468548
}}
469549
>
470550
<Show when={expanded()}>
471551
<Switch>
552+
<Match when={!mounted() && !tooLarge()}>
553+
<div
554+
data-slot="session-review-diff-placeholder"
555+
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
556+
style={{ height: "160px" }}
557+
/>
558+
</Match>
472559
<Match when={tooLarge()}>
473560
<div data-slot="session-review-large-diff">
474561
<div data-slot="session-review-large-diff-title">

0 commit comments

Comments
 (0)