📸 fix: Guard Screenshot Export Against Main-Thread Freezes (#14733)

* 📸 fix: Guard Screenshot Export Against Main-Thread Freezes

* 🧪 test: Cover Conversation Export Flows End-to-End

* 🧪 test: Stabilize Export Spec CSV and Toast Assertions
This commit is contained in:
Danny Avila 2026-08-10 22:47:50 -04:00 committed by GitHub
parent f7d9f36922
commit c93609cb82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 321 additions and 39 deletions

View file

@ -143,7 +143,7 @@ function MessagesViewContent({
</div>
) : (
<>
<div ref={screenshotTargetRef}>
<div ref={screenshotTargetRef} data-testid="screenshot-target">
<MultiMessage
messagesTree={_messagesTree}
messageId={conversationId ?? null}

View file

@ -14,13 +14,15 @@ import { useLocalize, useExportConversation } from '~/hooks';
import { normalizeExportFilename } from '~/utils';
const TYPE_OPTIONS = [
{ value: 'screenshot', label: 'screenshot (.png)' },
{ value: 'text', label: 'text (.txt)' },
{ value: 'markdown', label: 'markdown (.md)' },
{ value: 'text', label: 'text (.txt)' },
{ value: 'json', label: 'json (.json)' },
{ value: 'csv', label: 'csv (.csv)' },
{ value: 'screenshot', label: 'screenshot (.png)' },
];
const DEFAULT_TYPE = 'markdown';
export default function ExportModal({
open,
onOpenChange,
@ -37,7 +39,7 @@ export default function ExportModal({
const localize = useLocalize();
const [filename, setFileName] = useState('');
const [type, setType] = useState<string>('screenshot');
const [type, setType] = useState<string>(DEFAULT_TYPE);
const [includeOptions, setIncludeOptions] = useState<boolean | 'indeterminate'>(true);
const [exportBranches, setExportBranches] = useState<boolean | 'indeterminate'>(false);
@ -51,24 +53,21 @@ export default function ExportModal({
useEffect(() => {
setFileName(filenamify(String(conversation?.title ?? 'file')));
setType('screenshot');
setType(DEFAULT_TYPE);
setIncludeOptions(true);
setExportBranches(false);
setRecursive(true);
}, [conversation?.title, open]);
const handleTypeChange = useCallback((newType: string) => {
const branches = newType === 'json' || newType === 'csv' || newType === 'webpage';
const branches = newType === 'json' || newType === 'csv';
const options = newType !== 'csv' && newType !== 'screenshot';
setExportBranches(branches);
setIncludeOptions(options);
setType(newType);
}, []);
const exportBranchesSupport = useMemo(
() => type === 'json' || type === 'csv' || type === 'webpage',
[type],
);
const exportBranchesSupport = useMemo(() => type === 'json' || type === 'csv', [type]);
const exportOptionsSupport = useMemo(() => type !== 'csv' && type !== 'screenshot', [type]);
const { exportConversation } = useExportConversation({

View file

@ -2,11 +2,13 @@ import { useCallback } from 'react';
import download from 'downloadjs';
import { useParams } from 'react-router-dom';
import exportFromJSON from 'export-from-json';
import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
import { buildTree, QueryKeys } from 'librechat-data-provider';
import type { TConversation, TMessage, TPreset } from 'librechat-data-provider';
import { ScreenshotLimitError, useScreenshot } from '~/hooks/ScreenshotContext';
import useBuildMessageTree from '~/hooks/Messages/useBuildMessageTree';
import { useScreenshot } from '~/hooks/ScreenshotContext';
import { NotificationSeverity } from '~/common';
import { formatMessageText } from './format';
import { cleanupPreset } from '~/utils';
import { useLocalize } from '~/hooks';
@ -33,6 +35,7 @@ export default function useExportConversation({
recursive: boolean | 'indeterminate';
}) {
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const { captureScreenshot } = useScreenshot();
const buildMessageTree = useBuildMessageTree();
const localize = useLocalize();
@ -48,12 +51,21 @@ export default function useExportConversation({
}, [paramId, conversation?.conversationId, queryClient]);
const exportScreenshot = async () => {
let data;
let data: Blob;
try {
data = await captureScreenshot();
} catch (err) {
console.error('Failed to capture screenshot');
return console.error(err);
console.error('Failed to capture screenshot', err);
showToast({
message: localize(
err instanceof ScreenshotLimitError
? 'com_nav_export_screenshot_too_large'
: 'com_nav_export_screenshot_error',
),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
return;
}
download(data, `${filename}.png`, 'image/png');
};

View file

@ -1,4 +1,4 @@
import { createContext, useRef, useContext, RefObject } from 'react';
import { createContext, useRef, useContext, RefObject, ReactNode } from 'react';
import { toCanvas } from 'html-to-image';
import { ThemeContext, isDark } from '@librechat/client';
@ -6,47 +6,73 @@ type ScreenshotContextType = {
ref?: RefObject<HTMLDivElement>;
};
/** Canvas area ceiling (~16.7 MP, 4096²) — WebKit rejects larger canvases and PNG encode cost grows linearly past it */
const MAX_CAPTURE_AREA = 16_777_216;
/** Chromium/Firefox cap a canvas edge at 32767px; beyond it drawing silently produces a blank canvas */
const MAX_CANVAS_EDGE = 32_767;
/** Below half-resolution the capture is illegible, so abort rather than degrade further */
const MIN_PIXEL_RATIO = 0.5;
/** html-to-image clones every node and copies ~340 computed styles each; cap the synchronous clone work */
const MAX_CAPTURE_ELEMENTS = 50_000;
export class ScreenshotLimitError extends Error {
constructor(message: string) {
super(message);
this.name = 'ScreenshotLimitError';
}
}
const ScreenshotContext = createContext<ScreenshotContextType>({});
export const useScreenshot = () => {
const { ref } = useContext(ScreenshotContext);
const { theme } = useContext(ThemeContext);
const takeScreenShot = async (node?: HTMLElement) => {
const takeScreenShot = async (node?: HTMLElement): Promise<Blob> => {
if (!node) {
throw new Error('You should provide correct html node.');
}
const backgroundColor = isDark(theme) ? '#171717' : 'white';
const width = node.scrollWidth;
const height = node.scrollHeight;
if (!width || !height) {
throw new Error('Cannot capture an empty node.');
}
const elementCount = node.querySelectorAll('*').length;
if (elementCount > MAX_CAPTURE_ELEMENTS) {
throw new ScreenshotLimitError(
`Screenshot capture aborted: ${elementCount} elements exceed the ${MAX_CAPTURE_ELEMENTS} limit`,
);
}
const pixelRatio = Math.min(
window.devicePixelRatio || 1,
Math.sqrt(MAX_CAPTURE_AREA / (width * height)),
MAX_CANVAS_EDGE / Math.max(width, height),
);
if (pixelRatio < MIN_PIXEL_RATIO) {
throw new ScreenshotLimitError(
`Screenshot capture aborted: ${width}x${height} CSS px cannot fit within ${MAX_CAPTURE_AREA} device px`,
);
}
const backgroundColor = isDark(theme) ? '#171717' : 'white';
const canvas = await toCanvas(node, {
backgroundColor,
pixelRatio,
imagePlaceholder:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=',
});
const croppedCanvas = document.createElement('canvas');
const croppedCanvasContext = croppedCanvas.getContext('2d') as CanvasRenderingContext2D;
// init data
const cropPositionTop = 0;
const cropPositionLeft = 0;
const cropWidth = canvas.width;
const cropHeight = canvas.height;
croppedCanvas.width = cropWidth;
croppedCanvas.height = cropHeight;
croppedCanvasContext.fillStyle = backgroundColor;
croppedCanvasContext.fillRect(0, 0, cropWidth, cropHeight);
croppedCanvasContext.drawImage(canvas, cropPositionLeft, cropPositionTop);
const base64Image = croppedCanvas.toDataURL('image/png', 1);
return base64Image;
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) {
throw new Error('Failed to encode screenshot canvas.');
}
return blob;
};
const captureScreenshot = async () => {
const captureScreenshot = async (): Promise<Blob> => {
if (ref instanceof Function) {
throw new Error('Ref callback is not supported.');
}
@ -59,8 +85,8 @@ export const useScreenshot = () => {
return { screenshotTargetRef: ref, captureScreenshot };
};
export const ScreenshotProvider = ({ children }) => {
const ref = useRef(null);
export const ScreenshotProvider = ({ children }: { children: ReactNode }) => {
const ref = useRef<HTMLDivElement>(null);
return <ScreenshotContext.Provider value={{ ref }}>{children}</ScreenshotContext.Provider>;
};

View file

@ -510,6 +510,8 @@
"com_nav_export_include_endpoint_options": "Include endpoint options",
"com_nav_export_recursive": "Recursive",
"com_nav_export_recursive_or_sequential": "Recursive or sequential?",
"com_nav_export_screenshot_error": "Couldn't capture the screenshot. Try a different export type.",
"com_nav_export_screenshot_too_large": "This conversation is too large to export as a screenshot. Try a different export type.",
"com_nav_export_type": "Type",
"com_nav_external": "External",
"com_nav_font_size": "Message Font Size",

View file

@ -81,6 +81,47 @@ export async function deleteConversations(conversationIds: string[]): Promise<vo
});
}
export interface SeedMessage {
messageId: string;
parentMessageId: string;
text: string;
isCreatedByUser: boolean;
sender: string;
}
/**
* Inserts message documents directly so specs can build conversations far larger
* than the mock model could produce through the UI in reasonable time.
*/
export async function seedMessages(
userEmail: string,
conversationId: string,
messages: SeedMessage[],
): Promise<void> {
await withMongo(async (db) => {
const userId = await resolveUserId(db, userEmail);
const start = Date.now();
const docs = messages.map((message, index) => ({
...message,
conversationId,
user: userId,
endpoint: 'openAI',
error: false,
unfinished: false,
createdAt: new Date(start + index * 1000),
updatedAt: new Date(start + index * 1000),
__v: 0,
}));
await db.collection('messages').insertMany(docs);
});
}
export async function deleteMessagesByConversation(conversationIds: string[]): Promise<void> {
await withMongo(async (db) => {
await db.collection('messages').deleteMany({ conversationId: { $in: conversationIds } });
});
}
/** Clears every conversation for the user so the seeded date groups are not pushed
* below the virtualized viewport by rows left behind by other specs. */
export async function clearUserConversations(userEmail: string): Promise<void> {

View file

@ -0,0 +1,202 @@
import fs from 'fs';
import { randomUUID } from 'crypto';
import { expect, test } from '@playwright/test';
import type { Download, Locator, Page } from '@playwright/test';
import { getE2EUser } from '../../setup/user';
import {
MOCK_ENDPOINTS,
MOCK_REPLY_TEXT,
NEW_CHAT_PATH,
messagesView,
mockReply,
selectMockEndpoint,
sendMessage,
} from './helpers';
import {
deleteConversations,
deleteMessagesByConversation,
seedConversations,
seedMessages,
} from './db';
import type { SeedMessage } from './db';
const NO_PARENT = '00000000-0000-0000-0000-000000000000';
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/** Mirrors MAX_CAPTURE_AREA in client/src/hooks/ScreenshotContext.tsx. */
const MAX_CAPTURE_AREA = 16_777_216;
/** The capture aborts once the CSS area needs a pixel ratio below 0.5, i.e. above 4× the max area. */
const ABORT_CSS_AREA = 4 * MAX_CAPTURE_AREA;
const OVERSIZED_TITLE = 'Oversized export fixture';
const OVERSIZED_PARAGRAPH = 'Oversized export fixture paragraph';
const OVERSIZED_MESSAGES = 80;
const OVERSIZED_PARAGRAPHS_PER_MESSAGE = 50;
const userEmail = getE2EUser().email;
const cleanupConversationIds: string[] = [];
async function startMockConversation(page: Page): Promise<string> {
await page.goto(NEW_CHAT_PATH, { timeout: 15000 });
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
await sendMessage(page, 'Export fixture prompt');
await expect(mockReply(page).first()).toBeVisible({ timeout: 15000 });
await expect(page).toHaveURL(/\/c\/(?!new$)[\w-]+/, { timeout: 15000 });
const conversationId = new URL(page.url()).pathname.split('/').pop();
if (!conversationId) {
throw new Error(`Could not parse conversation id from ${page.url()}`);
}
cleanupConversationIds.push(conversationId);
return conversationId;
}
async function openExportModal(page: Page): Promise<Locator> {
await page.getByRole('button', { name: 'Export/Share' }).click();
await page.getByRole('menuitem', { name: 'Export' }).click();
const dialog = page.getByRole('dialog', { name: 'Export conversation' });
await expect(dialog).toBeVisible();
return dialog;
}
const typeDropdown = (dialog: Locator) => dialog.getByTestId('dropdown-menu');
async function selectExportType(page: Page, dialog: Locator, label: string) {
await typeDropdown(dialog).click();
await page.getByRole('option', { name: label }).click();
await expect(typeDropdown(dialog)).toContainText(label);
}
/** The modal stays open after exporting, so consecutive exports reuse one dialog. */
async function exportCurrentType(page: Page, dialog: Locator): Promise<Download> {
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30000 }),
dialog.getByRole('button', { name: 'Export', exact: true }).click(),
]);
return download;
}
async function downloadText(download: Download): Promise<string> {
return fs.promises.readFile(await download.path(), 'utf8');
}
function buildOversizedMessages(): SeedMessage[] {
const text = Array.from(
{ length: OVERSIZED_PARAGRAPHS_PER_MESSAGE },
(_, index) => `${OVERSIZED_PARAGRAPH} ${index}.`,
).join('\n\n');
const messages: SeedMessage[] = [];
let parentMessageId = NO_PARENT;
for (let i = 0; i < OVERSIZED_MESSAGES; i++) {
const messageId = randomUUID();
messages.push({
messageId,
parentMessageId,
text,
isCreatedByUser: i % 2 === 0,
sender: i % 2 === 0 ? 'User' : 'Assistant',
});
parentMessageId = messageId;
}
return messages;
}
test.afterAll(async () => {
if (cleanupConversationIds.length === 0) {
return;
}
await deleteMessagesByConversation(cleanupConversationIds);
await deleteConversations(cleanupConversationIds);
});
test.describe('conversation export', () => {
test('defaults to markdown with screenshot demoted to the last option', async ({ page }) => {
await startMockConversation(page);
const dialog = await openExportModal(page);
await expect(typeDropdown(dialog)).toContainText('markdown (.md)');
await typeDropdown(dialog).click();
const options = page.getByRole('option');
await expect(options.first()).toContainText('markdown (.md)');
await expect(options.last()).toContainText('screenshot (.png)');
});
test('exports the conversation in every format', async ({ page }) => {
test.setTimeout(120_000);
await startMockConversation(page);
const dialog = await openExportModal(page);
const markdownDownload = await exportCurrentType(page, dialog);
expect(markdownDownload.suggestedFilename()).toMatch(/\.md$/);
const markdown = await downloadText(markdownDownload);
expect(markdown).toContain('# Conversation');
expect(markdown).toContain(MOCK_REPLY_TEXT);
await selectExportType(page, dialog, 'text (.txt)');
const textDownload = await exportCurrentType(page, dialog);
expect(textDownload.suggestedFilename()).toMatch(/\.txt$/);
expect(await downloadText(textDownload)).toContain(MOCK_REPLY_TEXT);
await selectExportType(page, dialog, 'json (.json)');
const jsonDownload = await exportCurrentType(page, dialog);
expect(jsonDownload.suggestedFilename()).toMatch(/\.json$/);
const parsed = JSON.parse(await downloadText(jsonDownload)) as Record<string, unknown>;
expect(JSON.stringify(parsed)).toContain(MOCK_REPLY_TEXT);
await selectExportType(page, dialog, 'csv (.csv)');
const csvDownload = await exportCurrentType(page, dialog);
expect(csvDownload.suggestedFilename()).toMatch(/\.csv$/);
const csv = await downloadText(csvDownload);
expect(csv).toContain('sender');
/** CSV export maps only the legacy `text` field, which is empty for the mock
* model's content-parts reply assert on the user message instead. */
expect(csv).toContain('Export fixture prompt');
await selectExportType(page, dialog, 'screenshot (.png)');
const screenshotDownload = await exportCurrentType(page, dialog);
expect(screenshotDownload.suggestedFilename()).toMatch(/\.png$/);
const png = await fs.promises.readFile(await screenshotDownload.path());
expect(png.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)).toBe(true);
expect(png.byteLength).toBeGreaterThan(1000);
});
test('aborts screenshot export of an oversized conversation with an error toast', async ({
page,
}) => {
test.setTimeout(180_000);
const conversationId = randomUUID();
cleanupConversationIds.push(conversationId);
await seedConversations(userEmail, [
{ conversationId, title: OVERSIZED_TITLE, updatedAt: new Date() },
]);
await seedMessages(userEmail, conversationId, buildOversizedMessages());
await page.goto(`/c/${conversationId}`, { timeout: 30000 });
await expect(messagesView(page).getByText(`${OVERSIZED_PARAGRAPH} 0.`).first()).toBeVisible({
timeout: 60000,
});
const target = page.getByTestId('screenshot-target');
const area = await target.evaluate((node) => node.scrollWidth * node.scrollHeight);
expect(area).toBeGreaterThan(ABORT_CSS_AREA * 1.15);
const dialog = await openExportModal(page);
await selectExportType(page, dialog, 'screenshot (.png)');
let downloadFired = false;
page.on('download', () => {
downloadFired = true;
});
await dialog.getByRole('button', { name: 'Export', exact: true }).click();
await expect(page.getByText('too large to export as a screenshot').first()).toBeVisible({
timeout: 15000,
});
expect(downloadFired).toBe(false);
await selectExportType(page, dialog, 'markdown (.md)');
const fallbackDownload = await exportCurrentType(page, dialog);
expect(fallbackDownload.suggestedFilename()).toMatch(/\.md$/);
expect(await downloadText(fallbackDownload)).toContain(`${OVERSIZED_PARAGRAPH} 0.`);
});
});