Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/release-reloaded-review-renders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Fix a memory leak in `--watch` and other live reloads where every reload kept the previous review's files and render state alive, so long-running sessions grew without bound.
31 changes: 31 additions & 0 deletions packages/hunk/src/ui/hooks/useTerminalReview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,37 @@ describe("useTerminalReview", () => {
}
});

test("releases files from earlier reloads once later reloads replace them", async () => {
const { controllerRef, setFilesRef, setup } = await renderTerminalReview([createTwoHunkFile()]);
const reloadedFiles: WeakRef<DiffFile>[] = [];

try {
await flush(setup);

// Each reload replaces the files first and the measured stops one render later, which is the
// staggered update order that let memoized callbacks chain every earlier render together.
for (let reload = 0; reload < 12; reload += 1) {
const file = reload % 2 === 0 ? createSingleHunkFile() : createTwoHunkFile();
reloadedFiles.push(new WeakRef(file));
await act(async () => {
expectValue(setFilesRef.current)([file]);
});
await flush(setup);
}

expect(expectValue(controllerRef.current).visibleFiles).toHaveLength(1);
Bun.gc(true);
const retainedEarlyReloads = reloadedFiles
.slice(0, -4)
.filter((reference) => reference.deref() !== undefined);
expect(retainedEarlyReloads).toHaveLength(0);
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("moves through visible files with clamped file-header alignment", async () => {
const controllerRef: { current: TerminalReview | null } = { current: null };
const setup = await testRender(
Expand Down
13 changes: 10 additions & 3 deletions packages/hunk/src/ui/hooks/useTerminalReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,17 +881,24 @@ export function useTerminalReview({
[applyLineCursor, keyByFileId, runIntent],
);

// Read stops through a ref so this callback stays stable across reloads. Closures from one
// render share its scope, so a callback rebuilt when stops change would retain the render that
// rebuilt `resolveCommentRequest` for new files, and through it every earlier review generation.
const reviewVerticalStopsRef = useRef(reviewVerticalStops);
reviewVerticalStopsRef.current = reviewVerticalStops;

/** Read the exact rendered stop that currently owns keyboard focus. */
const currentReviewVerticalStop = useCallback((): ReviewVerticalStop | null => {
const activeNoteId = selectActiveStoredReviewNote(store.getSnapshot())?.note.id;
if (activeNoteId) {
return (
reviewVerticalStops.find((stop) => stop.kind === "note" && stop.noteId === activeNoteId) ??
null
reviewVerticalStopsRef.current.find(
(stop) => stop.kind === "note" && stop.noteId === activeNoteId,
) ?? null
);
}
return lineCursorRef.current ? { kind: "line", cursor: lineCursorRef.current } : null;
}, [reviewVerticalStops, store]);
}, [store]);

/** Focus one measured note stop through its current semantic owner. */
const focusReviewNoteStop = useCallback(
Expand Down
Loading