mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 22:37:20 +02:00
🎞️ 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
This commit is contained in:
parent
6d0938be64
commit
b93d60c416
26 changed files with 390 additions and 247 deletions
|
|
@ -4,6 +4,8 @@ import { Button, TooltipAnchor } from '@librechat/client';
|
|||
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const imageSizeCache = new Map<string, string>();
|
||||
|
||||
const getQualityStyles = (quality: string): string => {
|
||||
if (quality === 'high') {
|
||||
return 'bg-green-100 text-green-800';
|
||||
|
|
@ -50,18 +52,26 @@ export default function DialogImage({
|
|||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const getImageSize = useCallback(async (url: string) => {
|
||||
const cached = imageSizeCache.get(url);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
|
||||
if (contentLength) {
|
||||
const bytes = parseInt(contentLength, 10);
|
||||
return formatFileSize(bytes);
|
||||
const result = formatFileSize(bytes);
|
||||
imageSizeCache.set(url, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const fullResponse = await fetch(url);
|
||||
const blob = await fullResponse.blob();
|
||||
return formatFileSize(blob.size);
|
||||
const result = formatFileSize(blob.size);
|
||||
imageSizeCache.set(url, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error getting image size:', error);
|
||||
return null;
|
||||
|
|
@ -355,6 +365,7 @@ export default function DialogImage({
|
|||
ref={imageRef}
|
||||
src={src}
|
||||
alt="Image"
|
||||
decoding="async"
|
||||
className="block max-h-[85vh] object-contain"
|
||||
style={{
|
||||
maxWidth: getImageMaxWidth(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, memo } from 'react';
|
||||
import type { TFile, TMessage } from 'librechat-data-provider';
|
||||
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||
import { getCachedPreview } from '~/utils';
|
||||
import Image from './Image';
|
||||
|
||||
const Files = ({ message }: { message?: TMessage }) => {
|
||||
|
|
@ -17,13 +18,18 @@ const Files = ({ message }: { message?: TMessage }) => {
|
|||
{otherFiles.length > 0 &&
|
||||
otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)}
|
||||
{imageFiles.length > 0 &&
|
||||
imageFiles.map((file) => (
|
||||
<Image
|
||||
key={file.file_id}
|
||||
imagePath={file.preview ?? file.filepath ?? ''}
|
||||
altText={file.filename ?? 'Uploaded Image'}
|
||||
/>
|
||||
))}
|
||||
imageFiles.map((file) => {
|
||||
const cached = file.file_id ? getCachedPreview(file.file_id) : undefined;
|
||||
return (
|
||||
<Image
|
||||
key={file.file_id}
|
||||
width={file.width}
|
||||
height={file.height}
|
||||
altText={file.filename ?? 'Uploaded Image'}
|
||||
imagePath={cached ?? file.preview ?? file.filepath ?? ''}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,18 +1,36 @@
|
|||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { Skeleton } from '@librechat/client';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import { apiBaseUrl } from 'librechat-data-provider';
|
||||
import { cn } from '~/utils';
|
||||
import DialogImage from './DialogImage';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/** Max display height for chat images (Tailwind JIT class) */
|
||||
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
|
||||
export const IMAGE_MAX_H = 'max-h-[45vh]' as const;
|
||||
/** Matches the `max-w-lg` Tailwind class on the wrapper button (32rem = 512px at 16px base) */
|
||||
const IMAGE_MAX_W_PX = 512;
|
||||
|
||||
/** Caches image dimensions by src so remounts can reserve space */
|
||||
const dimensionCache = new Map<string, { width: number; height: number }>();
|
||||
/** Tracks URLs that have been fully painted — skip skeleton on remount */
|
||||
const paintedUrls = new Set<string>();
|
||||
|
||||
/** Test-only: resets module-level caches */
|
||||
export function _resetImageCaches(): void {
|
||||
dimensionCache.clear();
|
||||
paintedUrls.clear();
|
||||
}
|
||||
|
||||
function computeHeightStyle(w: number, h: number): React.CSSProperties {
|
||||
return { height: `min(45vh, ${(h / w) * 100}vw, ${(h / w) * IMAGE_MAX_W_PX}px)` };
|
||||
}
|
||||
|
||||
const Image = ({
|
||||
imagePath,
|
||||
altText,
|
||||
className,
|
||||
args,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
imagePath: string;
|
||||
altText: string;
|
||||
|
|
@ -24,18 +42,15 @@ const Image = ({
|
|||
style?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleImageLoad = () => setIsLoaded(true);
|
||||
|
||||
// Fix image path to include base path for subdirectory deployments
|
||||
const absoluteImageUrl = useMemo(() => {
|
||||
if (!imagePath) return imagePath;
|
||||
|
||||
// If it's already an absolute URL or doesn't start with /images/, return as is
|
||||
if (
|
||||
imagePath.startsWith('http') ||
|
||||
imagePath.startsWith('data:') ||
|
||||
|
|
@ -44,7 +59,6 @@ const Image = ({
|
|||
return imagePath;
|
||||
}
|
||||
|
||||
// Get the base URL and prepend it to the image path
|
||||
const baseURL = apiBaseUrl();
|
||||
return `${baseURL}${imagePath}`;
|
||||
}, [imagePath]);
|
||||
|
|
@ -78,6 +92,17 @@ const Image = ({
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (width && height && absoluteImageUrl) {
|
||||
dimensionCache.set(absoluteImageUrl, { width, height });
|
||||
}
|
||||
}, [absoluteImageUrl, width, height]);
|
||||
|
||||
const dims = width && height ? { width, height } : dimensionCache.get(absoluteImageUrl);
|
||||
const hasDimensions = !!(dims?.width && dims?.height);
|
||||
const heightStyle = hasDimensions ? computeHeightStyle(dims.width, dims.height) : undefined;
|
||||
const showSkeleton = hasDimensions && !paintedUrls.has(absoluteImageUrl);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
|
|
@ -87,40 +112,33 @@ const Image = ({
|
|||
aria-haspopup="dialog"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={cn(
|
||||
'relative mt-1 flex h-auto w-full max-w-lg cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
|
||||
'relative mt-1 w-full max-w-lg cursor-pointer overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
|
||||
className,
|
||||
)}
|
||||
style={heightStyle}
|
||||
>
|
||||
<LazyLoadImage
|
||||
{showSkeleton && <Skeleton className="absolute inset-0" aria-hidden="true" />}
|
||||
<img
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'block h-auto w-auto max-w-full text-transparent transition-opacity duration-100',
|
||||
IMAGE_MAX_H,
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={absoluteImageUrl}
|
||||
placeholder={
|
||||
<Skeleton
|
||||
className={cn(IMAGE_MAX_H, 'h-48 w-full max-w-lg')}
|
||||
aria-label="Loading image"
|
||||
aria-busy="true"
|
||||
/>
|
||||
}
|
||||
onLoad={() => paintedUrls.add(absoluteImageUrl)}
|
||||
className={cn(
|
||||
'relative block text-transparent',
|
||||
hasDimensions
|
||||
? 'size-full object-contain'
|
||||
: cn('h-auto w-auto max-w-full', IMAGE_MAX_H),
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isLoaded && (
|
||||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={absoluteImageUrl}
|
||||
downloadImage={downloadImage}
|
||||
args={args}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
)}
|
||||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={absoluteImageUrl}
|
||||
downloadImage={downloadImage}
|
||||
args={args}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'
|
|||
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
||||
import { ErrorMessage } from './MessageContent';
|
||||
import RetrievalCall from './RetrievalCall';
|
||||
import { getCachedPreview } from '~/utils';
|
||||
import AgentHandoff from './AgentHandoff';
|
||||
import CodeAnalyze from './CodeAnalyze';
|
||||
import Container from './Container';
|
||||
|
|
@ -222,8 +223,14 @@ const Part = memo(function Part({
|
|||
}
|
||||
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
||||
const imageFile = part[ContentTypes.IMAGE_FILE];
|
||||
const cached = imageFile.file_id ? getCachedPreview(imageFile.file_id) : undefined;
|
||||
return (
|
||||
<Image imagePath={imageFile.filepath} altText={imageFile.filename ?? 'Uploaded Image'} />
|
||||
<Image
|
||||
imagePath={cached ?? imageFile.filepath}
|
||||
altText={imageFile.filename ?? 'Uploaded Image'}
|
||||
width={imageFile.width}
|
||||
height={imageFile.height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
|
|||
|
||||
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
|
|
@ -76,6 +76,8 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
|||
<Image
|
||||
altText={attachment.filename || 'attachment image'}
|
||||
imagePath={filepath ?? ''}
|
||||
width={width}
|
||||
height={height}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */
|
||||
const EmptyTextPart = memo(() => {
|
||||
return (
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
|||
)}
|
||||
{imageAttachments?.map((attachment) => (
|
||||
<Image
|
||||
width={attachment.width}
|
||||
height={attachment.height}
|
||||
key={attachment.filepath}
|
||||
altText={attachment.filename}
|
||||
imagePath={attachment.filepath}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@ export default function OpenAIImageGen({
|
|||
}
|
||||
|
||||
const attachment = attachments?.[0];
|
||||
const { filepath = null, filename = '' } = (attachment as TFile & TAttachmentMetadata) || {};
|
||||
const {
|
||||
filepath = null,
|
||||
filename = '',
|
||||
width: imgWidth,
|
||||
height: imgHeight,
|
||||
} = (attachment as TFile & TAttachmentMetadata) || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting) {
|
||||
|
|
@ -120,9 +125,11 @@ export default function OpenAIImageGen({
|
|||
<div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
|
||||
{progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
|
||||
<Image
|
||||
width={imgWidth}
|
||||
args={parsedArgs}
|
||||
height={imgHeight}
|
||||
altText={filename}
|
||||
imagePath={filepath ?? ''}
|
||||
args={parsedArgs}
|
||||
className={progress < 1 ? 'invisible absolute' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Image from '../Image';
|
||||
import Image, { _resetImageCaches } from '../Image';
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: unknown[]) =>
|
||||
cn: (...classes: (string | boolean | undefined | null)[]) =>
|
||||
classes
|
||||
.flat(Infinity)
|
||||
.filter((c) => typeof c === 'string' && c.length > 0)
|
||||
.filter((c): c is string => typeof c === 'string' && c.length > 0)
|
||||
.join(' '),
|
||||
}));
|
||||
|
||||
|
|
@ -14,38 +14,6 @@ jest.mock('librechat-data-provider', () => ({
|
|||
apiBaseUrl: () => '',
|
||||
}));
|
||||
|
||||
jest.mock('react-lazy-load-image-component', () => ({
|
||||
LazyLoadImage: ({
|
||||
alt,
|
||||
src,
|
||||
className,
|
||||
onLoad,
|
||||
placeholder,
|
||||
visibleByDefault: _visibleByDefault,
|
||||
...rest
|
||||
}: {
|
||||
alt: string;
|
||||
src: string;
|
||||
className: string;
|
||||
onLoad: () => void;
|
||||
placeholder: React.ReactNode;
|
||||
visibleByDefault?: boolean;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div data-testid="lazy-image-wrapper">
|
||||
<img
|
||||
alt={alt}
|
||||
src={src}
|
||||
className={className}
|
||||
onLoad={onLoad}
|
||||
data-testid="lazy-image"
|
||||
{...rest}
|
||||
/>
|
||||
<div data-testid="placeholder">{placeholder}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Skeleton: ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div data-testid="skeleton" className={className} {...props} />
|
||||
|
|
@ -65,45 +33,84 @@ describe('Image', () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
_resetImageCaches();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
describe('rendering without dimensions', () => {
|
||||
it('renders with max-h-[45vh] height constraint', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const img = screen.getByRole('img');
|
||||
expect(img.className).toContain('max-h-[45vh]');
|
||||
});
|
||||
|
||||
it('renders with max-w-full to prevent landscape clipping', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const img = screen.getByRole('img');
|
||||
expect(img.className).toContain('max-w-full');
|
||||
});
|
||||
|
||||
it('renders with w-auto and h-auto for natural aspect ratio', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const img = screen.getByRole('img');
|
||||
expect(img.className).toContain('w-auto');
|
||||
expect(img.className).toContain('h-auto');
|
||||
});
|
||||
|
||||
it('starts with opacity-0 before image loads', () => {
|
||||
it('does not show skeleton without dimensions', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
expect(img.className).toContain('opacity-0');
|
||||
expect(img.className).not.toContain('opacity-100');
|
||||
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('transitions to opacity-100 after image loads', () => {
|
||||
it('does not apply heightStyle without dimensions', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.style.height).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering with dimensions', () => {
|
||||
it('shows skeleton behind image', () => {
|
||||
render(<Image {...defaultProps} width={1024} height={1792} />);
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies computed heightStyle to button', () => {
|
||||
render(<Image {...defaultProps} width={1024} height={1792} />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.style.height).toBeTruthy();
|
||||
expect(button.style.height).toContain('min(45vh');
|
||||
});
|
||||
|
||||
it('uses size-full object-contain on image when dimensions provided', () => {
|
||||
render(<Image {...defaultProps} width={768} height={916} />);
|
||||
const img = screen.getByRole('img');
|
||||
expect(img.className).toContain('size-full');
|
||||
expect(img.className).toContain('object-contain');
|
||||
});
|
||||
|
||||
it('skeleton is absolute inset-0', () => {
|
||||
render(<Image {...defaultProps} width={512} height={512} />);
|
||||
const skeleton = screen.getByTestId('skeleton');
|
||||
expect(skeleton.className).toContain('absolute');
|
||||
expect(skeleton.className).toContain('inset-0');
|
||||
});
|
||||
|
||||
it('marks URL as painted on load and skips skeleton on rerender', () => {
|
||||
const { rerender } = render(<Image {...defaultProps} width={512} height={512} />);
|
||||
const img = screen.getByRole('img');
|
||||
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(img);
|
||||
|
||||
expect(img.className).toContain('opacity-100');
|
||||
// Rerender same component — skeleton should not show (URL painted)
|
||||
rerender(<Image {...defaultProps} width={512} height={512} />);
|
||||
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('common behavior', () => {
|
||||
it('applies custom className to the button wrapper', () => {
|
||||
render(<Image {...defaultProps} className="mb-4" />);
|
||||
const button = screen.getByRole('button');
|
||||
|
|
@ -112,57 +119,9 @@ describe('Image', () => {
|
|||
|
||||
it('sets correct alt text', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('alt', 'Test image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skeleton placeholder', () => {
|
||||
it('renders skeleton with non-zero dimensions', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const skeleton = screen.getByTestId('skeleton');
|
||||
expect(skeleton.className).toContain('h-48');
|
||||
expect(skeleton.className).toContain('w-full');
|
||||
expect(skeleton.className).toContain('max-w-lg');
|
||||
});
|
||||
|
||||
it('renders skeleton with max-h constraint', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const skeleton = screen.getByTestId('skeleton');
|
||||
expect(skeleton.className).toContain('max-h-[45vh]');
|
||||
});
|
||||
|
||||
it('has accessible loading attributes', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
const skeleton = screen.getByTestId('skeleton');
|
||||
expect(skeleton).toHaveAttribute('aria-label', 'Loading image');
|
||||
expect(skeleton).toHaveAttribute('aria-busy', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialog interaction', () => {
|
||||
it('opens dialog on button click after image loads', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
fireEvent.load(img);
|
||||
|
||||
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render dialog before image loads', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct accessibility attributes on button', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
|
|
@ -172,28 +131,48 @@ describe('Image', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('dialog interaction', () => {
|
||||
it('opens dialog on button click', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dialog is always mounted (not gated by load state)', () => {
|
||||
render(<Image {...defaultProps} />);
|
||||
// DialogImage mock returns null when isOpen=false, but the component is in the tree
|
||||
// Clicking should immediately show it
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('image URL resolution', () => {
|
||||
it('passes /images/ paths through with base URL', () => {
|
||||
render(<Image {...defaultProps} imagePath="/images/test.png" />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', '/images/test.png');
|
||||
});
|
||||
|
||||
it('passes absolute http URLs through unchanged', () => {
|
||||
render(<Image {...defaultProps} imagePath="https://example.com/photo.jpg" />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
it('passes data URIs through unchanged', () => {
|
||||
render(<Image {...defaultProps} imagePath="data:image/png;base64,abc" />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
|
||||
});
|
||||
|
||||
it('passes non-/images/ paths through unchanged', () => {
|
||||
render(<Image {...defaultProps} imagePath="/other/path.png" />);
|
||||
const img = screen.getByTestId('lazy-image');
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', '/other/path.png');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { render, screen } from '@testing-library/react';
|
|||
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: unknown[]) =>
|
||||
cn: (...classes: (string | boolean | undefined | null)[]) =>
|
||||
classes
|
||||
.flat(Infinity)
|
||||
.filter((c) => typeof c === 'string' && c.length > 0)
|
||||
.filter((c): c is string => typeof c === 'string' && c.length > 0)
|
||||
.join(' '),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export default function Message(props: TMessageProps) {
|
|||
</h2>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||
<ContentParts
|
||||
edit={edit}
|
||||
isLast={isLast}
|
||||
|
|
@ -147,7 +147,7 @@ export default function Message(props: TMessageProps) {
|
|||
/>
|
||||
</div>
|
||||
{isLast && isSubmitting ? (
|
||||
<div className="mt-1 h-[27px] bg-transparent" />
|
||||
<div className="mt-1 h-[31px] bg-transparent" />
|
||||
) : (
|
||||
<SubRow classes="text-xs">
|
||||
<SiblingSwitch
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ const MessageRender = memo(function MessageRender({
|
|||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||
<MessageContext.Provider value={messageContextValue}>
|
||||
<MessageContent
|
||||
ask={ask}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
/** Height matches the SubRow action buttons row (31px) — keep in sync with HoverButtons */
|
||||
const PlaceholderRow = memo(function PlaceholderRow() {
|
||||
return <div className="mt-1 h-[27px] bg-transparent" />;
|
||||
return <div className="mt-1 h-[31px] bg-transparent" />;
|
||||
});
|
||||
PlaceholderRow.displayName = 'PlaceholderRow';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue