From 785e64f4e1b0313a809a9aad40b9a102051135a1 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 14:55:20 +0545 Subject: [PATCH] perf(json): bound the row JSON viewer for large values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row expand viewer could blow up memory on rows with big JSON (e.g. resume_parsing's Buffer 'data' byte arrays / large jsonb): - Raw view: the preview kept every array item / object key (only strings were truncated), so displayText ballooned, then got stringified and highlighted. Now cap arrays/objects to 200 children in the preview and skip per-token highlighting entirely for any truncated/large value (plain escaped text). Copy JSON still copies the full value. - Tree view: stop materializing a tuple for every element of a huge array just to show its count — use .length for the summary and build only the currently-visible children. --- src/lib/components/JsonTree.svelte | 49 +++++++++++---- src/lib/components/RowExpandViewer.svelte | 73 ++++++++++++++--------- 2 files changed, 82 insertions(+), 40 deletions(-) diff --git a/src/lib/components/JsonTree.svelte b/src/lib/components/JsonTree.svelte index 0299c874..18808016 100644 --- a/src/lib/components/JsonTree.svelte +++ b/src/lib/components/JsonTree.svelte @@ -39,19 +39,46 @@ ) const isContainer = $derived(isArray || isObject) + let expanded = $state(depth < defaultDepth) + let childLimit = $state(CHILD_PAGE) + + // Total child count — cheap for arrays (.length). Avoids materializing a tuple + // for every element of a huge array/object just to show a count. + const totalCount = $derived( + isArray ? /** @type {unknown[]} */ (value).length + : isObject ? Object.keys(/** @type {Record} */ (value)).length + : 0, + ) + + // Only the currently-visible children (up to childLimit) are materialized, so + // expanding a 100k-element array never builds 100k tuples at once. /** @type {[string, unknown][]} */ - const entries = $derived.by(() => { - if (isArray) return /** @type {unknown[]} */ (value).map((v, i) => /** @type {[string, unknown]} */ ([String(i), v])) - if (isObject) return Object.entries(/** @type {Record} */ (value)) + const visibleEntries = $derived.by(() => { + if (isArray) { + const arr = /** @type {unknown[]} */ (value) + const n = Math.min(arr.length, childLimit) + /** @type {[string, unknown][]} */ + const out = [] + for (let i = 0; i < n; i++) out.push([String(i), arr[i]]) + return out + } + if (isObject) { + /** @type {[string, unknown][]} */ + const out = [] + let i = 0 + for (const k in /** @type {Record} */ (value)) { + if (i >= childLimit) break + out.push([k, /** @type {Record} */ (value)[k]]) + i++ + } + return out + } return [] }) - let expanded = $state(depth < defaultDepth) - let childLimit = $state(CHILD_PAGE) - const summary = $derived.by(() => { - if (isArray) return entries.length === 1 ? '1 item' : `${entries.length} items` - if (isObject) return entries.length === 1 ? '1 key' : `${entries.length} keys` + if (isArray) return totalCount === 1 ? '1 item' : `${totalCount} items` + if (isObject) return totalCount === 1 ? '1 key' : `${totalCount} keys` return '' }) @@ -152,7 +179,7 @@ {#if isContainer && expanded}
- {#each entries.slice(0, childLimit) as [k, v] (k)} + {#each visibleEntries as [k, v] (k)} {/each} - {#if entries.length > childLimit} + {#if totalCount > childLimit} {/if}
diff --git a/src/lib/components/RowExpandViewer.svelte b/src/lib/components/RowExpandViewer.svelte index 67b41f4e..a3573ad6 100644 --- a/src/lib/components/RowExpandViewer.svelte +++ b/src/lib/components/RowExpandViewer.svelte @@ -16,6 +16,11 @@ } from '$lib/json-inspector.js' const TRUNCATE_LIMIT = 200 + // Max array items / object keys kept in the raw-text preview. Bounds the + // stringified size for wide values (e.g. a Buffer's `data` byte array with + // thousands of numbers) so the preview never balloons. Copy JSON still copies + // the full, untruncated value. + const PREVIEW_CHILD_LIMIT = 200 const HIGHLIGHT_LIMIT = 12000 let { @@ -60,43 +65,52 @@ /** @type {{ x: number, y: number, value: string | null } | null} */ let contextMenu = $state(null) - const displayText = $derived(formatJsonValue(truncateDeep(record, TRUNCATE_LIMIT))) + // Bounded preview of the record: strings truncated to TRUNCATE_LIMIT and + // arrays/objects capped to PREVIEW_CHILD_LIMIT children. `truncated` records + // whether anything was cut, which drives whether we highlight at all. + const displayInfo = $derived.by(() => { + let truncated = false + /** @param {unknown} v */ + const walk = (v) => { + if (typeof v === 'string') { + if (v.length > TRUNCATE_LIMIT) { truncated = true; return v.slice(0, TRUNCATE_LIMIT) + '…' } + return v + } + if (Array.isArray(v)) { + const head = v.slice(0, PREVIEW_CHILD_LIMIT).map(walk) + if (v.length > PREVIEW_CHILD_LIMIT) { truncated = true; head.push(`… ${v.length - PREVIEW_CHILD_LIMIT} more items`) } + return head + } + if (v !== null && typeof v === 'object') { + /** @type {Record} */ + const out = {} + let i = 0 + for (const k in v) { + if (i >= PREVIEW_CHILD_LIMIT) { truncated = true; break } + out[k] = walk(/** @type {Record} */ (v)[k]) + i++ + } + return out + } + return v + } + return { text: formatJsonValue(walk(record)), truncated } + }) + const displayText = $derived(displayInfo.text) function fullJsonText() { return formatJsonValue(record) } - /** - * @param {unknown} value - * @param {number} limit - * @returns {unknown} - */ - function truncateDeep(value, limit) { - if (typeof value === 'string') { - return value.length > limit ? value.slice(0, limit) + '…' : value - } - if (Array.isArray(value)) { - return value.map((v) => truncateDeep(v, limit)) - } - if (value !== null && typeof value === 'object') { - return Object.fromEntries( - Object.entries(/** @type {Record} */ (value)).map(([k, v]) => [ - k, - truncateDeep(v, limit), - ]) - ) - } - return value - } - $effect(() => { // Tree mode never touches the stringified text — skip the format + // highlight work entirely until the raw view is actually shown. if (viewMode !== 'raw') return - const source = displayText - // Above the limit, skip per-token spans entirely — plain escaped text keeps - // very large values cheap to render. - html = source.length > HIGHLIGHT_LIMIT + const { text: source, truncated } = displayInfo + // Skip per-token highlighting for large values (anything we had to truncate, + // or a still-long preview) — the span-per-token HTML is the memory hog. Plain + // escaped text stays cheap to build and render. + html = truncated || source.length > HIGHLIGHT_LIMIT ? `
${escapeHtml(source)}
` : highlightJson(source) }) @@ -107,7 +121,8 @@ $effect(() => { if (!html || !rootEl) return const source = displayText - if (source.length > HIGHLIGHT_LIMIT) return + // Plain (non-highlighted) render for large values — skip the link scan too. + if (displayInfo.truncated || source.length > HIGHLIGHT_LIMIT) return void tick().then(() => { const pre = rootEl?.querySelector('pre') if (pre instanceof HTMLElement) linkifyJsonInElement(pre, source)