Skip to content

Commit b7a8b6a

Browse files
refactor(web): thread find compiles under the strict React Compiler rules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent e2e9b4d commit b7a8b6a

1 file changed

Lines changed: 95 additions & 60 deletions

File tree

‎apps/web/src/components/chat/useThreadFind.ts‎

Lines changed: 95 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import type {
55
ScopedThreadRef,
66
} from "@t3tools/contracts";
77
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
8-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8+
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
9+
import { useStableCallback } from "~/hooks/useStableCallback";
910
import { orchestrationEnvironment } from "~/state/orchestration";
1011
import { useEnvironmentQuery } from "~/state/query";
1112
import { useDebouncedValue } from "~/state/queries";
@@ -49,7 +50,10 @@ export function useThreadFind({
4950
skills?: readonly InlineSkill[];
5051
content: Pick<OrchestrationV2ThreadProjection, "visibleTurnItems" | "runs"> | undefined;
5152
}) {
52-
const findPositionReaderRef = useRef<ThreadFindPositionReader | null>(null);
53+
// Held in state so the timeline props that carry it can be stored in state too.
54+
const [positionReader] = useState<RefObject<ThreadFindPositionReader | null>>(() => ({
55+
current: null,
56+
}));
5357
const lastContentRef = useRef(content);
5458
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
5559
const refreshAfterSearchRef = useRef(false);
@@ -58,7 +62,7 @@ export function useThreadFind({
5862
if (state.threadKey !== null && (!enabled || state.threadKey !== threadKey))
5963
setState(CLOSED_FIND);
6064
const isOpen = enabled && threadKey !== null && state.threadKey === threadKey;
61-
const open = useCallback(() => {
65+
const open = useStableCallback(() => {
6266
if (threadKey === null) return;
6367
if (!enabled) {
6468
toastManager.add({
@@ -70,7 +74,7 @@ export function useThreadFind({
7074
}
7175
const start =
7276
state.threadKey === threadKey && state.query.trim()
73-
? findPositionReaderRef.current?.(state.query.trim())
77+
? positionReader.current?.(state.query.trim())
7478
: undefined;
7579
setState((previous) => {
7680
const current = previous.threadKey === threadKey ? previous : CLOSED_FIND;
@@ -89,8 +93,8 @@ export function useThreadFind({
8993
: {}),
9094
};
9195
});
92-
}, [enabled, state.query, state.threadKey, threadKey]);
93-
const close = useCallback(() => setState(CLOSED_FIND), []);
96+
});
97+
const close = useStableCallback(() => setState(CLOSED_FIND));
9498
useEffect(() => subscribeThreadFindOpen(open), [open]);
9599

96100
const remote = useServerResults(
@@ -118,16 +122,35 @@ export function useThreadFind({
118122
const entryId = selectedMatch?.entryId;
119123
const runId = selectedMatch?.runId;
120124
const occurrence = selectedMatch?.occurrence;
121-
// Finishing the count must not rerender the timeline or request another jump.
122-
const match = useMemo(
123-
() =>
125+
const counting = remote.data?.complete === false;
126+
const navigationId = localSelection?.navigationId ?? remote.navigationId;
127+
const findExpanded = isOpen && state.query.trim().length > 0;
128+
const findQuery = isOpen && remote.data?.match ? state.query : "";
129+
const nextTimeline = {
130+
findOpen: isOpen,
131+
findExpanded,
132+
findQuery,
133+
activeFindMatch:
124134
entryId !== undefined && occurrence !== undefined
125135
? { entryId, runId: runId ?? null, occurrence }
126136
: null,
127-
[entryId, runId, occurrence],
128-
);
129-
const counting = remote.data?.complete === false;
130-
const navigationId = localSelection?.navigationId ?? remote.navigationId;
137+
findNavigationId: navigationId,
138+
};
139+
// Finishing the count or a pending response must not rerender the timeline or
140+
// request another jump, so these keep their identity until a field changes.
141+
const [storedTimeline, setTimeline] = useState(() => ({
142+
fields: nextTimeline,
143+
props: { ...nextTimeline, findPositionReaderRef: positionReader },
144+
}));
145+
let timeline = storedTimeline;
146+
if (!sameTimeline(timeline.fields, nextTimeline)) {
147+
timeline = {
148+
fields: nextTimeline,
149+
props: { ...nextTimeline, findPositionReaderRef: positionReader },
150+
};
151+
setTimeline(timeline);
152+
}
153+
const match = timeline.fields.activeFindMatch;
131154
const step = (delta: number) => {
132155
if (count === 0 || counting) return;
133156
setState((previous) => {
@@ -241,20 +264,6 @@ export function useThreadFind({
241264
};
242265
}, [threadKey, isOpen, state.query]);
243266

244-
const findExpanded = isOpen && state.query.trim().length > 0;
245-
const findQuery = isOpen && remote.data?.match ? state.query : "";
246-
const timelineProps = useMemo(
247-
() => ({
248-
findOpen: isOpen,
249-
findExpanded,
250-
findPositionReaderRef,
251-
findQuery,
252-
activeFindMatch: match,
253-
findNavigationId: navigationId,
254-
}),
255-
[isOpen, findExpanded, findQuery, match, navigationId],
256-
);
257-
258267
return {
259268
isOpen,
260269
open,
@@ -269,7 +278,7 @@ export function useThreadFind({
269278
focusRequestId: state.focusRequestId,
270279
onRetry: remote.refresh,
271280
onQueryChange: (query: string) => {
272-
const start = findPositionReaderRef.current?.(query.trim());
281+
const start = positionReader.current?.(query.trim());
273282
setState((previous) => ({
274283
...previous,
275284
query,
@@ -284,10 +293,31 @@ export function useThreadFind({
284293
onPrevious: () => step(-1),
285294
onClose: close,
286295
},
287-
timelineProps,
296+
timelineProps: timeline.props,
288297
};
289298
}
290299

300+
type TimelineFields = Omit<
301+
ReturnType<typeof useThreadFind>["timelineProps"],
302+
"findPositionReaderRef"
303+
>;
304+
305+
function sameTimeline(a: TimelineFields, b: TimelineFields) {
306+
const aMatch = a.activeFindMatch;
307+
const bMatch = b.activeFindMatch;
308+
return (
309+
a.findOpen === b.findOpen &&
310+
a.findExpanded === b.findExpanded &&
311+
a.findQuery === b.findQuery &&
312+
a.findNavigationId === b.findNavigationId &&
313+
(aMatch === null || bMatch === null
314+
? aMatch === bMatch
315+
: aMatch.entryId === bMatch.entryId &&
316+
aMatch.runId === bMatch.runId &&
317+
aMatch.occurrence === bMatch.occurrence)
318+
);
319+
}
320+
291321
function rebaseLocalSelection(
292322
selection: typeof CLOSED_FIND.localSelection,
293323
data: OrchestrationV2SearchThreadResult | null,
@@ -311,41 +341,36 @@ function useServerResults(
311341
skills: readonly InlineSkill[],
312342
progressive: boolean,
313343
) {
314-
const skillLabels = useMemo(
315-
() => skills.map(({ name, displayName }) => ({ name, displayName })),
316-
[skills],
317-
);
344+
const skillLabels = skills.map(({ name, displayName }) => ({ name, displayName }));
318345
const normalizedQuery = query.trim();
319346
const debouncedQuery = useDebouncedValue(normalizedQuery, 100);
320347
const environmentId = thread?.environmentId;
321348
const threadId = thread?.threadId;
322-
const atom = useMemo(
323-
() =>
324-
environmentId && threadId && debouncedQuery && normalizedQuery === debouncedQuery
325-
? (progressive
326-
? orchestrationEnvironment.threadFindProgressive
327-
: orchestrationEnvironment.threadFind)({
328-
environmentId,
329-
input: {
330-
threadId,
331-
query: debouncedQuery,
332-
...(start ? { start } : {}),
333-
...(offset !== 0 ? { offset } : {}),
334-
skills: skillLabels,
335-
},
336-
})
337-
: null,
338-
[
339-
environmentId,
340-
threadId,
341-
debouncedQuery,
342-
normalizedQuery,
343-
start,
344-
offset,
345-
skillLabels,
346-
progressive,
347-
],
348-
);
349+
const target =
350+
environmentId && threadId && debouncedQuery && normalizedQuery === debouncedQuery
351+
? {
352+
environmentId,
353+
input: {
354+
threadId,
355+
query: debouncedQuery,
356+
...(start ? { start } : {}),
357+
...(offset !== 0 ? { offset } : {}),
358+
skills: skillLabels,
359+
},
360+
}
361+
: null;
362+
const targetKey = target ? JSON.stringify([progressive, target]) : null;
363+
// Resolving the atom only when the request changes keeps unrelated renders from
364+
// touching the atom family.
365+
const [storedAtom, setStoredAtom] = useState(() => ({
366+
key: targetKey,
367+
atom: resolveThreadFindAtom(target, progressive),
368+
}));
369+
let atom = storedAtom.atom;
370+
if (storedAtom.key !== targetKey) {
371+
atom = resolveThreadFindAtom(target, progressive);
372+
setStoredAtom({ key: targetKey, atom });
373+
}
349374
const result = useEnvironmentQuery(atom);
350375
const key = thread
351376
? JSON.stringify([thread.environmentId, thread.threadId, normalizedQuery, skillLabels])
@@ -378,3 +403,13 @@ function useServerResults(
378403
(normalizedQuery !== debouncedQuery || result.isPending),
379404
};
380405
}
406+
407+
function resolveThreadFindAtom(
408+
target: Parameters<typeof orchestrationEnvironment.threadFind>[0] | null,
409+
progressive: boolean,
410+
) {
411+
if (!target) return null;
412+
return progressive
413+
? orchestrationEnvironment.threadFindProgressive(target)
414+
: orchestrationEnvironment.threadFind(target);
415+
}

0 commit comments

Comments
 (0)