LibreChat/e2e/specs/mock/attachment-preview-loop.spec.ts
Danny Avila 562bd8ec5f
🐛 fix: Prevent Infinite Render Loop on Code-Execution File Preview (#13922)
* 🐛 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.
2026-06-23 16:34:43 -04:00

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,
});
});
});