authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 07:49:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 07:49:02-07:00
log777f5bb2e11fe30e7fdcb5741b0b1d9bb20924d6
tree0e5e5baf69eaffca19886633378dc92ace775b49
parent246064993535e5d90107d3d7784ceef3cc883435
signature Signed by PGP key B5690EEEBB952194

perf(mobile): reuse diff rows during comment edits (#9693)


2 files changed, 247 insertions(+), 63 deletions(-)

apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts+121-1
......@@ -1,4 +1,4 @@
1import { describe, expect, it } from "vite-plus/test";
1import { describe, expect, it, vi } from "vite-plus/test";
22import {
33 DEFAULT_MOBILE_THEME_ID,
44 getMobileThemeVariables,
......@@ -9,12 +9,14 @@ import {
99import { readDefaultMobileThemeVariables } from "../../lib/mobileTheme.test-support";
1010
1111import {
12 buildNativeReviewDiffData,
1213 createNativeReviewDiffTheme,
1314 getCachedNativeReviewDiffData,
1415 type BuildNativeReviewDiffDataInput,
1516} from "./nativeReviewDiffAdapter";
1617import type { ReviewInlineComment } from "./reviewCommentSelection";
1718import { buildReviewParsedDiff } from "./reviewModel";
19import * as ReviewWordDiffs from "./reviewWordDiffs";
1820
1921const parsedDiff = buildReviewParsedDiff(
2022 [
......@@ -46,6 +48,23 @@ function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) {
4648 return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput;
4749}
4850
51function filesPatch(paths: ReadonlyArray<string>) {
52 return paths
53 .map((path) =>
54 [
55 `diff --git a/${path} b/${path}`,
56 `--- a/${path}`,
57 `+++ b/${path}`,
58 "@@ -1,2 +1,2 @@",
59 '-const first = renderPanel({ label: "before", enabled: true });',
60 '-const second = renderPanel({ label: "before", enabled: true });',
61 '+const first = renderPanel({ label: "after", enabled: true });',
62 '+const second = renderPanel({ label: "after", enabled: true });',
63 ].join("\n"),
64 )
65 .join("\n");
66}
67
4968function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) {
5069 return themeId === DEFAULT_MOBILE_THEME_ID
5170 ? readDefaultMobileThemeVariables(appearance)
......@@ -67,6 +86,107 @@ describe("getCachedNativeReviewDiffData", () => {
6786
6887 expect(equivalent).toBe(first);
6988 expect(changed).not.toBe(first);
89 expect(changed.rows.find((row) => row.kind === "comment")?.commentText).toBe("Changed");
90 });
91
92 it("reuses source rows and word matching when a file comment changes", () => {
93 const diff = buildReviewParsedDiff(filesPatch(["example.ts", "second.ts"]), "comment-reuse");
94 const matchWords = vi.spyOn(ReviewWordDiffs, "computeWordAltDiffRanges");
95 try {
96 const base = getCachedNativeReviewDiffData({ parsedDiff: diff });
97 expect(matchWords).toHaveBeenCalledTimes(4);
98 expect(base.rows.filter((row) => row.wordDiffRanges?.length)).toHaveLength(8);
99 const firstComment = makeComment("First file comment");
100 const secondComment = {
101 ...makeComment("Second file comment"),
102 id: "comment-2",
103 filePath: "second.ts",
104 };
105 const first = getCachedNativeReviewDiffData({
106 parsedDiff: diff,
107 comments: [firstComment, secondComment],
108 });
109 const changed = getCachedNativeReviewDiffData({
110 parsedDiff: diff,
111 comments: [{ ...firstComment, text: "Changed first comment" }, secondComment],
112 });
113
114 expect(matchWords).toHaveBeenCalledTimes(4);
115 expect(changed.files).toBe(base.files);
116 expect(changed.commentTargetsByRowId).toBe(base.commentTargetsByRowId);
117 expect(changed.rowIdByCommentLineId).toBe(base.rowIdByCommentLineId);
118 expect(changed.rows.find((row) => row.id === secondComment.id)).toBe(
119 first.rows.find((row) => row.id === secondComment.id),
120 );
121 const sourceRows = changed.rows.filter((row) => row.kind !== "comment");
122 for (const [index, row] of sourceRows.entries()) {
123 expect(row).toBe(base.rows[index]);
124 }
125 const removed = getCachedNativeReviewDiffData({ parsedDiff: diff, comments: [] });
126 expect(removed.rows).toEqual(base.rows);
127 expect(matchWords).toHaveBeenCalledTimes(4);
128 expect(first.rows.find((row) => row.id === firstComment.id)?.commentText).toBe(
129 "First file comment",
130 );
131 } finally {
132 matchWords.mockRestore();
133 }
134 });
135
136 it("updates comment titles and locations without changing source coordinates", () => {
137 const diff = buildReviewParsedDiff(filesPatch(["example.ts", "second.ts"]), "comment-metadata");
138 const comment = makeComment("Review this line");
139 const first = getCachedNativeReviewDiffData({ parsedDiff: diff, comments: [comment] });
140 const renamed = getCachedNativeReviewDiffData({
141 parsedDiff: diff,
142 comments: [{ ...comment, sectionTitle: "Branch comparison" }],
143 });
144 expect(renamed.rows.find((row) => row.id === comment.id)?.commentSectionTitle).toBe(
145 "Branch comparison",
146 );
147 const moved = getCachedNativeReviewDiffData({
148 parsedDiff: diff,
149 comments: [{ ...comment, filePath: "second.ts", endIndex: 99, rangeLabel: "+2" }],
150 });
151 const commentIndex = moved.rows.findIndex((row) => row.id === comment.id);
152 expect(moved.rows[commentIndex]).toMatchObject({
153 kind: "comment",
154 filePath: "second.ts",
155 commentRangeLabel: "+2",
156 });
157 expect(moved.rows[commentIndex - 1]).toMatchObject({
158 kind: "line",
159 change: "add",
160 newLineNumber: 2,
161 });
162 expect(moved.rows.filter((row) => row.kind !== "comment")).toEqual(
163 first.rows.filter((row) => row.kind !== "comment"),
164 );
165 expect(first.rows.find((row) => row.id === comment.id)?.commentSectionTitle).toBe(
166 "Dirty worktree",
167 );
168 });
169
170 it("keeps cached IDs and targets local to each parsed section and file layout", () => {
171 const original = filesPatch(["example.ts", "second.ts"]);
172 const variants = [
173 buildReviewParsedDiff(original, "first-section"),
174 buildReviewParsedDiff(original, "second-section"),
175 buildReviewParsedDiff(
176 filesPatch(["inserted.ts", "example.ts", "second.ts"]),
177 "first-section",
178 ),
179 buildReviewParsedDiff(filesPatch(["second.ts", "example.ts"]), "first-section"),
180 buildReviewParsedDiff(filesPatch(["example.ts"]), "first-section"),
181 buildReviewParsedDiff(
182 original.replaceAll("@@ -1,2 +1,2 @@", "@@ -7,2 +12,2 @@"),
183 "first-section",
184 ),
185 ];
186 for (const diff of [...variants, variants[0]!]) {
187 const input = { parsedDiff: diff, comments: [makeComment("Coordinates")] };
188 expect(getCachedNativeReviewDiffData(input)).toEqual(buildNativeReviewDiffData(input));
189 }
70190 });
71191});
72192
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+126-62
......@@ -105,10 +105,26 @@ export interface BuildNativeReviewDiffDataInput {
105105}
106106
107107interface CachedNativeReviewDiffData {
108 readonly prepared: PreparedNativeReviewDiffData;
108109 readonly commentsKey: string;
109110 readonly data: NativeReviewDiffData;
110111}
111112
113interface PreparedNativeReviewFileRows {
114 readonly fileId: string;
115 readonly filePath: string;
116 readonly lineCount: number;
117 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
118 commentedRows: {
119 readonly commentsKey: string;
120 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
121 } | null;
122}
123
124interface PreparedNativeReviewDiffData extends Omit<NativeReviewDiffData, "rows"> {
125 readonly fileRows: ReadonlyArray<PreparedNativeReviewFileRows>;
126}
127
112128const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>();
113129
114130function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string {
......@@ -116,19 +132,18 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment
116132 return "none";
117133 }
118134
119 return comments
120 .map((comment) =>
121 [
122 comment.id,
123 comment.sectionId,
124 comment.filePath,
125 comment.startIndex,
126 comment.endIndex,
127 comment.rangeLabel,
128 comment.text,
129 ].join("\u001f"),
130 )
131 .join("\u001e");
135 return JSON.stringify(
136 comments.map((comment) => [
137 comment.id,
138 comment.sectionId,
139 comment.sectionTitle,
140 comment.filePath,
141 comment.startIndex,
142 comment.endIndex,
143 comment.rangeLabel,
144 comment.text,
145 ]),
146 );
132147}
133148
134149export function createNativeReviewDiffTheme(
......@@ -378,12 +393,11 @@ function mapLineRow(
378393 };
379394}
380395
381function mapFileRows(
396function prepareFileRows(
382397 file: ReviewRenderableFile,
383 comments: ReadonlyArray<ReviewInlineComment>,
384398 commentTargetsByRowId: Map<string, NativeReviewDiffCommentTarget>,
385399 rowIdByCommentLineId: Map<string, string>,
386): ReadonlyArray<NativeReviewDiffRow> {
400): PreparedNativeReviewFileRows {
387401 const rows: NativeReviewDiffRow[] = [
388402 {
389403 kind: "file",
......@@ -398,22 +412,6 @@ function mapFileRows(
398412 ];
399413
400414 const lineRows = file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line");
401 const commentsByEndIndex = new Map<number, ReviewInlineComment[]>();
402 comments.forEach((comment) => {
403 if (comment.filePath !== file.path) {
404 return;
405 }
406 const endIndex = Math.min(comment.endIndex, lineRows.length - 1);
407 if (endIndex < 0) {
408 return;
409 }
410 const existing = commentsByEndIndex.get(endIndex);
411 if (existing) {
412 existing.push(comment);
413 return;
414 }
415 commentsByEndIndex.set(endIndex, [comment]);
416 });
417415 let lineIndex = 0;
418416 file.rows.forEach((row, rowIndex) => {
419417 if (row.kind === "hunk") {
......@@ -434,37 +432,70 @@ function mapFileRows(
434432 lines: lineRows,
435433 lineIndex,
436434 });
437 const commentsForLine = commentsByEndIndex.get(lineIndex) ?? [];
438 for (const comment of commentsForLine) {
435 lineIndex += 1;
436 });
437
438 rows.push(...noticeRowsForFile(file));
439 return {
440 fileId: file.id,
441 filePath: file.path,
442 lineCount: lineRows.length,
443 // Comments must not split the source deletion/addition runs used for word matching.
444 rows: addNativeWordDiffRanges(rows),
445 commentedRows: null,
446 };
447}
448
449function insertFileComments(
450 file: PreparedNativeReviewFileRows,
451 comments: ReadonlyArray<ReviewInlineComment>,
452): ReadonlyArray<NativeReviewDiffRow> {
453 if (comments.length === 0) {
454 file.commentedRows = null;
455 return file.rows;
456 }
457 const commentsKey = buildReviewCommentsCacheKey(comments);
458 if (file.commentedRows?.commentsKey === commentsKey) {
459 return file.commentedRows.rows;
460 }
461
462 const commentsByEndIndex = new Map<number, ReviewInlineComment[]>();
463 for (const comment of comments) {
464 const endIndex = Math.min(comment.endIndex, file.lineCount - 1);
465 if (endIndex < 0) continue;
466 const existing = commentsByEndIndex.get(endIndex);
467 if (existing) {
468 existing.push(comment);
469 } else {
470 commentsByEndIndex.set(endIndex, [comment]);
471 }
472 }
473 const rows: NativeReviewDiffRow[] = [];
474 let lineIndex = 0;
475 for (const row of file.rows) {
476 rows.push(row);
477 if (row.kind !== "line") continue;
478 for (const comment of commentsByEndIndex.get(lineIndex) ?? []) {
439479 rows.push({
440480 kind: "comment",
441481 id: comment.id,
442 fileId: file.id,
443 filePath: file.path,
482 fileId: file.fileId,
483 filePath: file.filePath,
444484 commentText: comment.text,
445485 commentRangeLabel: comment.rangeLabel,
446486 commentSectionTitle: comment.sectionTitle,
447487 });
448488 }
449489 lineIndex += 1;
450 });
451
452 rows.push(...noticeRowsForFile(file));
490 }
491 file.commentedRows = { commentsKey, rows };
453492 return rows;
454493}
455494
456export function buildNativeReviewDiffData(
457 input: BuildNativeReviewDiffDataInput,
458): NativeReviewDiffData;
459export function buildNativeReviewDiffData(parsedDiff: ReviewParsedDiff): NativeReviewDiffData;
460export function buildNativeReviewDiffData(
461 input: ReviewParsedDiff | BuildNativeReviewDiffDataInput,
462): NativeReviewDiffData {
463 const parsedDiff = "parsedDiff" in input ? input.parsedDiff : input;
464 const comments = "parsedDiff" in input ? (input.comments ?? []) : [];
495function prepareNativeReviewDiffData(parsedDiff: ReviewParsedDiff): PreparedNativeReviewDiffData {
465496 if (parsedDiff.kind !== "files") {
466497 return {
467 rows: [],
498 fileRows: [],
468499 files: [],
469500 commentTargetsByRowId: new Map(),
470501 rowIdByCommentLineId: new Map(),
......@@ -482,14 +513,12 @@ export function buildNativeReviewDiffData(
482513 }));
483514 const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>();
484515 const rowIdByCommentLineId = new Map<string, string>();
485 const rows = addNativeWordDiffRanges(
486 Arr.flatMap(parsedDiff.files, (file) =>
487 mapFileRows(file, comments, commentTargetsByRowId, rowIdByCommentLineId),
488 ),
516 const fileRows = parsedDiff.files.map((file) =>
517 prepareFileRows(file, commentTargetsByRowId, rowIdByCommentLineId),
489518 );
490519
491520 return {
492 rows,
521 fileRows,
493522 files,
494523 commentTargetsByRowId,
495524 rowIdByCommentLineId,
......@@ -498,10 +527,47 @@ export function buildNativeReviewDiffData(
498527 };
499528}
500529
530function buildCommentedNativeReviewDiffData(
531 prepared: PreparedNativeReviewDiffData,
532 comments: ReadonlyArray<ReviewInlineComment>,
533): NativeReviewDiffData {
534 const commentsByFilePath = new Map<string, ReviewInlineComment[]>();
535 for (const comment of comments) {
536 const existing = commentsByFilePath.get(comment.filePath);
537 if (existing) {
538 existing.push(comment);
539 } else {
540 commentsByFilePath.set(comment.filePath, [comment]);
541 }
542 }
543 return {
544 rows: Arr.flatMap(prepared.fileRows, (file) =>
545 insertFileComments(file, commentsByFilePath.get(file.filePath) ?? []),
546 ),
547 files: prepared.files,
548 commentTargetsByRowId: prepared.commentTargetsByRowId,
549 rowIdByCommentLineId: prepared.rowIdByCommentLineId,
550 additions: prepared.additions,
551 deletions: prepared.deletions,
552 };
553}
554
555export function buildNativeReviewDiffData(
556 input: BuildNativeReviewDiffDataInput,
557): NativeReviewDiffData;
558export function buildNativeReviewDiffData(parsedDiff: ReviewParsedDiff): NativeReviewDiffData;
559export function buildNativeReviewDiffData(
560 input: ReviewParsedDiff | BuildNativeReviewDiffDataInput,
561): NativeReviewDiffData {
562 const parsedDiff = "parsedDiff" in input ? input.parsedDiff : input;
563 const comments = "parsedDiff" in input ? (input.comments ?? []) : [];
564 return buildCommentedNativeReviewDiffData(prepareNativeReviewDiffData(parsedDiff), comments);
565}
566
501567/**
502 * Reuses the expensive flattened native row model across React development
503 * render probes and unrelated draft updates. Only the latest comment version
504 * is retained for each parsed diff so editing a comment cannot grow the cache.
568 * Prepares source rows once per parsed diff, including its section-specific IDs.
569 * Comment edits reuse those rows, word ranges, and targets. Each file retains
570 * only its latest comment overlay, and the weak key releases old parsed diffs.
505571 */
506572export function getCachedNativeReviewDiffData(
507573 input: BuildNativeReviewDiffDataInput,
......@@ -513,10 +579,8 @@ export function getCachedNativeReviewDiffData(
513579 return cached.data;
514580 }
515581
516 const data = buildNativeReviewDiffData({
517 parsedDiff: input.parsedDiff,
518 comments,
519 });
520 nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data });
582 const prepared = cached?.prepared ?? prepareNativeReviewDiffData(input.parsedDiff);
583 const data = buildCommentedNativeReviewDiffData(prepared, comments);
584 nativeReviewDiffDataCache.set(input.parsedDiff, { prepared, commentsKey, data });
521585 return data;
522586}