From e5d5018d7f556432ff190868e00e6e7933c5580f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:30:04 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf:=20memoize=20FavoritesList=20a?= =?UTF-8?q?nd=20BookmarkNav=20to=20prevent=20re-renders=20during=20streami?= =?UTF-8?q?ng=20(#14011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- .../components/Nav/Bookmarks/BookmarkNav.tsx | 6 +- .../Nav/Favorites/FavoritesList.tsx | 8 +- .../Favorites/tests/FavoritesList.spec.tsx | 28 ++- .../__tests__/ConversationsSection.spec.tsx | 168 ++++++++++++++++++ 4 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 client/src/components/UnifiedSidebar/__tests__/ConversationsSection.spec.tsx 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); + }); +});