Skip to content

Commit 2cfba4f

Browse files
dastratakosclaude
andcommitted
review: split useViewState into a read-only data hook
Both bugbot comments pointed at the same root cause: useViewState returns a fresh object every render, and PullRequestLayout was calling it just to read isChapterViewed — instantiating four mutation hooks it never used and tagging them as a useMemo dep that never stabilized. Adds useViewStateData(runId) that returns memoized Sets + helpers + the loading/error fields. useViewState now wraps it and adds the four mutations on top, sharing the same query cache. PullRequestLayout uses useViewStateData and depends on chapterIdSet (a stable reference) for the viewedCount memo, so the count actually caches across renders and no unused mutation hooks get instantiated. ChaptersList still calls useViewState since it needs both reads and mutations. The two hooks share the same query key, so there's still exactly one network fetch per runId. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2aeb7b4 commit 2cfba4f

2 files changed

Lines changed: 46 additions & 18 deletions

File tree

web/src/lib/use-view-state.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,30 +42,59 @@ const postKeyChangeView = (id: string) =>
4242
const deleteKeyChangeView = (id: string) =>
4343
jsonFetch<unknown>(`/api/key-change-view/${encodeURIComponent(id)}`, { method: "DELETE" });
4444

45-
export interface UseViewStateResult {
46-
markChapterViewed: (chapterId: string) => void;
47-
unmarkChapterViewed: (chapterId: string) => void;
45+
export interface UseViewStateDataResult {
46+
/** Stable reference; mutates only when the underlying query data changes. */
47+
chapterIdSet: ReadonlySet<string>;
48+
/** Stable reference; mutates only when the underlying query data changes. */
49+
keyChangeIdSet: ReadonlySet<string>;
4850
isChapterViewed: (chapterId: string) => boolean;
49-
markKeyChangeChecked: (keyChangeId: string) => void;
50-
unmarkKeyChangeChecked: (keyChangeId: string) => void;
5151
isKeyChangeChecked: (keyChangeId: string) => boolean;
5252
isLoading: boolean;
5353
error: unknown;
5454
}
5555

56-
export function useViewState(runId: string): UseViewStateResult {
57-
const queryClient = useQueryClient();
58-
const queryKey = useMemo(() => viewStateQueryKey(runId), [runId]);
56+
export interface UseViewStateResult extends UseViewStateDataResult {
57+
markChapterViewed: (chapterId: string) => void;
58+
unmarkChapterViewed: (chapterId: string) => void;
59+
markKeyChangeChecked: (keyChangeId: string) => void;
60+
unmarkKeyChangeChecked: (keyChangeId: string) => void;
61+
}
5962

63+
/**
64+
* Read-only view-state for components that just need to know what's viewed.
65+
* Returns memoized Sets so callers can include them as effect/memo deps
66+
* without re-running on every render. Components that also need to mutate
67+
* view-state should use `useViewState` instead — calling this hook avoids
68+
* instantiating the four mutation hooks.
69+
*/
70+
export function useViewStateData(runId: string): UseViewStateDataResult {
6071
const { data, isLoading, error } = useQuery<ViewState>({
61-
queryKey,
72+
queryKey: viewStateQueryKey(runId),
6273
queryFn: () => fetchViewState(runId),
6374
enabled: runId !== "",
6475
});
6576

6677
const chapterIdSet = useMemo(() => new Set(data?.chapterIds ?? []), [data?.chapterIds]);
6778
const keyChangeIdSet = useMemo(() => new Set(data?.keyChangeIds ?? []), [data?.keyChangeIds]);
6879

80+
return useMemo(
81+
() => ({
82+
chapterIdSet,
83+
keyChangeIdSet,
84+
isChapterViewed: (id: string) => chapterIdSet.has(id),
85+
isKeyChangeChecked: (id: string) => keyChangeIdSet.has(id),
86+
isLoading,
87+
error,
88+
}),
89+
[chapterIdSet, keyChangeIdSet, isLoading, error],
90+
);
91+
}
92+
93+
export function useViewState(runId: string): UseViewStateResult {
94+
const queryClient = useQueryClient();
95+
const queryKey = useMemo(() => viewStateQueryKey(runId), [runId]);
96+
const data = useViewStateData(runId);
97+
6998
// Optimistic-update helpers. Await cancelQueries before writing so any
7099
// in-flight refetch can't resolve and overwrite the optimistic cache.
71100
// onError rolls back to the snapshot; onSettled invalidates to reconcile
@@ -136,13 +165,10 @@ export function useViewState(runId: string): UseViewStateResult {
136165
});
137166

138167
return {
168+
...data,
139169
markChapterViewed: markChapterMutation.mutate,
140170
unmarkChapterViewed: unmarkChapterMutation.mutate,
141-
isChapterViewed: (id) => chapterIdSet.has(id),
142171
markKeyChangeChecked: markKeyChangeMutation.mutate,
143172
unmarkKeyChangeChecked: unmarkKeyChangeMutation.mutate,
144-
isKeyChangeChecked: (id) => keyChangeIdSet.has(id),
145-
isLoading,
146-
error,
147173
};
148174
}

web/src/routes/pull-request-layout.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
44
import { SectionLabel } from "@/components/pull-request/section-label";
55
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
66
import type { ChaptersResponse } from "@/lib/api-types";
7-
import { jsonFetch, useViewState } from "@/lib/use-view-state";
7+
import { jsonFetch, useViewStateData } from "@/lib/use-view-state";
88
import { cn } from "@/lib/utils";
99
import { ChaptersIndexPage } from "./chapters-index-page";
1010

@@ -99,15 +99,17 @@ export function PullRequestLayout({ runId }: { runId: string }) {
9999
const [activeTab, setActiveTab] = useState<PrTab>(PR_TAB.CHAPTERS);
100100

101101
// Lift the viewed count out of ChaptersIndexPage so the tab strip can render
102-
// "X/N viewed" — same hook instance, same query key, no extra network calls.
103-
const view = useViewState(runId);
102+
// "X/N viewed". Read-only hook avoids instantiating the four mutation hooks
103+
// we don't use here, and chapterIdSet is a stable reference so the memo
104+
// actually caches across renders.
105+
const { chapterIdSet } = useViewStateData(runId);
104106
const chapters = data?.chapters;
105107
const viewedCount = useMemo(() => {
106108
if (!chapters) return 0;
107109
let n = 0;
108-
for (const c of chapters) if (view.isChapterViewed(c.externalId)) n++;
110+
for (const c of chapters) if (chapterIdSet.has(c.externalId)) n++;
109111
return n;
110-
}, [chapters, view]);
112+
}, [chapters, chapterIdSet]);
111113

112114
// Mirrors hosted's chapterCountLabel: just the total when nothing's been
113115
// viewed yet, otherwise "X/N viewed". Drops the count entirely if the

0 commit comments

Comments
 (0)