Skip to content

Commit 4b7798a

Browse files
memoize all of the things
1 parent da3c93e commit 4b7798a

10 files changed

Lines changed: 88 additions & 51 deletions

File tree

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
"embla-carousel-auto-scroll": "^8.3.0",
138138
"embla-carousel-react": "^8.3.0",
139139
"escape-string-regexp": "^5.0.0",
140+
"fast-deep-equal": "^3.1.3",
140141
"fuse.js": "^7.0.0",
141142
"google-auth-library": "^10.1.0",
142143
"graphql": "^16.9.0",

packages/web/src/features/chat/components/chatBox/chatBox.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { insertMention, slateContentToString } from "@/features/chat/utils";
88
import { cn, IS_MAC } from "@/lib/utils";
99
import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react";
1010
import { ArrowUp, Loader2, StopCircleIcon, TriangleAlertIcon } from "lucide-react";
11-
import { Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
11+
import { Fragment, KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
1212
import { useHotkeys } from "react-hotkeys-hook";
1313
import { Descendant, insertText } from "slate";
1414
import { Editable, ReactEditor, RenderElementProps, RenderLeafProps, useFocused, useSelected, useSlate } from "slate-react";
@@ -19,6 +19,7 @@ import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery";
1919
import { useSuggestionsData } from "./useSuggestionsData";
2020
import { useToast } from "@/components/hooks/use-toast";
2121
import { SearchContextQuery } from "@/lib/types";
22+
import isEqual from "fast-deep-equal/react";
2223

2324
interface ChatBoxProps {
2425
onSubmit: (children: Descendant[], editor: CustomEditor) => void;
@@ -34,7 +35,7 @@ interface ChatBoxProps {
3435
onContextSelectorOpenChanged: (isOpen: boolean) => void;
3536
}
3637

37-
export const ChatBox = ({
38+
const ChatBoxComponent = ({
3839
onSubmit: _onSubmit,
3940
onStop,
4041
preferredSuggestionsBoxPlacement = "bottom-start",
@@ -368,6 +369,8 @@ export const ChatBox = ({
368369
)
369370
}
370371

372+
export const ChatBox = memo(ChatBoxComponent, isEqual);
373+
371374
const DefaultElement = (props: RenderElementProps) => {
372375
return <p {...props.attributes}>{props.children}</p>
373376
}

packages/web/src/features/chat/components/chatThread/answerCard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { isServiceError } from "@/lib/utils";
1717
import useCaptureEvent from "@/hooks/useCaptureEvent";
1818
import { LangfuseWeb } from "langfuse";
1919
import { env } from "@sourcebot/shared/client";
20+
import isEqual from "fast-deep-equal/react";
2021

2122
interface AnswerCardProps {
2223
answerText: string;
@@ -178,4 +179,4 @@ const AnswerCardComponent = forwardRef<HTMLDivElement, AnswerCardProps>(({
178179

179180
AnswerCardComponent.displayName = 'AnswerCard';
180181

181-
export const AnswerCard = memo(AnswerCardComponent);
182+
export const AnswerCard = memo(AnswerCardComponent, isEqual);

packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import { CSSProperties, forwardRef, memo, useCallback, useEffect, useMemo, useRe
88
import scrollIntoView from 'scroll-into-view-if-needed';
99
import { Reference, referenceSchema, SBChatMessage, Source } from "../../types";
1010
import { useExtractReferences } from '../../useExtractReferences';
11-
import { getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences } from '../../utils';
11+
import { getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences, tryResolveFileReference } from '../../utils';
1212
import { AnswerCard } from './answerCard';
1313
import { DetailsCard } from './detailsCard';
1414
import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer';
1515
import { ReferencedSourcesListView } from './referencedSourcesListView';
1616
import { uiVisiblePartTypes } from '../../constants';
17+
import isEqual from "fast-deep-equal/react";
1718

1819
interface ChatThreadListItemProps {
1920
userMessage: SBChatMessage;
@@ -32,7 +33,6 @@ export const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThread
3233
chatId,
3334
index,
3435
}, ref) => {
35-
console.log(`re-rendering chat thread list item`, index);
3636
const leftPanelRef = useRef<HTMLDivElement>(null);
3737
const [leftPanelHeight, setLeftPanelHeight] = useState<number | null>(null);
3838
const answerRef = useRef<HTMLDivElement>(null);
@@ -81,7 +81,6 @@ export const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThread
8181
return getAnswerPartFromAssistantMessage(assistantMessage, isStreaming);
8282
}, [assistantMessage, isStreaming]);
8383

84-
const references = useExtractReferences(answerPart);
8584

8685
// Groups parts into steps that are associated with thinking steps that
8786
// should be visible to the user. By "steps", we mean parts that originated
@@ -280,6 +279,26 @@ export const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThread
280279
};
281280
}, [hoveredReference]);
282281

282+
const references = useExtractReferences(answerPart);
283+
284+
// Extract the file sources that are referenced by the answer part.
285+
const referencedFileSources = useMemo(() => {
286+
const fileSources = sources.filter((source) => source.type === 'file');
287+
288+
return references
289+
.filter((reference) => reference.type === 'file')
290+
.map((reference) => tryResolveFileReference(reference, fileSources))
291+
.filter((file) => file !== undefined)
292+
// de-duplicate files
293+
.filter((file, index, self) =>
294+
index === self.findIndex((t) =>
295+
t?.path === file?.path
296+
&& t?.repo === file?.repo
297+
&& t?.revision === file?.revision
298+
)
299+
);
300+
}, [references, sources]);
301+
283302

284303
return (
285304
<div
@@ -365,11 +384,11 @@ export const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThread
365384
<div
366385
className="sticky top-0"
367386
>
368-
{references.length > 0 ? (
387+
{referencedFileSources.length > 0 ? (
369388
<ReferencedSourcesListView
370389
index={index}
371390
references={references}
372-
sources={sources}
391+
sources={referencedFileSources}
373392
hoveredReference={hoveredReference}
374393
selectedReference={selectedReference}
375394
onSelectedReferenceChanged={setSelectedReference}
@@ -396,10 +415,32 @@ export const ChatThreadListItemComponent = forwardRef<HTMLDivElement, ChatThread
396415

397416
ChatThreadListItemComponent.displayName = 'ChatThreadListItem';
398417

399-
// Only allow re-rendering when the message _is_ streaming.
400-
// This is a performance optimizations to prevent unnecessary
401-
// re-renders for chatThreadListItems that are not streaming.
402-
export const ChatThreadListItem = memo(ChatThreadListItemComponent, (_, nextProps) => !nextProps.isStreaming);
418+
// Custom comparison function that handles the known issue where useChat mutates
419+
// message objects in place during streaming, causing fast-deep-equal to return
420+
// true even when content changes (because it checks reference equality first).
421+
// See: https://github.com/vercel/ai/issues/6466
422+
const arePropsEqual = (
423+
prevProps: ChatThreadListItemProps,
424+
nextProps: ChatThreadListItemProps
425+
): boolean => {
426+
// Always re-render if streaming status changes
427+
if (prevProps.isStreaming !== nextProps.isStreaming) {
428+
return false;
429+
}
430+
431+
// If currently streaming, always allow re-render
432+
// This bypasses the fast-deep-equal reference check issue when useChat
433+
// mutates message objects in place during token streaming
434+
if (nextProps.isStreaming) {
435+
return false;
436+
}
437+
438+
// For non-streaming messages, use deep equality
439+
// At this point, useChat should have finished and created final objects
440+
return isEqual(prevProps, nextProps);
441+
};
442+
443+
export const ChatThreadListItem = memo(ChatThreadListItemComponent, arePropsEqual);
403444

404445
// Finds the nearest reference element to the viewport center.
405446
const getNearestReferenceElement = (referenceElements: Element[]) => {

packages/web/src/features/chat/components/chatThread/detailsCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { SearchReposToolComponent } from './tools/searchReposToolComponent';
1717
import { ListAllReposToolComponent } from './tools/listAllReposToolComponent';
1818
import { SBChatMessageMetadata, SBChatMessagePart } from '../../types';
1919
import { SearchScopeIcon } from '../searchScopeIcon';
20+
import isEqual from "fast-deep-equal/react";
2021

2122

2223
interface DetailsCardProps {
@@ -36,7 +37,6 @@ const DetailsCardComponent = ({
3637
metadata,
3738
thinkingSteps,
3839
}: DetailsCardProps) => {
39-
4040
return (
4141
<Card className="mb-4">
4242
<Collapsible open={isExpanded} onOpenChange={onExpandedChanged}>
@@ -212,4 +212,4 @@ const DetailsCardComponent = ({
212212
)
213213
}
214214

215-
export const DetailsCard = memo(DetailsCardComponent);
215+
export const DetailsCard = memo(DetailsCardComponent, isEqual);

packages/web/src/features/chat/components/chatThread/markdownRenderer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { CodeBlock } from './codeBlock';
2020
import { FILE_REFERENCE_REGEX } from '@/features/chat/constants';
2121
import { createFileReference } from '@/features/chat/utils';
2222
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';
23+
import isEqual from "fast-deep-equal/react";
2324

2425
export const REFERENCE_PAYLOAD_ATTRIBUTE = 'data-reference-payload';
2526

@@ -221,4 +222,4 @@ const MarkdownRendererComponent = forwardRef<HTMLDivElement, MarkdownRendererPro
221222

222223
MarkdownRendererComponent.displayName = 'MarkdownRenderer';
223224

224-
export const MarkdownRenderer = memo(MarkdownRendererComponent);
225+
export const MarkdownRenderer = memo(MarkdownRendererComponent, isEqual);

packages/web/src/features/chat/components/chatThread/referencedFileSourceListItem.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createCodeFoldingExtension } from "./codeFoldingExtension";
2020
import useCaptureEvent from "@/hooks/useCaptureEvent";
2121
import { CodeHostType } from "@sourcebot/db";
2222
import { createAuditAction } from "@/ee/features/audit/actions";
23+
import isEqual from "fast-deep-equal/react";
2324

2425
const lineDecoration = Decoration.line({
2526
attributes: { class: "cm-range-border-radius chat-lineHighlight" },
@@ -355,6 +356,6 @@ const ReferencedFileSourceListItem = ({
355356
)
356357
}
357358

358-
export default memo(forwardRef(ReferencedFileSourceListItem)) as (
359+
export default memo(forwardRef(ReferencedFileSourceListItem), isEqual) as (
359360
props: ReferencedFileSourceListItemProps & { ref?: Ref<ReactCodeMirrorRef> },
360361
) => ReturnType<typeof ReferencedFileSourceListItem>;

packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import { useQueries } from "@tanstack/react-query";
99
import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
1010
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
1111
import scrollIntoView from 'scroll-into-view-if-needed';
12-
import { FileReference, FileSource, Reference, Source } from "../../types";
12+
import { FileReference, FileSource, Reference } from "../../types";
13+
import { tryResolveFileReference } from '../../utils';
1314
import ReferencedFileSourceListItem from "./referencedFileSourceListItem";
15+
import isEqual from 'fast-deep-equal/react';
1416

1517
interface ReferencedSourcesListViewProps {
1618
references: FileReference[];
17-
sources: Source[];
19+
sources: FileSource[];
1820
index: number;
1921
hoveredReference?: Reference;
2022
onHoveredReferenceChanged: (reference?: Reference) => void;
@@ -23,13 +25,6 @@ interface ReferencedSourcesListViewProps {
2325
style: React.CSSProperties;
2426
}
2527

26-
const resolveFileReference = (reference: FileReference, sources: FileSource[]): FileSource | undefined => {
27-
return sources.find(
28-
(source) => source.repo.endsWith(reference.repo) &&
29-
source.path.endsWith(reference.path)
30-
);
31-
}
32-
3328
const ReferencedSourcesListViewComponent = ({
3429
references,
3530
sources,
@@ -59,43 +54,26 @@ const ReferencedSourcesListViewComponent = ({
5954
}
6055
}, []);
6156

62-
const referencedFileSources = useMemo((): FileSource[] => {
63-
const fileSources = sources.filter((source) => source.type === 'file');
64-
65-
return references
66-
.filter((reference) => reference.type === 'file')
67-
.map((reference) => resolveFileReference(reference, fileSources))
68-
.filter((file) => file !== undefined)
69-
// de-duplicate files
70-
.filter((file, index, self) =>
71-
index === self.findIndex((t) =>
72-
t?.path === file?.path
73-
&& t?.repo === file?.repo
74-
&& t?.revision === file?.revision
75-
)
76-
);
77-
}, [references, sources]);
78-
7957
// Memoize the computation of references grouped by file source
8058
const referencesGroupedByFile = useMemo(() => {
8159
const groupedReferences = new Map<string, FileReference[]>();
8260

83-
for (const fileSource of referencedFileSources) {
61+
for (const fileSource of sources) {
8462
const fileKey = getFileId(fileSource);
8563
const referencesInFile = references.filter((reference) => {
8664
if (reference.type !== 'file') {
8765
return false;
8866
}
89-
return resolveFileReference(reference, [fileSource]) !== undefined;
67+
return tryResolveFileReference(reference, [fileSource]) !== undefined;
9068
});
9169
groupedReferences.set(fileKey, referencesInFile);
9270
}
9371

9472
return groupedReferences;
95-
}, [references, referencedFileSources, getFileId]);
73+
}, [references, sources, getFileId]);
9674

9775
const fileSourceQueries = useQueries({
98-
queries: referencedFileSources.map((file) => ({
76+
queries: sources.map((file) => ({
9977
queryKey: ['fileSource', file.path, file.repo, file.revision],
10078
queryFn: () => unwrapServiceError(getFileSource({
10179
fileName: file.path,
@@ -112,7 +90,7 @@ const ReferencedSourcesListViewComponent = ({
11290
return;
11391
}
11492

115-
const fileSource = resolveFileReference(selectedReference, referencedFileSources);
93+
const fileSource = tryResolveFileReference(selectedReference, sources);
11694
if (!fileSource) {
11795
return;
11896
}
@@ -179,7 +157,7 @@ const ReferencedSourcesListViewComponent = ({
179157
behavior: 'smooth',
180158
});
181159
}
182-
}, [getFileId, referencedFileSources, selectedReference]);
160+
}, [getFileId, sources, selectedReference]);
183161

184162
const onExpandedChanged = useCallback((fileId: string, isExpanded: boolean) => {
185163
if (isExpanded) {
@@ -200,7 +178,7 @@ const ReferencedSourcesListViewComponent = ({
200178
}
201179
}, []);
202180

203-
if (referencedFileSources.length === 0) {
181+
if (sources.length === 0) {
204182
return (
205183
<div className="p-4 text-center text-muted-foreground text-sm">
206184
No file references found
@@ -215,7 +193,7 @@ const ReferencedSourcesListViewComponent = ({
215193
>
216194
<div className="space-y-4 pr-2">
217195
{fileSourceQueries.map((query, index) => {
218-
const fileSource = referencedFileSources[index];
196+
const fileSource = sources[index];
219197
const fileName = fileSource.path.split('/').pop() ?? fileSource.path;
220198

221199
if (query.isLoading) {
@@ -280,4 +258,4 @@ const ReferencedSourcesListViewComponent = ({
280258
}
281259

282260
// Memoize to prevent unnecessary re-renders
283-
export const ReferencedSourcesListView = memo(ReferencedSourcesListViewComponent);
261+
export const ReferencedSourcesListView = memo(ReferencedSourcesListViewComponent, isEqual);

packages/web/src/features/chat/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,13 @@ export const buildSearchQuery = (options: {
374374
export const getLanguageModelKey = (model: LanguageModelInfo) => {
375375
return `${model.provider}-${model.model}-${model.displayName}`;
376376
}
377+
378+
/**
379+
* Given a file reference and a list of file sources, attempts to resolve the file source that the reference points to.
380+
*/
381+
export const tryResolveFileReference = (reference: FileReference, sources: FileSource[]): FileSource | undefined => {
382+
return sources.find(
383+
(source) => source.repo.endsWith(reference.repo) &&
384+
source.path.endsWith(reference.path)
385+
);
386+
}

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8197,6 +8197,7 @@ __metadata:
81978197
eslint-config-next: "npm:15.5.0"
81988198
eslint-plugin-react: "npm:^7.37.5"
81998199
eslint-plugin-react-hooks: "npm:^5.2.0"
8200+
fast-deep-equal: "npm:^3.1.3"
82008201
fuse.js: "npm:^7.0.0"
82018202
google-auth-library: "npm:^10.1.0"
82028203
graphql: "npm:^16.9.0"

0 commit comments

Comments
 (0)