mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
* 🐛 fix: Prevent Infinite Render Loop on Code-Execution File Preview Loading a conversation that contains a large (>1MB) code-execution office file crashed the whole app with React error #185 ("Maximum update depth exceeded") on hard refresh. Root cause (client-only): the terminal-write effect in useAttachmentPreviewSync writes the resolved preview record back into messageAttachmentsMap with a fresh object identity on every run, and `attachment` is in the effect's dependency array. useAttachments re-derives `attachment` ({...db, ...liveEntry}) with a new identity on every map write, so once polling resolves (pending -> ready on a loaded conversation) the effect ping-pongs forever: setAttachmentsMap -> re-derive -> effect -> setAttachmentsMap. Only files large/slow enough to defer extraction are persisted at status: 'pending', which is why small documents never triggered it. Fix: an idempotency gate that bails before setAttachmentsMap when the merged attachment already carries the resolved status/text/textFormat/ previewError. The write happens once and then settles. Tests: - useAttachmentPreviewSync.loop.spec.tsx wires the real useAttachments -> hook feedback to reproduce the loop (verified to throw #185 without the gate, settle with it). - e2e/specs/mock/attachment-preview-loop.spec.ts loads a conversation with a pending code-exec attachment whose preview resolves ready and asserts the app does not crash. Closes #13916 * 🔧 feat: Make Office Preview Extraction Cap Configurable (default 2MB) The inline code-execution preview extraction ceiling was a hardcoded 1MB constant (MAX_TEXT_EXTRACT_BYTES). Office/text artifacts over that skip the inline preview and resolve to "Preview unavailable" (download-only). Make it configurable via FILE_PREVIEW_MAX_EXTRACT_BYTES and raise the default to 2MB so larger documents get an inline preview out of the box. The rendered HTML remains independently capped at MAX_TEXT_CACHE_BYTES (512KB), so image-heavy files over that still fall back to the existing "preview too large" banner rather than rendering unbounded output. - resolveMaxTextExtractBytes(env) parses the override, falling back to 2MB on missing/non-numeric/non-positive values (warns on invalid). - Documented in .env.example next to the other file-size limits. - Unit tests cover default, valid override, fractional flooring, and invalid fallback. * 🐛 fix: Guard sub-byte preview cap from flooring to zero A fractional FILE_PREVIEW_MAX_EXTRACT_BYTES in (0, 1) passed the positive-number check then floored to 0, making MAX_TEXT_EXTRACT_BYTES zero and treating every non-empty artifact as oversized. Floor first, then require the result to be >= 1 byte before accepting it; otherwise fall back to the 2 MB default. Adds coverage for the sub-byte case. * ✅ test: Make exported-ceiling assertion env-independent The "exported ceiling" assertion compared MAX_TEXT_EXTRACT_BYTES to a literal 2 MB, but that const is initialized from FILE_PREVIEW_MAX_EXTRACT_BYTES at module load — so the suite would falsely fail when run with the override set. Assert the export tracks resolveMaxTextExtractBytes(env) for the current environment instead; the undefined-case test continues to pin the 2 MB default.
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { Route } from '@playwright/test';
|
|
|
|
/**
|
|
* Regression for issue #13916: hard-refreshing a conversation that
|
|
* contains a large (>1MB) code-execution office file crashed the whole
|
|
* app with React error #185 ("Maximum update depth exceeded").
|
|
*
|
|
* Root cause (client-only): on a loaded conversation the message's
|
|
* attachment is frozen at the immediate-persist snapshot
|
|
* `status: 'pending'`. `useAttachmentPreviewSync` polls the preview
|
|
* endpoint, and once it resolves it writes the record back into
|
|
* `messageAttachmentsMap`. `useAttachments` re-derives the attachment
|
|
* with a fresh identity on every such write, and that attachment is in
|
|
* the effect's dependency array — so the write-back ping-ponged forever.
|
|
*
|
|
* This spec reconstructs that exact load: it intercepts the
|
|
* conversation, messages, and preview endpoints to serve a code-exec
|
|
* tool call carrying a still-`pending` office attachment whose preview
|
|
* resolves to `ready`. With the bug present the page throws #185 and the
|
|
* route's error boundary replaces the chat; with the fix it settles and
|
|
* the conversation renders normally.
|
|
*
|
|
* It uses network interception (not the fake model) because the trigger
|
|
* is the persisted deferred-preview lifecycle, which the mock LLM does
|
|
* not produce.
|
|
*/
|
|
|
|
const NO_PARENT = '00000000-0000-0000-0000-000000000000';
|
|
|
|
const unique = (p: string) => `${p}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
const escapeRe = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
test.describe('issue #13916 — code-exec attachment preview', () => {
|
|
test('loading a conversation with a pending >1MB doc attachment does not crash (React #185)', async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120000);
|
|
|
|
const conversationId = unique('e2e-13916');
|
|
const messageId = `${conversationId}-msg`;
|
|
const fileId = `${conversationId}-file`;
|
|
const filename = 'repro-13916-large.docx';
|
|
const now = new Date(0).toISOString();
|
|
|
|
/** DB-frozen, immediate-persist snapshot: pending, no resolved text. */
|
|
const attachment = {
|
|
file_id: fileId,
|
|
filename,
|
|
filepath: `/uploads/682f49b90f07376815c38ef2/${fileId}__${filename}`,
|
|
type: 'execute_code',
|
|
source: 'local',
|
|
bytes: 1256732,
|
|
messageId,
|
|
conversationId,
|
|
toolCallId: 'tc-13916',
|
|
status: 'pending',
|
|
metadata: {
|
|
codeEnvRef: { kind: 'user', id: '682f49b90f07376815c38ef2', storage_session_id: 'sess' },
|
|
},
|
|
};
|
|
|
|
const message = {
|
|
messageId,
|
|
conversationId,
|
|
parentMessageId: NO_PARENT,
|
|
isCreatedByUser: false,
|
|
sender: 'Assistant',
|
|
endpoint: 'Mock Provider A',
|
|
model: 'mock-model-a',
|
|
text: '',
|
|
content: [
|
|
{
|
|
type: 'tool_call',
|
|
tool_call: {
|
|
id: 'tc-13916',
|
|
name: 'execute_code',
|
|
args: '{"lang":"py","code":"# edit the document"}',
|
|
output: 'edited 1 paragraph',
|
|
progress: 1,
|
|
},
|
|
},
|
|
],
|
|
attachments: [attachment],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
const conversation = {
|
|
conversationId,
|
|
title: 'File Editing Request',
|
|
endpoint: 'Mock Provider A',
|
|
endpointType: 'custom',
|
|
model: 'mock-model-a',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
/* The preview poll resolves immediately to `ready`; this terminal
|
|
* response is the edge that kicks off the (formerly infinite) write-back. */
|
|
const previewReady = {
|
|
file_id: fileId,
|
|
status: 'ready',
|
|
text: '<p>edited paragraph added</p>',
|
|
textFormat: 'html',
|
|
};
|
|
|
|
const convoIdRe = escapeRe(conversationId);
|
|
await page.route(new RegExp(`/api/convos/${convoIdRe}(?:\\?.*)?$`), (route: Route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(conversation),
|
|
}),
|
|
);
|
|
await page.route(new RegExp(`/api/messages/${convoIdRe}(?:\\?.*)?$`), (route: Route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([message]),
|
|
}),
|
|
);
|
|
await page.route(/\/api\/files\/[^/]+\/preview(?:\?.*)?$/, (route: Route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(previewReady),
|
|
}),
|
|
);
|
|
|
|
/* The exact failure signature from the issue. Capture both the
|
|
* uncaught throw and the console error React logs alongside it. */
|
|
const maxDepth = /Maximum update depth exceeded/i;
|
|
const fatalErrors: string[] = [];
|
|
page.on('pageerror', (err) => {
|
|
if (maxDepth.test(err.message)) {
|
|
fatalErrors.push(err.message);
|
|
}
|
|
});
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error' && maxDepth.test(msg.text())) {
|
|
fatalErrors.push(msg.text());
|
|
}
|
|
});
|
|
|
|
await page.goto(`/c/${conversationId}`, { timeout: 30000 });
|
|
|
|
/* Anti-false-pass: the attachment path must actually execute, else
|
|
* the buggy build would never loop and this test would guard nothing.
|
|
* The filename renders only once the code-exec attachment mounts. */
|
|
await expect(page.getByText(new RegExp(escapeRe('repro-13916'))).first()).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
|
|
/* Let the preview poll + any re-render storm play out. */
|
|
await page.waitForTimeout(3000);
|
|
|
|
expect(
|
|
fatalErrors,
|
|
`React #185 fired during conversation load:\n${fatalErrors.join('\n---\n')}`,
|
|
).toHaveLength(0);
|
|
|
|
/* The route's error boundary replaces the composer on crash, so its
|
|
* presence is an independent "the app survived" signal. */
|
|
await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
});
|
|
});
|