Skip to content
Open
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
59 changes: 59 additions & 0 deletions apps/web/src/hooks/useLiveRefresh.prune.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<UnkeyedProbe />);
});
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(<KeyedProbe viewKey={viewKey} />);
});
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);
});
});
23 changes: 21 additions & 2 deletions apps/web/src/hooks/useLiveRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();

/**
* 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
Expand Down Expand Up @@ -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) => {
Expand Down
Loading