perf: memoize FavoritesList and BookmarkNav to prevent re-renders during streaming (#14011)

* perf: memoize FavoritesList and BookmarkNav to prevent streaming re-renders

ConversationsSection re-renders during message streaming as its
conversation-list query and title generation update the cache. Its
FavoritesList and BookmarkNav children were not memoized, so they
re-rendered on every parent commit despite their props and
subscriptions never changing during a stream.

Wrap both in React.memo to insulate them from the parent cascade.
Their props (toggleNav, isSmallScreen, tags, setTags) are referentially
stable, so memo fully decouples them. Add a regression test asserting
FavoritesList does not re-run when its parent re-renders with stable
props.

* test: verify ConversationsSection insulates Favorites/Bookmarks from streaming re-renders

Renders the real ConversationsSection (mocking only data hooks) and
forces repeated re-renders via a subscription it depends on, mirroring
the conversation-list/title-generation cache churn during streaming.
Asserts FavoritesList and BookmarkNav do not re-render, proving the
parent passes referentially stable props so React.memo holds in the
real render path (not just with hand-fed stable props).
This commit is contained in:
Marco Beretta 2026-06-30 17:30:04 +02:00 committed by GitHub
parent 8545af91f2
commit e5d5018d7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 204 additions and 6 deletions

View file

@ -1,10 +1,10 @@
import { useState, useId, useMemo, useCallback } from 'react';
import { useState, useId, useMemo, useCallback, memo } from 'react';
import * as Ariakit from '@ariakit/react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import { DropdownPopup, TooltipAnchor } from '@librechat/client';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import type * as t from '~/common';
import type { FC } from 'react';
import type * as t from '~/common';
import { useGetConversationTags } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -129,4 +129,4 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps)
);
};
export default BookmarkNav;
export default memo(BookmarkNav);

View file

@ -1,4 +1,4 @@
import React, { useRef, useCallback, useMemo, useEffect } from 'react';
import React, { useRef, useCallback, useMemo, useEffect, memo } from 'react';
import { useRecoilValue } from 'recoil';
import { LayoutGrid } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
@ -127,7 +127,7 @@ const DraggableFavoriteItem = ({
);
};
export default function FavoritesList({
function FavoritesList({
isSmallScreen,
toggleNav,
}: {
@ -479,3 +479,7 @@ export default function FavoritesList({
</div>
);
}
FavoritesList.displayName = 'FavoritesList';
export default memo(FavoritesList);

View file

@ -1,5 +1,5 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { render, waitFor, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
@ -165,6 +165,32 @@ describe('FavoritesList', () => {
});
});
describe('memoization', () => {
it('does not re-run when the parent re-renders with stable props (insulated from streaming)', () => {
const stableToggleNav = jest.fn();
const Parent = () => {
const [count, setCount] = React.useState(0);
return (
<>
<button data-testid="force-rerender" onClick={() => setCount((c) => c + 1)}>
{count}
</button>
<FavoritesList isSmallScreen={false} toggleNav={stableToggleNav} />
</>
);
};
renderWithProviders(<Parent />);
const callsAfterMount = mockUseFavorites.mock.calls.length;
expect(callsAfterMount).toBeGreaterThan(0);
fireEvent.click(screen.getByTestId('force-rerender'));
expect(mockUseFavorites).toHaveBeenCalledTimes(callsAfterMount);
});
});
describe('missing agent handling', () => {
it('should exclude missing agents (404) from rendered favorites and render valid agents', async () => {
const validAgent: Agent = {

View file

@ -0,0 +1,168 @@
import React from 'react';
import { DndProvider } from 'react-dnd';
import { BrowserRouter } from 'react-router-dom';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { render, act, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { atom, RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import type { SetterOrUpdater } from 'recoil';
/**
* Real recoil atom used to force ConversationsSection to re-render on demand,
* standing in for the conversation-list / title-generation cache churn that
* happens while a message is streaming. The mocked `useTitleGeneration`
* subscribes to it, so bumping it re-renders ConversationsSection (and only
* ConversationsSection) exactly like a streaming update would.
*/
const streamTickAtom = atom<number>({ key: 'conversations-section-stream-tick', default: 0 });
const mockUseFavorites = jest.fn(() => ({
favorites: [] as unknown[],
reorderFavorites: jest.fn(),
isLoading: false,
}));
const mockUseGetConversationTags = jest.fn(() => ({ data: [] as unknown[] }));
const mockUseTitleGeneration = jest.fn(() => {
useRecoilValue(streamTickAtom);
});
jest.mock('~/store', () => {
const { atom: recoilAtom } = jest.requireActual('recoil');
return {
__esModule: true,
default: {
sidebarExpanded: recoilAtom({ key: 'mock-cs-sidebarExpanded', default: false }),
search: recoilAtom({
key: 'mock-cs-search',
default: { query: '', debouncedQuery: '', enabled: false, isTyping: false },
}),
},
};
});
jest.mock('~/hooks', () => ({
__esModule: true,
useLocalize: () => (key: string) => key,
useHasAccess: () => true,
useAuthContext: () => ({ isAuthenticated: true }),
useLocalStorage: () => [true, jest.fn()],
useNavScrolling: () => ({ moveToTop: jest.fn() }),
useFavorites: () => mockUseFavorites(),
useShowMarketplace: () => false,
useNewConvo: () => ({ newConversation: jest.fn() }),
useGetConversation: () => () => null,
}));
jest.mock('~/data-provider', () => ({
__esModule: true,
useConversationsInfiniteQuery: () => ({
data: { pages: [{ conversations: [], nextCursor: null }] },
fetchNextPage: jest.fn(),
isFetchingNextPage: false,
isLoading: false,
isFetching: false,
}),
useTitleGeneration: () => mockUseTitleGeneration(),
useGetEndpointsQuery: () => ({ data: {}, isLoading: false }),
useGetStartupConfig: () => ({ data: { modelSpecs: { list: [] } } }),
useGetConversationTags: () => mockUseGetConversationTags(),
}));
jest.mock('~/Providers', () => ({
__esModule: true,
useAssistantsMapContext: () => ({}),
useAgentsMapContext: () => ({}),
}));
jest.mock('~/hooks/Input/useSelectMention', () => ({
__esModule: true,
default: () => ({ onSelectEndpoint: jest.fn(), onSelectSpec: jest.fn() }),
}));
jest.mock('~/components/Conversations', () => ({
__esModule: true,
Conversations: () => <div data-testid="conversations-stub" />,
}));
jest.mock('~/components/Conversations/ProjectsSection', () => ({
__esModule: true,
default: () => <div data-testid="projects-stub" />,
}));
jest.mock('~/components/Nav/SearchBar', () => ({
__esModule: true,
default: () => <div data-testid="searchbar-stub" />,
}));
jest.mock('~/components/Nav/Favorites/FavoriteItem', () => ({
__esModule: true,
default: () => <div data-testid="favorite-item-stub" />,
}));
import ConversationsSection from '../ConversationsSection';
let setStreamTick: SetterOrUpdater<number>;
function TickController() {
setStreamTick = useSetRecoilState(streamTickAtom);
return null;
}
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
const renderSection = () =>
render(
<QueryClientProvider client={createQueryClient()}>
<RecoilRoot>
<BrowserRouter>
<DndProvider backend={HTML5Backend}>
<TickController />
<ConversationsSection />
</DndProvider>
</BrowserRouter>
</RecoilRoot>
</QueryClientProvider>,
);
describe('ConversationsSection streaming re-renders', () => {
beforeEach(() => {
mockUseFavorites.mockImplementation(() => ({
favorites: [],
reorderFavorites: jest.fn(),
isLoading: false,
}));
mockUseGetConversationTags.mockImplementation(() => ({ data: [] }));
mockUseTitleGeneration.mockImplementation(() => {
useRecoilValue(streamTickAtom);
});
});
it('does not re-render FavoritesList or BookmarkNav when the section re-renders mid-stream', async () => {
renderSection();
// BookmarkNav is lazy-loaded; wait until it has actually rendered (its own
// data hook firing is the deterministic signal that the chunk resolved).
await waitFor(() => expect(mockUseGetConversationTags).toHaveBeenCalled());
expect(mockUseFavorites.mock.calls.length).toBeGreaterThan(0);
expect(mockUseGetConversationTags.mock.calls.length).toBeGreaterThan(0);
const favBaseline = mockUseFavorites.mock.calls.length;
const tagBaseline = mockUseGetConversationTags.mock.calls.length;
const titleBaseline = mockUseTitleGeneration.mock.calls.length;
// Simulate a stream: repeatedly re-render ConversationsSection.
for (let i = 0; i < 5; i++) {
act(() => {
setStreamTick((prev) => prev + 1);
});
}
// Sanity check: the section genuinely re-rendered each tick.
expect(mockUseTitleGeneration.mock.calls.length).toBeGreaterThan(titleBaseline);
// The memoized children, fed referentially stable props, did not re-render.
expect(mockUseFavorites.mock.calls.length).toBe(favBaseline);
expect(mockUseGetConversationTags.mock.calls.length).toBe(tagBaseline);
});
});