diff --git a/apps/web/src/hooks/useLiveRefresh.prune.test.tsx b/apps/web/src/hooks/useLiveRefresh.prune.test.tsx
new file mode 100644
index 0000000000000..9a454af8161e2
--- /dev/null
+++ b/apps/web/src/hooks/useLiveRefresh.prune.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { getLiveRefreshTrackedViewCount, useLiveRefresh } from "./useLiveRefresh";
+
+function UnkeyedProbe() {
+ useLiveRefresh(() => {});
+ return null;
+}
+
+function KeyedProbe({ viewKey }: { readonly viewKey: string }) {
+ useLiveRefresh(() => {}, { key: viewKey });
+ return null;
+}
+
+describe("useLiveRefresh view bookkeeping", () => {
+ let renderer: ReactTestRenderer | undefined;
+ afterEach(() => {
+ act(() => {
+ renderer?.unmount();
+ });
+ renderer = undefined;
+ });
+
+ it("drops the useId fallback entry when its mount unmounts", () => {
+ const before = getLiveRefreshTrackedViewCount();
+ act(() => {
+ renderer = create();
+ });
+ expect(getLiveRefreshTrackedViewCount()).toBe(before + 1);
+ act(() => {
+ renderer?.unmount();
+ });
+ renderer = undefined;
+ // A useId names one mount: no later mount can look it up again, so keeping
+ // it would leak one entry per remount (e.g. every visit to the
+ // pull-requests route).
+ expect(getLiveRefreshTrackedViewCount()).toBe(before);
+ });
+
+ it("keeps a caller-supplied key across unmounts", () => {
+ const viewKey = `test-view-${Date.now()}`;
+ const before = getLiveRefreshTrackedViewCount();
+ act(() => {
+ renderer = create();
+ });
+ expect(getLiveRefreshTrackedViewCount()).toBe(before + 1);
+ act(() => {
+ renderer?.unmount();
+ });
+ renderer = undefined;
+ // A caller-supplied key names a view that outlives its mounts (one panel
+ // showing a different pull request each time it opens), so its timestamp
+ // stays for the next arrival.
+ expect(getLiveRefreshTrackedViewCount()).toBe(before + 1);
+ });
+});
diff --git a/apps/web/src/hooks/useLiveRefresh.ts b/apps/web/src/hooks/useLiveRefresh.ts
index 5c3fc3c81a2da..8e67d6821624c 100644
--- a/apps/web/src/hooks/useLiveRefresh.ts
+++ b/apps/web/src/hooks/useLiveRefresh.ts
@@ -79,11 +79,21 @@ export function shouldRefreshOnInterval(input: {
* When each view last read, kept outside React because a mount is exactly what it has to outlive:
* a reader who navigates away and straight back mounts a fresh hook, and a timestamp that started
* at zero would call that first read due. Keyed by whatever the caller calls the view, because two
- * views on screen at once each owe their own reader an answer. Entries are one number and never
- * pruned; there is one per view the session ever read.
+ * views on screen at once each owe their own reader an answer. Entries are one number; a
+ * caller-supplied key names a view that outlives its mounts, so those entries are never pruned.
+ * The useId fallback names one mount only, so its entry is dropped when that mount unmounts —
+ * no later mount can look it up again, and keeping it would leak one entry per remount.
*/
const lastRefreshedAtByView = new Map();
+/**
+ * How many view identities currently hold a last-read timestamp. The map is
+ * module state, so this is exposed for tests to observe pruning.
+ */
+export function getLiveRefreshTrackedViewCount(): number {
+ return lastRefreshedAtByView.size;
+}
+
/**
* When the reader last did anything. Shared rather than per view: a person is present in the
* window, not in one component of it. Only tracked while a live view is mounted, and the handler
@@ -130,6 +140,15 @@ export function useLiveRefresh(
const fallbackViewId = useId();
const viewId = key ?? fallbackViewId;
+ // A useId fallback identifies this mount only: unlike a caller-supplied key it
+ // can never be looked up again after unmount, so its entry is dropped when the
+ // mount dies instead of accumulating one stale number per remount.
+ useEffect(() => {
+ return () => {
+ if (key === undefined) lastRefreshedAtByView.delete(fallbackViewId);
+ };
+ }, [fallbackViewId, key]);
+
useEffect(() => {
if (!enabled) return;
const read = (now: number) => {