mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 20:01:35 +00:00
⚡ 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:
parent
8545af91f2
commit
e5d5018d7f
4 changed files with 204 additions and 6 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue