From ea643e8c9c5cdda5dd98b7b8fc1fe92170c36673 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 28 Jul 2026 07:32:44 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=97=20fix:=20Render=20Shared=20Links?= =?UTF-8?q?=20Containing=20Steers=20(#14480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔗 fix: Render Shared Links Containing Steers The /share/:shareId route mounts outside AuthContextProvider, so any useAuthContext() on that tree throws and the whole page is replaced by the route error boundary. SteerPart called it directly, and the MessageIcon tree it renders reaches Endpoints/Icon, which called it too - so fixing only the first still died on the icon. Both now read the user atom instead. AuthContextProvider mirrors the user into it, so authenticated rendering is unchanged, and the share route reads undefined rather than throwing. The two crash sites were invisible because the spec mocked both ~/hooks/AuthContext and MessageIcon. Both mocks are gone: the test seeds the atom and renders the real icon tree, with a case covering the share route having neither an auth context nor a user. * fix: sort test imports and assert the real avatar title The worktree has no node_modules, so the lint-staged sort-imports hook never ran on the first commit and CI caught the drift. The icon assertion also used the wrong value: Endpoints/Icon derives the title from user.name ?? user.username, so the seeded user renders 'Danny', not the username. * fix: keep viewer identity off shared steer avatars store.user is app-wide and survives navigation, so a signed-in viewer opening a share link still has an identity in state — reading it for the avatar put the viewer's face on the sharer's steer. The shared branch now renders the generic avatar, mirroring Share/MessageIcon, while the label guard already handled the text. Also fixes the share test, which passed undefined into a defaulted parameter and so seeded a user anyway, testing the signed-in path it claimed to exclude. --- .../Chat/Messages/Content/Parts/SteerPart.tsx | 26 +++++++-- .../Parts/__tests__/SteerPart.test.tsx | 58 ++++++++++++++----- client/src/components/Endpoints/Icon.tsx | 7 ++- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx index 6b3e51b3f6..4361481d54 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx @@ -1,7 +1,7 @@ import { memo, useMemo, useState, useCallback } from 'react'; import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; -import { InfoHoverCard, ESide } from '@librechat/client'; +import { InfoHoverCard, ESide, UserIcon } from '@librechat/client'; import type { TFile, TMessage } from 'librechat-data-provider'; import type { TMessageIcon } from '~/common'; import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog'; @@ -10,7 +10,6 @@ import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import Image from '~/components/Chat/Messages/Content/Image'; -import { useAuthContext } from '~/hooks/AuthContext'; import { fontSizeAtom } from '~/store/fontSize'; import { useShareContext } from '~/Providers'; import { useLocalize } from '~/hooks'; @@ -41,7 +40,9 @@ const SteerPart = memo(function SteerPart({ createdAt?: number; }) { const localize = useLocalize(); - const { user } = useAuthContext(); + /** Read the atom rather than the auth context: AuthContextProvider mirrors the + * user into it, and the public share route mounts outside that provider. */ + const user = useRecoilValue(store.user); const fontSize = useAtomValue(fontSizeAtom); const { isSharedConvo } = useShareContext(); const usernameDisplay = useRecoilValue(store.UsernameDisplay); @@ -90,7 +91,24 @@ const SteerPart = memo(function SteerPart({ >
- + {isSharedConvo === true ? ( + /** The atom still holds the viewer's identity when a signed-in user opens + * a share link, so rendering the identity-bearing avatar here would put + * the viewer's face on the sharer's steer. Mirrors Share/MessageIcon. */ +
+ +
+ ) : ( + + )}
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx index 4c44aeef8a..8f586c18e0 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SteerPart.test.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { TMessage } from 'librechat-data-provider'; import SteerPart from '../SteerPart'; +import store from '~/store'; let mockShareContext: { isSharedConvo?: boolean; shareId?: string } = {}; @@ -10,19 +12,10 @@ jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, })); -jest.mock('~/hooks/AuthContext', () => ({ - useAuthContext: () => ({ user: { name: 'Danny', username: 'danny' } }), -})); - jest.mock('~/Providers', () => ({ useShareContext: () => mockShareContext, })); -jest.mock('~/components/Chat/Messages/MessageIcon', () => ({ - __esModule: true, - default: () =>
, -})); - jest.mock('~/components/Chat/Messages/ui/MessageTimestamp', () => ({ __esModule: true, default: () => null, @@ -53,11 +46,23 @@ jest.mock('~/components/Chat/Messages/Content/Image', () => ({ default: ({ altText }: { altText: string }) => {altText}, })); -function renderPart(files?: TMessage['files']) { +/** Seeds the user atom rather than mocking `useAuthContext`, and renders the real + * MessageIcon tree — mocking either one hid a crash on the share route, where + * neither an auth context nor a user exists. */ +const SEEDED_USER = { name: 'Danny', username: 'danny' }; + +function renderPart( + files?: TMessage['files'], + /** `null` seeds nothing — passing `undefined` would fall back to the default and + * silently test the signed-in state instead of the anonymous share route. */ + user: { name: string; username: string } | null = SEEDED_USER, +) { return render( - - - , + + user && set(store.user, user as never)}> + + + , ); } @@ -72,6 +77,29 @@ describe('SteerPart author label', () => { expect(screen.queryByText('com_user_message')).toBeNull(); }); + it('renders on the share route, where there is no auth context and no user', () => { + /** Regression for #14474: `/share/:shareId` mounts outside AuthContextProvider, + * so any `useAuthContext()` on this tree throws and the whole page is replaced + * by the route error boundary. Both SteerPart and the MessageIcon tree it + * renders used to call it. */ + mockShareContext = { isSharedConvo: true, shareId: 'share-1' }; + + expect(() => renderPart(undefined, null)).not.toThrow(); + expect(screen.getByText('steered words')).toBeInTheDocument(); + expect(screen.getByText('com_user_message')).toBeInTheDocument(); + }); + + it('never renders the viewer identity on a shared steer avatar', () => { + /** The user atom is app-wide and survives navigation, so a signed-in viewer + * opening a share link still has an identity in state. The shared steer must + * show the generic avatar regardless. */ + mockShareContext = { isSharedConvo: true, shareId: 'share-1' }; + renderPart(undefined, SEEDED_USER); + + expect(screen.queryByTitle('Danny')).toBeNull(); + expect(screen.queryByText('Danny')).toBeNull(); + }); + it('labels with the generic user message in the share view, never the viewer identity', () => { mockShareContext = { isSharedConvo: true, shareId: 'share-1' }; renderPart(); @@ -104,7 +132,9 @@ describe('SteerPart presentation', () => { it('presents the steer as a user message with an icon', () => { renderPart(); - expect(screen.getByTestId('user-icon')).toBeInTheDocument(); + /** Asserts the real avatar rather than a stubbed one — the previous mock was + * what hid the auth-context crash inside this icon tree. */ + expect(screen.getByTitle('Danny')).toBeInTheDocument(); expect(screen.getByText('steered words')).toBeInTheDocument(); }); diff --git a/client/src/components/Endpoints/Icon.tsx b/client/src/components/Endpoints/Icon.tsx index fae0f286d3..7de5e39185 100644 --- a/client/src/components/Endpoints/Icon.tsx +++ b/client/src/components/Endpoints/Icon.tsx @@ -1,10 +1,11 @@ import React, { memo } from 'react'; +import { useRecoilValue } from 'recoil'; import { UserIcon, useAvatar } from '@librechat/client'; import type { IconProps } from '~/common'; import MessageEndpointIcon from './MessageEndpointIcon'; -import { useAuthContext } from '~/hooks/AuthContext'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; +import store from '~/store'; type ResolvedAvatar = { type: 'image'; src: string } | { type: 'fallback' }; @@ -101,7 +102,9 @@ const UserAvatar = memo( UserAvatar.displayName = 'UserAvatar'; const Icon: React.FC = memo((props) => { - const { user } = useAuthContext(); + /** Same reason as SteerPart: this renders on the unauthenticated share route, + * where `useAuthContext` throws. The atom is the same value in the app. */ + const user = useRecoilValue(store.user); const { size = 30, isCreatedByUser } = props; const avatarSrc = useAvatar(user);