LibreChat/client/src/components/Messages/ContentRender.tsx
Danny Avila b93d60c416
🎞️ refactor: Image Rendering with Preview Caching and Layout Reservation (#12114)
* refactor: Update Image Component to Remove Lazy Loading and Enhance Rendering

- Removed the react-lazy-load-image-component dependency from the Image component, simplifying the image loading process.
- Updated the Image component to use a standard <img> tag with async decoding for improved performance and user experience.
- Adjusted related tests to reflect changes in image rendering behavior and ensure proper functionality without lazy loading.

* refactor: Enhance Image Handling and Caching Across Components

- Introduced a new previewCache utility for managing local blob preview URLs, improving image loading efficiency.
- Updated the Image component and related parts (FileRow, Files, Part, ImageAttachment, LogContent) to utilize cached previews, enhancing rendering performance and user experience.
- Added width and height properties to the Image component for better layout management and consistency across different usages.
- Improved file handling logic in useFileHandling to cache previews during file uploads, ensuring quick access to image data.
- Enhanced overall code clarity and maintainability by streamlining image rendering logic and reducing redundancy.

* refactor: Enhance OpenAIImageGen Component with Image Dimensions

- Added width and height properties to the OpenAIImageGen component for improved image rendering and layout management.
- Updated the Image component usage within OpenAIImageGen to utilize the new dimensions, enhancing visual consistency and performance.
- Improved code clarity by destructuring additional properties from the attachment object, streamlining the component's logic.

* refactor: Implement Image Size Caching in DialogImage Component

- Introduced an imageSizeCache to store and retrieve image sizes, enhancing performance by reducing redundant fetch requests.
- Updated the getImageSize function to first check the cache before making network requests, improving efficiency in image handling.
- Added decoding attribute to the image element for optimized rendering behavior.

* refactor: Enhance UserAvatar Component with Avatar Caching and Error Handling

- Introduced avatar caching logic to optimize avatar resolution based on user ID and avatar source, improving performance and reducing redundant image loads.
- Implemented error handling for failed image loads, allowing for fallback to a default avatar when necessary.
- Updated UserAvatar props to streamline the interface by removing the user object and directly accepting avatar-related properties.
- Enhanced overall code clarity and maintainability by refactoring the component structure and logic.

* fix: Layout Shift in Message and Placeholder Components for Consistent Height Management

- Adjusted the height of the PlaceholderRow and related message components to ensure consistent rendering with a minimum height of 31px.
- Updated the MessageParts and ContentRender components to utilize a minimum height for better layout stability.
- Enhanced overall code clarity by standardizing the structure of message-related components.

* tests: Update FileRow Component to Prefer Cached Previews for Image Rendering

- Modified the image URL selection logic in the FileRow component to prioritize cached previews over file paths when uploads are complete, enhancing rendering performance and user experience.
- Updated related tests to reflect changes in image URL handling, ensuring accurate assertions for both preview and file path scenarios.
- Introduced a fallback mechanism to use file paths when no preview exists, improving robustness in file handling.

* fix: Image cache lifecycle and dialog decoding

- Add deletePreview/clearPreviewCache to previewCache.ts for blob URL cleanup
- Wire deletePreview into useFileDeletion to revoke blobs on file delete
- Move dimensionCache.set into useMemo to avoid side effects during render
- Extract IMAGE_MAX_W_PX constant (512) to document coupling with max-w-lg
- Export _resetImageCaches for test isolation
- Change DialogImage decoding from "sync" to "async" to avoid blocking main thread

* fix: Avatar cache invalidation and cleanup

- Include avatarSrc in cache invalidation to prevent stale avatars
- Remove unused username parameter from resolveAvatar
- Skip caching when userId is empty to prevent cache key collisions

* test: Fix test isolation and type safety

- Reset module-level dimensionCache/paintedUrls in beforeEach via _resetImageCaches
- Replace any[] with typed mock signature in cn mock for both test files

* chore: Code quality improvements from review

- Use barrel imports for previewCache in Files.tsx and Part.tsx
- Single Map.get with truthy check instead of has+get in useEventHandlers
- Add JSDoc comments explaining EmptyText margin removal and PlaceholderRow height
- Fix FileRow toast showing "Deleting file" when file isn't actually deleted (progress < 1)

* fix: Address remaining review findings (R1-R3)

- Add deletePreview calls to deleteFiles batch path to prevent blob URL leaks
- Change useFileDeletion import from deep path to barrel (~/utils)
- Change useMemo to useEffect for dimensionCache.set (side effect, not derived value)

* fix: Address audit comments 2, 5, and 7

- Fix files preservation to distinguish null (missing) from [] (empty) in finalHandler
- Add auto-revoke on overwrite in cachePreview to prevent leaked blobs
- Add removePreviewEntry for key transfer without revoke
- Clean up stale temp_file_id cache entry after promotion to permanent file_id
2026-03-06 19:09:52 -05:00

196 lines
6.1 KiB
TypeScript

import { useCallback, useMemo, memo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, getMessageAriaLabel } from '~/utils';
import { fontSizeAtom } from '~/store/fontSize';
import store from '~/store';
type ContentRenderProps = {
message?: TMessage;
isSubmitting?: boolean;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
>;
const ContentRender = memo(function ContentRender({
message: msg,
siblingIdx,
siblingCount,
setSiblingIdx,
currentEditId,
setCurrentEditId,
isSubmitting = false,
}: ContentRenderProps) {
const localize = useLocalize();
const { attachments, searchResults } = useAttachments({
messageId: msg?.messageId,
attachments: msg?.attachments,
});
const {
edit,
index,
agent,
assistant,
enterEdit,
conversation,
messageLabel,
handleContinue,
handleFeedback,
latestMessageId,
copyToClipboard,
regenerateMessage,
latestMessageDepth,
} = useMessageActions({
message: msg,
searchResults,
currentEditId,
setCurrentEditId,
});
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const isLast = useMemo(
() => !(msg?.children?.length ?? 0) && (msg?.depth === latestMessageDepth || msg?.depth === -1),
[msg?.children, msg?.depth, latestMessageDepth],
);
const hasNoChildren = !(msg?.children?.length ?? 0);
const isLatestMessage = msg?.messageId === latestMessageId;
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const iconData: TMessageIcon = useMemo(
() => ({
endpoint: msg?.endpoint ?? conversation?.endpoint,
model: msg?.model ?? conversation?.model,
iconURL: msg?.iconURL,
modelLabel: messageLabel,
isCreatedByUser: msg?.isCreatedByUser,
}),
[
messageLabel,
conversation?.endpoint,
conversation?.model,
msg?.model,
msg?.iconURL,
msg?.endpoint,
msg?.isCreatedByUser,
],
);
const { hasParallelContent } = useContentMetadata(msg);
if (!msg) {
return null;
}
const getChatWidthClass = () => {
if (maximizeChatSpace) {
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
}
if (hasParallelContent) {
return 'md:max-w-[58rem] xl:max-w-[70rem]';
}
return 'md:max-w-[47rem] xl:max-w-[55rem]';
};
const baseClasses = {
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
chat: getChatWidthClass(),
};
const conditionalClasses = {
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
};
return (
<div
id={msg.messageId}
aria-label={getMessageAriaLabel(msg, localize)}
className={cn(
baseClasses.common,
baseClasses.chat,
conditionalClasses.focus,
'message-render',
)}
>
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
)}
<div
className={cn(
'relative flex flex-col',
hasParallelContent ? 'w-full' : 'w-11/12',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
{!hasParallelContent && (
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
)}
<div className="flex flex-col gap-1">
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
<ContentParts
edit={edit}
isLast={isLast}
enterEdit={enterEdit}
siblingIdx={siblingIdx}
messageId={msg.messageId}
attachments={attachments}
searchResults={searchResults}
setSiblingIdx={setSiblingIdx}
isLatestMessage={isLatestMessage}
isSubmitting={effectiveIsSubmitting}
isCreatedByUser={msg.isCreatedByUser}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>
</div>
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
message={msg}
isEditing={edit}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessageId={latestMessageId}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
</div>
);
});
ContentRender.displayName = 'ContentRender';
export default ContentRender;