🪝 fix: Safe Hook Fallbacks for Tool-Call Components in Search Route (#12423)

* fix: add useOptionalMessagesOperations hook for context-safe message operations

Add a variant of useMessagesOperations that returns no-op functions
when MessagesViewProvider is absent instead of throwing, enabling
shared components to render safely outside the chat route.

* fix: use optional message operations in ToolCallInfo and UIResourceCarousel

Switch ToolCallInfo and UIResourceCarousel from useMessagesOperations
to useOptionalMessagesOperations so they no longer crash when rendered
in the /search route, which lacks MessagesViewProvider.

* fix: update test mocks to use useOptionalMessagesOperations

* fix: consolidate noops and narrow useMemo dependency in useOptionalMessagesOperations

- Replace three noop variants (noopAsync, noopReturn, noop) with a single
  `const noop = () => undefined` that correctly returns void/undefined
- Destructure individual fields from context before the useMemo so the
  dependency array tracks stable operation references, not the full
  context object (avoiding unnecessary re-renders on unrelated state changes)
- Add useOptionalMessagesConversation for components that need conversation
  data outside MessagesViewProvider

* fix: use optional hooks in MCPUIResource components to prevent search crash

MCPUIResource and MCPUIResourceCarousel render inside Markdown prose and
can appear in the /search route. Switch them from the strict
useMessagesOperations/useMessagesConversation hooks to the optional
variants that return safe defaults when MessagesViewProvider is absent.

* test: update test mocks for optional hook renames

* fix: update ToolCallInfo and UIResourceCarousel test mocks to useOptionalMessagesOperations

* fix: use optional message operations in useConversationUIResources

useConversationUIResources internally called the strict
useMessagesOperations(), which still threw when MCPUIResource rendered
outside MessagesViewProvider. Switch to useOptionalMessagesOperations
so the entire MCPUIResource render chain is safe in the /search route.

* style: fix import order per project conventions

* fix: replace as-unknown-as casts with typed NOOP_OPS stubs

- Define OptionalMessagesOps type and NOOP_OPS constant with properly
  typed no-op functions, eliminating all `as unknown as T` casts
- Use conversationId directly from useOptionalMessagesConversation
  instead of re-deriving it from conversation object
- Update JSDoc to reflect search route support

* test: add no-provider regression tests for optional message hooks

Verify useOptionalMessagesOperations and useOptionalMessagesConversation
return safe defaults when rendered outside MessagesViewProvider, covering
the core crash path this PR fixes.
This commit is contained in:
Danny Avila 2026-03-26 16:40:37 -04:00 committed by GitHub
parent 5e3b7bcde3
commit 083042e56c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 153 additions and 49 deletions

View file

@ -140,6 +140,55 @@ export function useMessagesOperations() {
);
}
type OptionalMessagesOps = Pick<
MessagesViewContextValue,
'ask' | 'regenerate' | 'handleContinue' | 'getMessages' | 'setMessages'
>;
const NOOP_OPS: OptionalMessagesOps = {
ask: () => {},
regenerate: () => {},
handleContinue: () => {},
getMessages: () => undefined,
setMessages: () => {},
};
/**
* Hook for components that need message operations but may render outside MessagesViewProvider
* (e.g. the /search route). Returns no-op stubs when the provider is absent UI actions will
* be silently discarded rather than crashing. Callers must use optional chaining on
* `getMessages()` results, as it returns `undefined` outside the provider.
*/
export function useOptionalMessagesOperations(): OptionalMessagesOps {
const context = useContext(MessagesViewContext);
const ask = context?.ask;
const regenerate = context?.regenerate;
const handleContinue = context?.handleContinue;
const getMessages = context?.getMessages;
const setMessages = context?.setMessages;
return useMemo(
() => ({
ask: ask ?? NOOP_OPS.ask,
regenerate: regenerate ?? NOOP_OPS.regenerate,
handleContinue: handleContinue ?? NOOP_OPS.handleContinue,
getMessages: getMessages ?? NOOP_OPS.getMessages,
setMessages: setMessages ?? NOOP_OPS.setMessages,
}),
[ask, regenerate, handleContinue, getMessages, setMessages],
);
}
/**
* Hook for components that need conversation data but may render outside MessagesViewProvider
* (e.g. the /search route). Returns `undefined` for both fields when the provider is absent.
*/
export function useOptionalMessagesConversation() {
const context = useContext(MessagesViewContext);
const conversation = context?.conversation;
const conversationId = context?.conversationId;
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
}
/** Hook for components that only need message state */
export function useMessagesState() {
const { index, latestMessageId, latestMessageDepth, setLatestMessage } = useMessagesViewContext();

View file

@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import {
useOptionalMessagesOperations,
useOptionalMessagesConversation,
} from '../MessagesViewContext';
describe('useOptionalMessagesOperations', () => {
it('returns noop stubs when rendered outside MessagesViewProvider', () => {
const { result } = renderHook(() => useOptionalMessagesOperations());
expect(result.current.ask).toBeInstanceOf(Function);
expect(result.current.regenerate).toBeInstanceOf(Function);
expect(result.current.handleContinue).toBeInstanceOf(Function);
expect(result.current.getMessages).toBeInstanceOf(Function);
expect(result.current.setMessages).toBeInstanceOf(Function);
});
it('noop stubs do not throw when called', () => {
const { result } = renderHook(() => useOptionalMessagesOperations());
expect(() => result.current.ask({} as never)).not.toThrow();
expect(() => result.current.regenerate({} as never)).not.toThrow();
expect(() => result.current.handleContinue({} as never)).not.toThrow();
expect(() => result.current.setMessages([])).not.toThrow();
});
it('getMessages returns undefined outside the provider', () => {
const { result } = renderHook(() => useOptionalMessagesOperations());
expect(result.current.getMessages()).toBeUndefined();
});
it('returns stable references across re-renders', () => {
const { result, rerender } = renderHook(() => useOptionalMessagesOperations());
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});
describe('useOptionalMessagesConversation', () => {
it('returns undefined fields when rendered outside MessagesViewProvider', () => {
const { result } = renderHook(() => useOptionalMessagesConversation());
expect(result.current.conversation).toBeUndefined();
expect(result.current.conversationId).toBeUndefined();
});
it('returns stable references across re-renders', () => {
const { result, rerender } = renderHook(() => useOptionalMessagesConversation());
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});

View file

@ -3,11 +3,11 @@ import { ChevronDown } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { TAttachment, UIResource } from 'librechat-data-provider';
import { useOptionalMessagesOperations } from '~/Providers';
import { useLocalize, useExpandCollapse } from '~/hooks';
import UIResourceCarousel from './UIResourceCarousel';
import { useMessagesOperations } from '~/Providers';
import { OutputRenderer } from './ToolOutput';
import { handleUIAction, cn } from '~/utils';
import { OutputRenderer } from './ToolOutput';
function isSimpleObject(obj: unknown): obj is Record<string, string | number | boolean | null> {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
@ -102,7 +102,7 @@ export default function ToolCallInfo({
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const { ask } = useMessagesOperations();
const { ask } = useOptionalMessagesOperations();
const [showParams, setShowParams] = useState(false);
const { style: paramsExpandStyle, ref: paramsExpandRef } = useExpandCollapse(showParams);

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
import { useMessagesOperations } from '~/Providers';
import { useOptionalMessagesOperations } from '~/Providers';
import { handleUIAction } from '~/utils';
interface UIResourceCarouselProps {
@ -13,7 +13,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
const [showRightArrow, setShowRightArrow] = useState(true);
const [isContainerHovered, setIsContainerHovered] = useState(false);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const { ask } = useMessagesOperations();
const { ask } = useOptionalMessagesOperations();
const handleScroll = React.useCallback(() => {
if (!scrollContainerRef.current) return;

View file

@ -3,7 +3,11 @@ import { render, screen } from '@testing-library/react';
import Markdown from '../Markdown';
import { RecoilRoot } from 'recoil';
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useLocalize } from '~/hooks';
@ -12,8 +16,8 @@ import { useLocalize } from '~/hooks';
jest.mock('~/Providers', () => ({
...jest.requireActual('~/Providers'),
useMessageContext: jest.fn(),
useMessagesConversation: jest.fn(),
useMessagesOperations: jest.fn(),
useOptionalMessagesConversation: jest.fn(),
useOptionalMessagesOperations: jest.fn(),
}));
jest.mock('~/data-provider');
jest.mock('~/hooks');
@ -26,11 +30,11 @@ jest.mock('@mcp-ui/client', () => ({
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
typeof useOptionalMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
typeof useOptionalMessagesOperations
>;
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
typeof useGetMessagesByConvoId

View file

@ -25,7 +25,7 @@ jest.mock('~/hooks', () => ({
}));
jest.mock('~/Providers', () => ({
useMessagesOperations: () => ({
useOptionalMessagesOperations: () => ({
ask: jest.fn(),
}),
}));

View file

@ -13,10 +13,10 @@ jest.mock('@mcp-ui/client', () => ({
),
}));
// Mock useMessagesOperations hook
// Mock useOptionalMessagesOperations hook
const mockAsk = jest.fn();
jest.mock('~/Providers', () => ({
useMessagesOperations: () => ({
useOptionalMessagesOperations: () => ({
ask: mockAsk,
}),
}));

View file

@ -1,8 +1,8 @@
import React from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import { handleUIAction } from '~/utils';
import { useOptionalMessagesConversation, useOptionalMessagesOperations } from '~/Providers';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useMessagesConversation, useMessagesOperations } from '~/Providers';
import { handleUIAction } from '~/utils';
import { useLocalize } from '~/hooks';
interface MCPUIResourceProps {
@ -13,19 +13,14 @@ interface MCPUIResourceProps {
};
}
/**
* Component that renders an MCP UI resource based on its resource ID.
* Works in both main app and share view.
*/
/** Renders an MCP UI resource based on its resource ID. Works in chat, share, and search views. */
export function MCPUIResource(props: MCPUIResourceProps) {
const { resourceId } = props.node.properties;
const localize = useLocalize();
const { ask } = useMessagesOperations();
const { conversation } = useMessagesConversation();
const { ask } = useOptionalMessagesOperations();
const { conversationId } = useOptionalMessagesConversation();
const conversationResourceMap = useConversationUIResources(
conversation?.conversationId ?? undefined,
);
const conversationResourceMap = useConversationUIResources(conversationId ?? undefined);
const uiResource = conversationResourceMap.get(resourceId ?? '');

View file

@ -1,8 +1,8 @@
import React, { useMemo } from 'react';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useMessagesConversation } from '~/Providers';
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
import type { UIResource } from 'librechat-data-provider';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
import { useOptionalMessagesConversation } from '~/Providers';
interface MCPUIResourceCarouselProps {
node: {
@ -12,16 +12,11 @@ interface MCPUIResourceCarouselProps {
};
}
/**
* Component that renders multiple MCP UI resources in a carousel.
* Works in both main app and share view.
*/
/** Renders multiple MCP UI resources in a carousel. Works in chat, share, and search views. */
export function MCPUIResourceCarousel(props: MCPUIResourceCarouselProps) {
const { conversation } = useMessagesConversation();
const { conversationId } = useOptionalMessagesConversation();
const conversationResourceMap = useConversationUIResources(
conversation?.conversationId ?? undefined,
);
const conversationResourceMap = useConversationUIResources(conversationId ?? undefined);
const uiResources = useMemo(() => {
const { resourceIds = [] } = props.node.properties;

View file

@ -2,7 +2,11 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResource } from '../MCPUIResource';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
import { useLocalize } from '~/hooks';
import { handleUIAction } from '~/utils';
@ -22,11 +26,11 @@ jest.mock('@mcp-ui/client', () => ({
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
typeof useOptionalMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
typeof useOptionalMessagesOperations
>;
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;

View file

@ -2,7 +2,11 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
// Mock dependencies
jest.mock('~/Providers');
@ -19,11 +23,11 @@ jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
typeof useOptionalMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
typeof useOptionalMessagesOperations
>;
describe('MCPUIResourceCarousel', () => {

View file

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { Tools } from 'librechat-data-provider';
import type { TAttachment, UIResource } from 'librechat-data-provider';
import { useMessagesOperations } from '~/Providers';
import { useOptionalMessagesOperations } from '~/Providers';
import store from '~/store';
/**
@ -16,7 +16,7 @@ import store from '~/store';
export function useConversationUIResources(
conversationId: string | undefined,
): Map<string, UIResource> {
const { getMessages } = useMessagesOperations();
const { getMessages } = useOptionalMessagesOperations();
const conversationAttachmentsMap = useRecoilValue(
store.conversationAttachmentsSelector(conversationId),