fix: optimize metadata payload size and file response parsing for record index pageload

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.
This commit is contained in:
Sonarly Claude Code
2026-04-06 04:00:47 +00:00
parent bad488494f
commit a77cbf6cbd
2 changed files with 45 additions and 1 deletions
@@ -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),
);
};
@@ -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<string> => {
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<CsvPreviewData> => {
const response = await fetch(url);
const text = await response.text();
const text = await readPartialResponseText(response);
const result = Papa.parse<string[]>(text, {
preview: DEFAULT_PREVIEW_ROWS + 1, // +1 for header row