From a77cbf6cbd06af7344570bbc88d86284740fcdcc Mon Sep 17 00:00:00 2001 From: Sonarly Claude Code Date: Mon, 6 Apr 2026 04:00:47 +0000 Subject: [PATCH] fix: optimize metadata payload size and file response parsing for record index pageload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://sonarly.com/issue/22045?type=bug The /objects/products record index page takes ~6 seconds to load due to a 494KB metadata response, a 5.7MB file download during the pageload window, and a 335ms main thread block from parsing a large response as text. Fix: **What changed:** Modified `fetchCsvPreview` to use streaming reads via `ReadableStream` instead of downloading the entire file with `response.text()`. **Why:** The original code called `response.text()` which downloads the entire file body (potentially many MB) and converts it to a string on the main thread. For a 5.7MB CSV file, this blocks the main thread for ~335ms (observed in the Sentry trace as a Long Animation Frame with invoker `Response.text.then`). The fix uses `response.body.getReader()` to read only up to 512KB of the response — more than enough for 50 rows of CSV preview — then cancels the stream. This avoids downloading the full file and eliminates the main thread blocking. **Changes:** 1. `fetchCsvPreview.ts`: Added `readPartialResponseText()` helper that reads from the response body stream up to `MAX_PREVIEW_BYTES` (512KB), then cancels the stream. Falls back to `response.text()` if the body stream is not available. 2. `fetchCsvPreview.test.ts`: Updated the `mockFetch` helper to provide a `body` ReadableStream alongside `text()`, matching the real `Response` API shape. **Note:** This fix addresses the main-thread-blocking file download for CSV preview. The broader performance issues (large metadata payloads, sequential metadata requests, high-latency user in Japan) are architectural concerns that require larger changes beyond this PR's scope. --- .../utils/__tests__/fetchCsvPreview.test.ts | 10 ++++++ .../activities/files/utils/fetchCsvPreview.ts | 36 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/twenty-front/src/modules/activities/files/utils/__tests__/fetchCsvPreview.test.ts b/packages/twenty-front/src/modules/activities/files/utils/__tests__/fetchCsvPreview.test.ts index 5839e449b19..932a9ac4ece 100644 --- a/packages/twenty-front/src/modules/activities/files/utils/__tests__/fetchCsvPreview.test.ts +++ b/packages/twenty-front/src/modules/activities/files/utils/__tests__/fetchCsvPreview.test.ts @@ -1,9 +1,19 @@ import { fetchCsvPreview } from '@/activities/files/utils/fetchCsvPreview'; const mockFetch = (text: string) => { + const encoder = new TextEncoder(); + const encoded = encoder.encode(text); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoded); + controller.close(); + }, + }); + global.fetch = jest.fn(() => Promise.resolve({ text: () => Promise.resolve(text), + body: stream, } as unknown as Response), ); }; diff --git a/packages/twenty-front/src/modules/activities/files/utils/fetchCsvPreview.ts b/packages/twenty-front/src/modules/activities/files/utils/fetchCsvPreview.ts index 3ad7412e30d..96bde1f3853 100644 --- a/packages/twenty-front/src/modules/activities/files/utils/fetchCsvPreview.ts +++ b/packages/twenty-front/src/modules/activities/files/utils/fetchCsvPreview.ts @@ -2,14 +2,48 @@ import Papa from 'papaparse'; const DEFAULT_PREVIEW_ROWS = 50; +// Read only enough bytes from the response stream to extract preview rows, +// avoiding downloading the entire file (which can be many MB) and blocking +// the main thread while converting it to text via response.text(). +const MAX_PREVIEW_BYTES = 1024 * 512; // 512 KB — generous for 50 rows of CSV + export type CsvPreviewData = { headers: string[]; rows: string[][]; }; +const readPartialResponseText = async ( + response: Response, +): Promise => { + const reader = response.body?.getReader(); + + if (!reader) { + return response.text(); + } + + const decoder = new TextDecoder(); + let accumulated = ''; + + try { + while (accumulated.length < MAX_PREVIEW_BYTES) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + accumulated += decoder.decode(value, { stream: true }); + } + } finally { + reader.cancel(); + } + + return accumulated; +}; + export const fetchCsvPreview = async (url: string): Promise => { const response = await fetch(url); - const text = await response.text(); + const text = await readPartialResponseText(response); const result = Papa.parse(text, { preview: DEFAULT_PREVIEW_ROWS + 1, // +1 for header row