authorgravatar for steva.borus@gmail.comStevan Borus <steva.borus@gmail.com> 2026-10-06 22:56:33+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 13:56:33-07:00
logf21d6da51c9aa78ea12e7f13a378f55f1e63cb38
tree0e6091e41a5a81f4246a4dae7867eab186636f46
parent115640ae49924c9e4e6215bd8d911445ad932bff
signature Signed by PGP key B5690EEEBB952194

perf(web): diff panel no longer re-renders every file header each time a patch arrives (#16033)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 145 insertions(+), 79 deletions(-)

apps/web/src/components/DiffPanel.tsx+103-54
...@@ -21,7 +21,7 @@ import {...@@ -21,7 +21,7 @@ import {
21import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide";21import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide";
22import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
23import * as DateTime from "effect/DateTime";23import * as DateTime from "effect/DateTime";
24import { useCallback, useEffect, useMemo, useRef, useState } from "react";24import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
25import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";25import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";
26import { useOpenInPreferredEditor } from "../editorPreferences";26import { useOpenInPreferredEditor } from "../editorPreferences";
27import { useFileContextMenuHandler } from "../fileContextMenu";27import { useFileContextMenuHandler } from "../fileContextMenu";
...@@ -118,6 +118,74 @@ interface CollapsedDiffFilesState {...@@ -118,6 +118,74 @@ interface CollapsedDiffFilesState {
118118
119const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set();119const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set();
120120
121/** Collapse control for one file header; re-renders only when its own file changes. */
122function DiffFileCollapseToggle({
123 filePath,
124 fileKey,
125 collapsed,
126 unavailable,
127 iconClassName,
128 onToggle,
129}: {
130 filePath: string;
131 fileKey: string;
132 collapsed: boolean;
133 unavailable: boolean;
134 iconClassName: string;
135 onToggle: (fileKey: string) => void;
136}) {
137 return (
138 <Tooltip>
139 <TooltipTrigger
140 render={
141 <Button
142 size="icon-micro"
143 variant="ghost"
144 className="-ms-0.5"
145 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
146 aria-expanded={!collapsed}
147 disabled={unavailable}
148 onClick={(event) => {
149 event.stopPropagation();
150 onToggle(fileKey);
151 }}
152 />
153 }
154 >
155 <MorphIcon
156 className={cn("size-4", iconClassName)}
157 icon={collapsed ? ChevronRight : ChevronDown}
158 />
159 </TooltipTrigger>
160 <TooltipPopup side="top">{collapsed ? "Expand diff" : "Collapse diff"}</TooltipPopup>
161 </Tooltip>
162 );
163}
164
165/** Copy and status controls for one file header; re-renders only when its own file changes. */
166function DiffFileHeaderSuffix({
167 filePath,
168 hasStat,
169 error,
170 truncated,
171 onRetry,
172}: {
173 filePath: string;
174 hasStat: boolean;
175 error: boolean;
176 truncated: boolean;
177 onRetry: (path: string) => void;
178}) {
179 return (
180 <>
181 <DiffFilePathCopyButton filePath={filePath} />
182 {hasStat ? (
183 <DiffFileStatus error={error} truncated={truncated} retry={() => onRetry(filePath)} />
184 ) : null}
185 </>
186 );
187}
188
121interface DiffPanelProps {189interface DiffPanelProps {
122 mode?: DiffPanelMode;190 mode?: DiffPanelMode;
123 composerDraftTarget: ScopedThreadRef | DraftId;191 composerDraftTarget: ScopedThreadRef | DraftId;
...@@ -598,22 +666,24 @@ export default function DiffPanel({...@@ -598,22 +666,24 @@ export default function DiffPanel({
598 },666 },
599 [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef],667 [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef],
600 );668 );
601 const toggleDiffFileCollapsed = useCallback(669 const collapseDefaultsRef = useRef({ collapseScopeKey, defaultCollapsedDiffFileKeys });
602 (fileKey: string) => {670 useLayoutEffect(() => {
603 setCollapsedDiffFiles((current) => {671 collapseDefaultsRef.current = { collapseScopeKey, defaultCollapsedDiffFileKeys };
604 const next = new Set(672 }, [collapseScopeKey, defaultCollapsedDiffFileKeys]);
605 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys,673 const toggleDiffFileCollapsed = useCallback((fileKey: string) => {
606 );674 const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current;
607 if (next.has(fileKey)) {675 setCollapsedDiffFiles((current) => {
608 next.delete(fileKey);676 const next = new Set(
609 } else {677 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys,
610 next.add(fileKey);678 );
611 }679 if (next.has(fileKey)) {
612 return { scopeKey: collapseScopeKey, fileKeys: next };680 next.delete(fileKey);
613 });681 } else {
614 },682 next.add(fileKey);
615 [collapseScopeKey, defaultCollapsedDiffFileKeys],683 }
616 );684 return { scopeKey: collapseScopeKey, fileKeys: next };
685 });
686 }, []);
617687
618 const toggleDiffFileCollapse = useCallback(() => {688 const toggleDiffFileCollapse = useCallback(() => {
619 setCodeViewRevision((current) => current + 1);689 setCodeViewRevision((current) => current + 1);
...@@ -1082,14 +1152,15 @@ export default function DiffPanel({...@@ -1082,14 +1152,15 @@ export default function DiffPanel({
1082 composerDraftTarget={composerDraftTarget}1152 composerDraftTarget={composerDraftTarget}
1083 renderHeaderFilenameSuffix={(fileDiff) => {1153 renderHeaderFilenameSuffix={(fileDiff) => {
1084 const path = resolveFileDiffPath(fileDiff);1154 const path = resolveFileDiffPath(fileDiff);
1085 const stat = fileStats.get(path);1155 const state = fileStates.get(path);
1086 return (1156 return (
1087 <>1157 <DiffFileHeaderSuffix
1088 <DiffFilePathCopyButton filePath={path} />1158 filePath={path}
1089 {stat ? (1159 hasStat={fileStats.has(path)}
1090 <DiffFileStatus {...fileStates.get(path)} retry={() => retry(path)} />1160 error={state?.error ?? false}
1091 ) : null}1161 truncated={state?.truncated ?? false}
1092 </>1162 onRetry={retry}
1163 />
1093 );1164 );
1094 }}1165 }}
1095 {...(lazySource1166 {...(lazySource
...@@ -1109,37 +1180,15 @@ export default function DiffPanel({...@@ -1109,37 +1180,15 @@ export default function DiffPanel({
1109 : {})}1180 : {})}
1110 renderHeaderPrefix={(fileDiff, fileKey) => {1181 renderHeaderPrefix={(fileDiff, fileKey) => {
1111 const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true;1182 const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true;
1112 const collapsed = unavailable || collapsedDiffFileKeys.has(fileKey);
1113 const filePath = resolveFileDiffPath(fileDiff);
1114 return (1183 return (
1115 <Tooltip>1184 <DiffFileCollapseToggle
1116 <TooltipTrigger1185 filePath={resolveFileDiffPath(fileDiff)}
1117 render={1186 fileKey={fileKey}
1118 <Button1187 collapsed={unavailable || collapsedDiffFileKeys.has(fileKey)}
1119 size="icon-micro"1188 unavailable={unavailable}
1120 variant="ghost"1189 iconClassName={getDiffCollapseIconClassName(fileDiff)}
1121 className="-ms-0.5"1190 onToggle={toggleDiffFileCollapsed}
1122 aria-label={1191 />
1123 collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`
1124 }
1125 aria-expanded={!collapsed}
1126 disabled={unavailable}
1127 onClick={(event) => {
1128 event.stopPropagation();
1129 toggleDiffFileCollapsed(fileKey);
1130 }}
1131 />
1132 }
1133 >
1134 <MorphIcon
1135 className={cn("size-4", getDiffCollapseIconClassName(fileDiff))}
1136 icon={collapsed ? ChevronRight : ChevronDown}
1137 />
1138 </TooltipTrigger>
1139 <TooltipPopup side="top">
1140 {collapsed ? "Expand diff" : "Collapse diff"}
1141 </TooltipPopup>
1142 </Tooltip>
1143 );1192 );
1144 }}1193 }}
1145 options={{1194 options={{
apps/web/src/components/diffs/AnnotatableCodeView.tsx+6-6
...@@ -11,7 +11,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts";...@@ -11,7 +11,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts";
11import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react";11import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react";
1212
13import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";13import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
14import { fnv1a32 } from "~/lib/diffRendering";14import { fnv1a32, resolveFileDiffPath } from "~/lib/diffRendering";
15import {15import {
16 buildDiffReviewComment,16 buildDiffReviewComment,
17 restoreDiffReviewCommentRange,17 restoreDiffReviewCommentRange,
...@@ -217,15 +217,15 @@ export function AnnotatableCodeView({...@@ -217,15 +217,15 @@ export function AnnotatableCodeView({
217 if (!range) return;217 if (!range) return;
218 const item = context.item;218 const item = context.item;
219 if (item.type !== "diff") return;219 if (item.type !== "diff") return;
220 const file = filesByKey.get(item.id);220 // Read from the item, not the file list, so this callback keeps its identity as
221 if (!file) return;221 // patches arrive; the viewer re-applies its options whenever it changes.
222 const id = nextFileCommentId();222 const id = nextFileCommentId();
223 const comment = buildDiffReviewComment({223 const comment = buildDiffReviewComment({
224 id,224 id,
225 sectionId,225 sectionId,
226 sectionTitle,226 sectionTitle,
227 filePath: file.filePath,227 filePath: resolveFileDiffPath(item.fileDiff),
228 fileDiff: file.fileDiff,228 fileDiff: item.fileDiff,
229 range,229 range,
230 text: "",230 text: "",
231 });231 });
...@@ -242,7 +242,7 @@ export function AnnotatableCodeView({...@@ -242,7 +242,7 @@ export function AnnotatableCodeView({
242 },242 },
243 });243 });
244 },244 },
245 [filesByKey, sectionId, sectionTitle],245 [sectionId, sectionTitle],
246 );246 );
247247
248 const hasOpenComment = draft !== null;248 const hasOpenComment = draft !== null;
apps/web/src/components/diffs/useReviewFilePatches.ts+36-19
...@@ -3,7 +3,15 @@ import type { FileDiffMetadata } from "@pierre/diffs";...@@ -3,7 +3,15 @@ import type { FileDiffMetadata } from "@pierre/diffs";
3import type { EnvironmentId, ReviewDiffPreviewSource } from "@t3tools/contracts";3import type { EnvironmentId, ReviewDiffPreviewSource } from "@t3tools/contracts";
4import * as AsyncResult from "effect/reactivity/AsyncResult";4import * as AsyncResult from "effect/reactivity/AsyncResult";
5import * as Atom from "effect/reactivity/Atom";5import * as Atom from "effect/reactivity/Atom";
6import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";6import {
7 useCallback,
8 useContext,
9 useEffect,
10 useLayoutEffect,
11 useMemo,
12 useRef,
13 useState,
14} from "react";
7import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering";15import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering";
8import { reviewEnvironment } from "~/state/review";16import { reviewEnvironment } from "~/state/review";
917
...@@ -146,12 +154,17 @@ export function useReviewFilePatches({...@@ -146,12 +154,17 @@ export function useReviewFilePatches({
146 [requestFiles, settledFileCount],154 [requestFiles, settledFileCount],
147 );155 );
148 const requestFile = useCallback((index: number) => requestFiles([index]), [requestFiles]);156 const requestFile = useCallback((index: number) => requestFiles([index]), [requestFiles]);
157 const retryInputsRef = useRef({ queries, files });
158 useLayoutEffect(() => {
159 retryInputsRef.current = { queries, files };
160 }, [queries, files]);
149 const retry = useCallback(161 const retry = useCallback(
150 (path: string) => {162 (path: string) => {
163 const { queries, files } = retryInputsRef.current;
151 const query = queries.find(({ index }) => files[index]?.path === path)?.query;164 const query = queries.find(({ index }) => files[index]?.path === path)?.query;
152 if (query) registry.refresh(query);165 if (query) registry.refresh(query);
153 },166 },
154 [queries, files, registry],167 [registry],
155 );168 );
156 const renderableFiles = useMemo(169 const renderableFiles = useMemo(
157 () =>170 () =>
...@@ -185,23 +198,27 @@ export function useReviewFilePatches({...@@ -185,23 +198,27 @@ export function useReviewFilePatches({
185 ),198 ),
186 [source, files, patches, scope, preview],199 [source, files, patches, scope, preview],
187 );200 );
188 const fileStates = new Map(201 const fileStates = useMemo(
189 files.map((file, index) => {202 () =>
190 const patch = patches.get(index);203 new Map(
191 return [204 files.map((file, index) => {
192 file.path,205 const patch = patches.get(index);
193 {206 return [
194 error:207 file.path,
195 patch?._tag === "Failure" ||208 {
196 (patch?._tag === "Success" &&209 error:
197 (patch.value.patch?.kind !== "files" ||210 patch?._tag === "Failure" ||
198 !patch.value.patch.files.some(211 (patch?._tag === "Success" &&
199 (candidate) => resolveFileDiffPath(candidate) === file.path,212 (patch.value.patch?.kind !== "files" ||
200 ))),213 !patch.value.patch.files.some(
201 truncated: patch?._tag === "Success" && patch.value.source.truncated,214 (candidate) => resolveFileDiffPath(candidate) === file.path,
202 },215 ))),
203 ] as const;216 truncated: patch?._tag === "Success" && patch.value.source.truncated,
204 }),217 },
218 ] as const;
219 }),
220 ),
221 [files, patches],
205 );222 );
206 const readyFilePaths = useMemo(223 const readyFilePaths = useMemo(
207 () =>224 () =>