diff --git a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx index 16a64bb5e0..1a6bc9f1f5 100644 --- a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx +++ b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx @@ -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 = ({ tags, setTags }: BookmarkNavProps) ); }; -export default BookmarkNav; +export default memo(BookmarkNav); diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index a52354ef20..af008595fa 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -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({ ); } + +FavoritesList.displayName = 'FavoritesList'; + +export default memo(FavoritesList); diff --git a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx index 7e61692d51..6d44086cf4 100644 --- a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx +++ b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx @@ -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 ( + <> + + + + ); + }; + + renderWithProviders(); + + 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 = { diff --git a/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx new file mode 100644 index 0000000000..219aea5132 --- /dev/null +++ b/client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx @@ -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({ 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: () =>
, +})); + +jest.mock('~/components/Conversations/ProjectsSection', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('~/components/Nav/SearchBar', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('~/components/Nav/Favorites/FavoriteItem', () => ({ + __esModule: true, + default: () =>
, +})); + +import ConversationsSection from '../ConversationsSection'; + +let setStreamTick: SetterOrUpdater; + +function TickController() { + setStreamTick = useSetRecoilState(streamTickAtom); + return null; +} + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +const renderSection = () => + render( + + + + + + + + + + , + ); + +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); + }); +});