From 44f83015cd8b7e4645a1904fe4e10fedea22d7a3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 1 Apr 2026 19:29:12 +0530 Subject: [PATCH] perf(review): defer offscreen diff mounts (#20469) --- packages/ui/src/components/session-review.tsx | 105 ++++++++++++++++-- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 5000fcdc49..1040aa2921 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js" -import { onCleanup } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -26,6 +25,7 @@ import { createLineCommentController } from "./line-comment-annotations" import type { LineCommentEditorProps } from "./line-comment" const MAX_DIFF_CHANGED_LINES = 500 +const REVIEW_MOUNT_MARGIN = 300 export type SessionReviewDiffStyle = "unified" | "split" @@ -69,7 +69,7 @@ export interface SessionReviewProps { split?: boolean diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void - onDiffRendered?: () => void + onDiffRendered?: VoidFunction onLineComment?: (comment: SessionReviewLineComment) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void @@ -137,11 +137,14 @@ type SessionReviewSelection = { export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined let focusToken = 0 + let frame: number | undefined const i18n = useI18n() const fileComponent = useFileComponent() const anchors = new Map() + const nodes = new Map() const [store, setStore] = createStore({ open: [] as string[], + visible: {} as Record, force: {} as Record, selection: null as SessionReviewSelection | null, commenting: null as SessionReviewSelection | null, @@ -154,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => { const open = () => props.open ?? store.open const files = createMemo(() => props.diffs.map((diff) => diff.file)) const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const))) + const grouped = createMemo(() => { + const next = new Map() + for (const comment of props.comments ?? []) { + const list = next.get(comment.file) + if (list) { + list.push(comment) + continue + } + next.set(comment.file, [comment]) + } + return next + }) const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const hasDiffs = () => files().length > 0 - const handleChange = (open: string[]) => { - props.onOpenChange?.(open) - if (props.open !== undefined) return - setStore("open", open) + const syncVisible = () => { + frame = undefined + if (!scroll) return + + const root = scroll.getBoundingClientRect() + const top = root.top - REVIEW_MOUNT_MARGIN + const bottom = root.bottom + REVIEW_MOUNT_MARGIN + const openSet = new Set(open()) + const next: Record = {} + + for (const [file, el] of nodes) { + if (!openSet.has(file)) continue + const rect = el.getBoundingClientRect() + if (rect.bottom < top || rect.top > bottom) continue + next[file] = true + } + + const prev = untrack(() => store.visible) + const prevKeys = Object.keys(prev) + const nextKeys = Object.keys(next) + if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return + setStore("visible", next) + } + + const queue = () => { + if (frame !== undefined) return + frame = requestAnimationFrame(syncVisible) + } + + const pinned = (file: string) => + props.focusedComment?.file === file || + props.focusedFile === file || + selection()?.file === file || + commenting()?.file === file || + opened()?.file === file + + const handleScroll: JSX.EventHandler = (event) => { + queue() + const next = props.onScroll + if (!next) return + if (Array.isArray(next)) { + const [fn, data] = next as [(data: unknown, event: Event) => void, unknown] + fn(data, event) + return + } + ;(next as JSX.EventHandler)(event) + } + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + createEffect(() => { + props.open + files() + queue() + }) + + const handleChange = (next: string[]) => { + props.onOpenChange?.(next) + if (props.open === undefined) setStore("open", next) + queue() } const handleExpandOrCollapseAll = () => { @@ -274,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => { viewportRef={(el) => { scroll = el props.scrollRef?.(el) + queue() }} - onScroll={props.onScroll as any} + onScroll={handleScroll} classList={{ [props.classes?.root ?? ""]: !!props.classes?.root, }} @@ -291,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => { const item = createMemo(() => diffs().get(file)!) const expanded = createMemo(() => open().includes(file)) + const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file))) const force = () => !!store.force[file] - const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file)) + const comments = createMemo(() => grouped().get(file) ?? []) const commentedLines = createMemo(() => comments().map((c) => c.selection)) const beforeText = () => (typeof item().before === "string" ? item().before : "") @@ -381,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => { onCleanup(() => { anchors.delete(file) + nodes.delete(file) + queue() }) const handleLineSelected = (range: SelectedLineRange | null) => { @@ -465,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => { ref={(el) => { wrapper = el anchors.set(file, el) + nodes.set(file, el) + queue() }} > + +
+