From 67d480fa7282acba1e2bcc6776a4061ecdf1909c Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 27 Aug 2026 17:50:08 +0545 Subject: [PATCH 01/14] fix(vite): pre-bundle canvas-confetti so the dep optimizer stops rehashing canvas-confetti is only reachable through a lazy `await import()` on the license pages, so Vite's initial scan never saw it. The first time one of those views mounted, Vite discovered the dep at runtime, re-ran the optimizer and rewrote every pre-bundled chunk with new hashes. The webview was still holding the old URLs, so runtime-*.js, index-client-*.js, legacy-client-*.js and Icon-*.js all 404'd at once. Losing the Svelte runtime chunk takes down every page, not just the license screen, which is why the SQL editor and everything else went blank rather than one view failing. Declaring it in optimizeDeps.include puts it in the first pass, so there is no mid-session re-optimize to race with. --- vite.config.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/vite.config.js b/vite.config.js index d3ee301f..e10e2368 100644 --- a/vite.config.js +++ b/vite.config.js @@ -42,6 +42,13 @@ export default defineConfig({ svelte({ compilerOptions: { runes: true } }), tailwindcss(), ], + optimizeDeps: { + // canvas-confetti is only reached through a lazy `await import()` on the license + // pages, so Vite's initial scan misses it. Discovering it mid-session re-runs the + // optimizer, rehashes every pre-bundled chunk, and the webview then 404s on the + // old runtime-*/index-client-*/Icon-* paths -> the whole app goes blank. + include: ['canvas-confetti'], + }, resolve: { alias: { $lib: path.resolve(__dirname, './src/lib'), From 078620c07fa2c7b01cb7371c73cf404e380e90dc Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 27 Aug 2026 17:50:22 +0545 Subject: [PATCH 02/14] perf(grid): cache truncation and hoist per-cell work out of the draw loop Profiled the canvas draw path on a 60Hz display against local Postgres: `events` (1M rows) and `openai_docs` (10k rows of pgvector embeddings). Wide-but-plain tables were already fine, holding p95 17ms. Text-heavy ones were not: openai_docs ran p50 18ms / p95 22ms / max 30ms, so 45-55fps. The dominant cost was measureText: 113 calls per frame shaping 16,014 characters through HarfBuzz for 3.08ms of an 11.7ms draw, re-deriving the same truncations every frame. Truncation is a pure function of (font, column width, string) and all three hold still while scrolling, so each combination is now measured once. The cache deliberately survives a `rows` swap, which is what keeps windowed scrolling on hits, and is dropped on a column change so one table's strings are not held alive after navigating away. Three more per-cell costs, all invariant, moved out of the hot path: - isBooleanType / isSqlArrayType / isVectorType / isGeometryType and the right-alignment test ran per cell per frame. Four regexes and about six throwaway strings per cell, roughly 40k regex executions a second while scrolling, for something that depends only on the column's declared type. They live in _colCache now, which already exists for exactly this. - drawCell read about 20 values straight off the reactive graph per cell. A $derived read re-checks the derived against every dependency's write version, so a 150-cell viewport paid ~3000 graph reads a frame for values that cannot change mid-frame. Snapshotted once per frame into bodyC. - geom.cols.length sat in the row loop condition, re-entering the geom derived once per column per row, and the pinned pass scanned every column of every row even with nothing pinned. Measured on openai_docs: measureText 113 -> 31 calls/frame and 16,014 -> 1,521 chars/frame, draw 11.7ms -> 8.7ms, frames p50 18 -> 17ms, p95 22 -> 17ms, max 30 -> 20ms, and 1-3 of 118 frames over 18ms instead of most of them. --- src/lib/components/DataTable.svelte | 230 ++++++++++++++++++++-------- 1 file changed, 164 insertions(+), 66 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 964d505d..3d1bde08 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -971,7 +971,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } if (_dispCacheVFns !== _vcolFns) { _dispCacheVFns = _vcolFns; _vexprTextCache.clear(); } if (_dispCacheTFns !== _colTransformFns) { _dispCacheTFns = _colTransformFns; _colTfCache.clear(); } + // The truncation cache is keyed by (font, width, string) and stays correct + // across pages, so it deliberately survives a `rows` swap - that is what + // keeps windowed scrolling on cache hits. It is dropped on a column change + // so one table's strings are not held alive after you navigate to another. + if (_truncCacheCols !== columns) { _truncCacheCols = columns; _truncCacheReset(); } } + let _truncCacheCols = /** @type {unknown} */ (null); /** Drop one row's cached text when the row behind it was replaced in place. * Called once per row per frame from drawBodyRow - not per cell, which would * put a Map lookup on every drawn cell for a check that can only change per row. */ @@ -3143,12 +3149,36 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Per-column stable cache - computed once per column/schema change instead of // once per cell per render. getColumnEnumValues, canEditColumn, and fkByColumn // were previously called rows×cols times on every reactive update. - const _colCache = $derived.by(() => columns.map((col, colIdx) => ({ - colType: col?.dataType ?? col?.data_type ?? '', - enumValues: getColumnEnumValues(col), - canEdit: !readonly && primaryKey.length > 0 && isEditableType(col?.dataType ?? col?.data_type ?? ''), - fk: fkByColumn[col?.name ?? ''] ?? null, - }))) + const _colCache = $derived.by(() => { + // Alignment mode is per-table, not per-column, but it decides `alignRight` + // below, so it belongs in this cache's dependency set. + const align = $appTableAlign + return columns.map((col) => { + const colType = col?.dataType ?? col?.data_type ?? '' + const t = String(colType) + return { + colType, + enumValues: getColumnEnumValues(col), + canEdit: !readonly && primaryKey.length > 0 && isEditableType(t), + fk: fkByColumn[col?.name ?? ''] ?? null, + // Type predicates. Each one is a regex over the column's declared type, + // and drawCell needs all of them; evaluated there they ran once per cell + // per frame - four regexes and half a dozen throwaway strings per cell, + // ~40k regex executions/sec while scrolling. They depend only on the + // column, so they are resolved once here instead. + isBool: isBooleanType(t), + isArrayType: isSqlArrayType(t), + isVector: isVectorType(t), + isGeom: isGeometryType(t), + // Right-alignment under the 'numbers' setting: quantities line up by + // place value, prose stays left. Booleans match /int/ on engines that + // spell them tinyint(1), so they are excluded before the numeric test. + alignRight: align === 'right' ? true + : align !== 'numbers' ? false + : !/bool/i.test(t) && _statsNumericRe.test(t), + } + }) + }) // Per-column numeric stats for heatmap + annotator extensions. Computed once // per data/settings change (NOT per render), sampled to bound cost on big @@ -3166,14 +3196,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * @param {number} actualIdx index into `columns` */ function isRightAlignedColumn(actualIdx) { - const mode = $appTableAlign - if (mode === 'right') return true - if (mode !== 'numbers') return false - const col = columns[actualIdx] - const type = String(col?.dataType ?? col?.data_type ?? '') - // Booleans match /int/ in some engines (tinyint(1)) but are not quantities. - if (/bool/i.test(type)) return false - return _statsNumericRe.test(type) + const cached = _colCache[actualIdx] + if (cached) return cached.alignRight + // No such column (a virtual/unmapped index): only the blanket 'right' mode + // still applies. + return $appTableAlign === 'right' } const _colStats = $derived.by(() => { void $pluginState @@ -3978,6 +4005,27 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return _glyphW > 0 ? str.length * _glyphW : ctx.measureText(str).width } + // Truncated strings, keyed by column width then by source string, for the + // active font. + // + // Truncating is the most expensive thing in the draw loop: WebKit shapes the + // whole string through HarfBuzz for every measureText, and a text-heavy table + // measured ~16,000 characters per frame (113 calls, 3.1ms of an 11ms frame) + // re-deriving answers it had already computed. The result is a pure function + // of (font, width, string) and all three are constant while scrolling, so each + // combination is measured once and the rest of the scroll is map hits. + /** @type {Map>} */ + const _truncCache = new Map() + /** The font `_truncCache` was built under; a zoom or font change drops it. */ + let _truncCacheFont = '' + let _truncCacheEntries = 0 + const _TRUNC_CACHE_MAX = 8192 + + function _truncCacheReset() { + _truncCache.clear() + _truncCacheEntries = 0 + } + /** Truncate `text` to fit `maxW` px under the current ctx.font, adding `…`. */ function truncText(ctx, text, maxW) { if (maxW <= 0) return '' @@ -3986,6 +4034,24 @@ import FilterX from "@lucide/svelte/icons/filter-x"; _syncGlyphW(ctx) // Fast no-truncation check: if the monospace estimate fits, trust it. if (_glyphW > 0 && len * _glyphW <= maxW) return text + // Past here the string has to be measured, so consult the cache first. + // `_glyphFont` was just refreshed from ctx.font by _syncGlyphW above. + if (_truncCacheFont !== _glyphFont) { _truncCacheReset(); _truncCacheFont = _glyphFont } + let byText = _truncCache.get(maxW) + if (byText !== undefined) { + const hit = byText.get(text) + if (hit !== undefined) return hit + } + const out = _truncMeasure(ctx, text, maxW, len) + if (_truncCacheEntries >= _TRUNC_CACHE_MAX) { _truncCacheReset(); byText = undefined } + if (byText === undefined) { byText = new Map(); _truncCache.set(maxW, byText) } + byText.set(text, out) + _truncCacheEntries++ + return out + } + + /** The measuring half of truncText - only reached on a cache miss. */ + function _truncMeasure(ctx, text, maxW, len) { // For truncation, always measure accurately - the monospace estimate can // over-count for non-ASCII characters (Arabic, CJK, etc.) that fall back // to a narrower font, leaving an apparent gap after the ellipsis. @@ -4182,10 +4248,35 @@ import FilterX from "@lucide/svelte/icons/filter-x"; firstColIdx++ } + // Pinned columns, collected once per frame. Null rather than an empty array + // so drawBodyRow can skip the loop with a single check. + /** @type {typeof geom.cols | null} */ + let pinnedCols = null + for (const col of geom.cols) { + if (!col.pinned) continue + if (!pinnedCols) pinnedCols = [] + pinnedCols.push(col) + } + const bodyC = { cFg, cText, cMuted, cGrid, cBorder, cMutedBg, cRing, cAccent, cPanel, usedW, navName, AMBER, BLUE_FG, RED, cPrimary, frozenW, tableStyle, dotSize, vSeps, firstColIdx, rangeColNames, rangeFirstCol, rangeLastCol, rangeR0, rangeR1, + // Frame-constant snapshots of the reactive values the per-cell loops read. + // Every entry below was previously read straight off the reactive graph + // inside drawCell, i.e. once per cell per frame. A $derived read is not a + // property access - it re-checks the derived against each of its + // dependencies' write versions - and there were ~20 of them per cell, so a + // 150-cell viewport paid ~3000 graph reads a frame for values that cannot + // change mid-frame. Read once here, then drawCell only touches this object. + cols: geom.cols, pinnedCols, scrollLeft: _scrollLeft, viewportWidth: W, + padX: CELL_PAD_X, iconHit: ICON_HIT, zoom: canvasZoom, fonts: _fonts, + nameToActualIdx: _nameToActualIdx, colCache: _colCache, + editingCell, focusedRow, hoveredRow, hoveredColName, focusColName, selectedCols, + hasPendingEdits, pendingEdits, editedRowSet: _editedRowSet, + colStats: _colStats, extActive: _extActive, colTransformFns: _colTransformFns, + searchLower: _searchLower, nullishOn: _nullishOn, rows, + alignAll: $appTableAlign === 'right', } const bodyTopY = Math.max(0, _scrollTop - HEADER_H - insertRowOffset) @@ -4448,20 +4539,26 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // frame's precomputed first visible index (c.firstColIdx) and break once a // column starts past the right viewport edge, so neither off-screen tail is // iterated per row (matters for very wide tables). - for (let ci = c.firstColIdx; ci < geom.cols.length; ci++) { - const col = geom.cols[ci] + // `cols`, `sl` and `vw` are read off the frame context rather than the + // reactive graph: `geom` is a $derived, and `geom.cols.length` in the loop + // condition re-entered it once per column per row. + const cols = c.cols + const sl = c.scrollLeft + for (let ci = c.firstColIdx, nc = cols.length, vpw = c.viewportWidth; ci < nc; ci++) { + const col = cols[ci] if (col.pinned) continue - const dx = col.contentX - _scrollLeft - if (dx >= _viewportWidth) break + const dx = col.contentX - sl + if (dx >= vpw) break if (dx + col.w <= 0) continue drawCell(ctx, idx, col, dx, ry, rh, c) } - // Pinned cells on top (frozen left). - for (const col of geom.cols) { - if (!col.pinned) continue - const dx = colDrawnX(col, geom, _scrollLeft) - drawCell(ctx, idx, col, dx, ry, rh, c, true) + // Pinned cells on top (frozen left). c.pinnedCols is null when nothing is + // pinned - the common case - so no row pays for a scan of every column. + if (c.pinnedCols) { + for (const col of c.pinnedCols) { + drawCell(ctx, idx, col, colDrawnX(col, geom, sl), ry, rh, c, true) + } } drawRowGutters(ctx, idx, -_scrollLeft, ry, rh, c) @@ -4574,7 +4671,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // collected into ONE path and stroked once - instead of a beginPath/stroke // per cell. This collapses ~(cols+3) draw-call flushes per row down to one, // the single biggest scroll-perf win alongside O(1) text measurement. - const vw = _viewportWidth + const vw = c.viewportWidth // Vertical separators were collected once for the frame; the row only chooses // how to render them per the active grid-style preset. All branches stay a // single batched path/fill, so this is O(visible cols) no matter the row count. @@ -4612,8 +4709,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; function drawCell(ctx, idx, col, cellX, ry, rh, c, pinned = false) { const w = col.w - const actualIdx = _nameToActualIdx.get(col.name) ?? -1 - const cached = _colCache[actualIdx] + const actualIdx = c.nameToActualIdx.get(col.name) ?? -1 + const cached = c.colCache[actualIdx] if (pinned) { ctx.fillStyle = c.cPanel @@ -4622,33 +4719,34 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (_rowBg) { ctx.fillStyle = _rowBg; ctx.fillRect(cellX, ry, w, rh) } } - const editing = editingCell && editingCell.rowIdx === idx && editingCell.colIdx === actualIdx - const staged = (hasPendingEdits && _editedRowSet.has(idx)) ? pendingEdits.get(idx + ':' + actualIdx) : undefined + const ec = c.editingCell + const editing = ec && ec.rowIdx === idx && ec.colIdx === actualIdx + const staged = (c.hasPendingEdits && c.editedRowSet.has(idx)) ? c.pendingEdits.get(idx + ':' + actualIdx) : undefined const isDirty = !!staged - const value = staged ? staged.value : rows[idx]?.[actualIdx] + const value = staged ? staged.value : c.rows[idx]?.[actualIdx] const isNull = value === null || value === undefined const isJson = !isNull && typeof value === 'object' const fk = cached?.fk ?? null const activeFk = fk && !isNull - const isFocusedCell = focusedRow === idx && c.navName === col.name && !editing + const isFocusedCell = c.focusedRow === idx && c.navName === col.name && !editing // Extension render directive (badges, tints, masks, links, swatches, …). // Computed once per cell and merged across enabled formatters; null/JSON // cells skip it entirely so the common path allocates nothing. let _ctxArg - if (_colStats) { _statsCtx.stats = _colStats.get(actualIdx); _ctxArg = _statsCtx } - const dir = (_extActive && !isNull && !isJson) + if (c.colStats) { _statsCtx.stats = c.colStats.get(actualIdx); _ctxArg = _statsCtx } + const dir = (c.extActive && !isNull && !isJson) ? formatCellValue(value, cached?.colType ?? '', col.name, _ctxArg) : null // Cell background tints. if (!editing) { // Column-selection band (drawn first so other tints layer on top). - if (selectedCols.has(col.name)) { + if (c.selectedCols.has(col.name)) { ctx.fillStyle = withAlpha(c.cPrimary, 0.08); ctx.fillRect(cellX, ry, w, rh) } // Focused-column band (drawn first so per-cell tints layer on top). - if (focusColName !== null && col.name === focusColName) { + if (c.focusColName !== null && col.name === c.focusColName) { ctx.fillStyle = withAlpha(c.cPrimary, 0.1); ctx.fillRect(cellX, ry, w, rh) } if (isDirty) { ctx.fillStyle = withAlpha(c.AMBER, 0.15); ctx.fillRect(cellX, ry, w, rh) } @@ -4687,14 +4785,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (editing) return // text drawn by the DOM overlay - const rowHover = hoveredRow === idx - const isHover = rowHover && hoveredColName === col.name - const canExpand = (cached?.canEdit ?? false) && !cached?.enumValues && !isBooleanType(cached?.colType ?? '') + const rowHover = c.hoveredRow === idx + const isHover = rowHover && c.hoveredColName === col.name + const canExpand = (cached?.canEdit ?? false) && !cached?.enumValues && !cached?.isBool const cy = ry + rh / 2 // A per-column transform (chosen from the header menu) renders live and wins // over formatter directives; skipped for staged/editing cells. - const colTf = (!staged && rows[idx]) ? _colTransformFns[col.name] : undefined + const colTf = (!staged && c.rows[idx]) ? c.colTransformFns[col.name] : undefined // Avatar / image thumbnail transform - draw the image itself, not text. if (colTf && _IMG_TF.has(colTf.id) && !isNull && isImageUrl(value)) { drawCellImage(ctx, String(value), cellX, ry, w, rh, cy, colTf.id === 'avatar', c) @@ -4704,12 +4802,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Cell text - directive display wins; masked cells reveal on hover. const revealed = dir?.mask && isHover // SQL array columns render pgAdmin-style ({a,b}); jsonb arrays stay JSON. - const isArrayCol = Array.isArray(value) && isSqlArrayType(cached?.colType) - const isVectorCol = typeof value === "string" && isVectorType(cached?.colType) + const isArrayCol = Array.isArray(value) && !!cached?.isArrayType + const isVectorCol = typeof value === "string" && !!cached?.isVector // Geometry gets the same treatment as vectors: the type + SRID (+ point // coords) read better than a row of EWKT. geometrySummary only inspects // the header, so it is cheap enough for the draw path. - const isGeomCol = typeof value === "string" && isGeometryType(cached?.colType) + const isGeomCol = typeof value === "string" && !!cached?.isGeom const text = colTf ? colTransformText(idx, actualIdx, value, colTf) : dir @@ -4720,11 +4818,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ? vectorDisplay(value, vectorHeads(w)) : isGeomCol ? geometrySummary(value) - : staged || !rows[idx] + : staged || !c.rows[idx] ? displayCell(value) : cellDisplayText(idx, actualIdx, value) // NULL glyph when the Empty & NULL Markers plugin is on (formatters skip null). - const shownText = (isNull && _nullishOn) ? '∅' : text + const shownText = (isNull && c.nullishOn) ? '∅' : text // Text color - directive link/fg may override (but never over a stronger // dirty/fk/focused state highlight). @@ -4739,48 +4837,48 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // status pill is a left-anchored object; sliding it to the right edge would // read as a layout bug rather than an alignment choice. const alignRight = - !dir?.badge && !dir?.swatch && !dir?.dot && isRightAlignedColumn(actualIdx) + !dir?.badge && !dir?.swatch && !dir?.dot && (cached ? cached.alignRight : c.alignAll) - const warnW = dir?.warn ? Math.round(14 * canvasZoom) : 0 + const warnW = dir?.warn ? Math.round(14 * c.zoom) : 0 // The hover actions live on the side the value isn't using - right of // left-aligned text, left of right-aligned text - so they occupy empty // space in both cases. Only long values, the ones that would actually // collide, give up room, and only while the pointer is in the cell. - const hoverW = isHover ? ICON_HIT + (canExpand ? ICON_HIT : 0) : 0 + const hoverW = isHover ? c.iconHit + (canExpand ? c.iconHit : 0) : 0 const fkW = (activeFk && rowHover) ? 20 : 0 // The same gap on both sides. Left-aligned text used to reserve 4px on the // assumption that a value never reaches the right edge - but a *truncated* // value reaches it every time, which put the ellipsis hard against the column // divider and made every long column read as congested. - const rightReserve = CELL_PAD_X + (alignRight ? 0 : hoverW) + fkW + warnW + const rightReserve = c.padX + (alignRight ? 0 : hoverW) + fkW + warnW const hoverLeftReserve = alignRight ? hoverW : 0 // Left-side decorations (color swatch / boolean dot) push the text right. - let textX = cellX + CELL_PAD_X + let textX = cellX + c.padX let leftPad = 0 if (dir?.swatch) { - const sw = Math.round(11 * canvasZoom) - roundRect(ctx, textX, cy - sw / 2, sw, sw, Math.round(2.5 * canvasZoom)) + const sw = Math.round(11 * c.zoom) + roundRect(ctx, textX, cy - sw / 2, sw, sw, Math.round(2.5 * c.zoom)) ctx.fillStyle = dir.swatch; ctx.fill() ctx.strokeStyle = withAlpha(c.cBorder, 0.6); ctx.lineWidth = 1 - roundRect(ctx, textX, cy - sw / 2, sw, sw, Math.round(2.5 * canvasZoom)); ctx.stroke() - leftPad = sw + Math.round(7 * canvasZoom) + roundRect(ctx, textX, cy - sw / 2, sw, sw, Math.round(2.5 * c.zoom)); ctx.stroke() + leftPad = sw + Math.round(7 * c.zoom) } else if (dir?.dot) { - const dr = Math.round(7 * canvasZoom) + const dr = Math.round(7 * c.zoom) ctx.fillStyle = dir.dot ctx.beginPath(); ctx.arc(textX + dr / 2, cy, dr / 2, 0, Math.PI * 2); ctx.fill() - leftPad = dr + Math.round(7 * canvasZoom) + leftPad = dr + Math.round(7 * c.zoom) } textX += leftPad + hoverLeftReserve - const textMaxW = w - CELL_PAD_X - leftPad - hoverLeftReserve - rightReserve + const textMaxW = w - c.padX - leftPad - hoverLeftReserve - rightReserve - ctx.font = _fonts.cell + ctx.font = c.fonts.cell ctx.textAlign = 'left' if (dir?.badge) { // Status pill - label inside a rounded, tinted capsule. - const padX = Math.round(7 * canvasZoom) + const padX = Math.round(7 * c.zoom) const label = truncText(ctx, shownText, Math.max(0, textMaxW - padX * 2)) - const pillH = Math.min(rh - Math.round(6 * canvasZoom), Math.round(17 * canvasZoom)) + const pillH = Math.min(rh - Math.round(6 * c.zoom), Math.round(17 * c.zoom)) const pillW = Math.min(Math.max(0, textMaxW), textWidth(ctx, label) + padX * 2) const py = ry + (rh - pillH) / 2 roundRect(ctx, textX, py, pillW, pillH, pillH / 2) @@ -4795,11 +4893,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const drawX = alignRight ? textX + Math.max(0, textMaxW - textWidth(ctx, drawn)) : textX - if (_searchLower && !isNull) drawSearchHighlights(ctx, drawn, drawX, ry, rh, c) + if (c.searchLower && !isNull) drawSearchHighlights(ctx, drawn, drawX, ry, rh, c) ctx.fillStyle = textColor ctx.fillText(drawn, drawX, cy + 0.5) if (dir?.link) { - const uy = cy + Math.round(7 * canvasZoom) + const uy = cy + Math.round(7 * c.zoom) const uw = Math.min(textWidth(ctx, drawn), Math.max(0, textMaxW)) ctx.strokeStyle = withAlpha(textColor, 0.5); ctx.lineWidth = 1 ctx.beginPath(); ctx.moveTo(drawX, uy); ctx.lineTo(drawX + uw, uy); ctx.stroke() @@ -4811,7 +4909,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // 0. Validation warning marker (amber dot). if (dir?.warn) { - const wr = Math.round(4 * canvasZoom) + const wr = Math.round(4 * c.zoom) ctx.fillStyle = withAlpha(c.AMBER, 0.95) ctx.beginPath(); ctx.arc(rx - wr, cy, wr, 0, Math.PI * 2); ctx.fill() rx -= warnW @@ -4824,14 +4922,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (alignRight) { let lx = cellX + 4 drawIcon(ctx, 'copy', lx + 5, cy - 7, 14, c.cMuted, 1.8) - lx += ICON_HIT + lx += c.iconHit if (canExpand) drawIcon(ctx, 'maximize-2', lx + 5, cy - 7, 14, c.cMuted, 1.8) } else { - drawIcon(ctx, 'copy', rx - ICON_HIT + 5, cy - 7, 14, c.cMuted, 1.8) - rx -= ICON_HIT + drawIcon(ctx, 'copy', rx - c.iconHit + 5, cy - 7, 14, c.cMuted, 1.8) + rx -= c.iconHit if (canExpand) { - drawIcon(ctx, 'maximize-2', rx - ICON_HIT + 5, cy - 7, 14, c.cMuted, 1.8) - rx -= ICON_HIT + drawIcon(ctx, 'maximize-2', rx - c.iconHit + 5, cy - 7, 14, c.cMuted, 1.8) + rx -= c.iconHit } } } From 98bd015bcc3c48d883dbb4833a895f141dc4a0b6 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 27 Aug 2026 17:50:33 +0545 Subject: [PATCH 03/14] perf(tauri): switch to mimalloc and optimize the IPC path in dev builds Opening one 1M-row table took the app from a 45MB/48MB idle baseline to 587MB on the Rust side and 891MB in the web process. Closing every tab released nothing, and neither did a full webview reload. For the Rust half the cause was visible in /proc/PID/smaps: four ~50MB glibc malloc arenas, one per worker thread, retained for the rest of the session. glibc does not hand those back, so the process never shrank after you moved on to a smaller table. mimalloc decommits pages it no longer needs and is faster on the allocation-heavy row-decode and JSON paths, which is most of what a browse does. Same open-scroll-seek workload measured after the swap: retained growth 527MB -> 27MB. Separately, the dev profile left the whole IPC path unoptimized. Every command result is serialised to JSON for the webview and a browse is megabytes of it; at opt-level 0 serde_json's generated code is roughly 20x slower than release, so a dev build spent most of a row fetch inside serialisation rather than in the database. serde_json, serde, itoa, ryu, the four sqlx crates, rust_decimal, chrono, uuid and base64 now build at opt-level 3 in dev, following the crypto block already in this file. They are all dependencies, so incremental rebuilds of our own code are unaffected. --- src-tauri/Cargo.lock | 21 ++++++++++++++++++++- src-tauri/Cargo.toml | 42 ++++++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 11 +++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 058d22eb..fb53aee1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -122,6 +122,7 @@ dependencies = [ "keyring", "libc", "log", + "mimalloc", "objc2", "objc2-app-kit", "objc2-quartz-core", @@ -2597,7 +2598,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.61.2", ] [[package]] @@ -3133,6 +3134,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "libredox" version = "0.1.16" @@ -3240,6 +3250,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9d8f3a11..f61d9600 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,11 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus futures = "0.3" sysinfo = { version = "0.33", default-features = false, features = ["system"] } libc = "0.2" +# Global allocator. glibc malloc keeps one ~64MB arena per worker thread and +# does not hand them back, so serialising one large result set left ~200MB of +# resident arenas behind for the rest of the session. mimalloc releases pages to +# the OS and is faster on the alloc-heavy JSON path. +mimalloc = { version = "0.1", default-features = false } chrono = { version = "0.4", features = ["serde"] } rust_decimal = { version = "1", features = ["serde"] } uuid = { version = "1", features = ["serde"] } @@ -108,3 +113,40 @@ opt-level = 3 opt-level = 3 [profile.dev.package.block-buffer] opt-level = 3 + +# ── Dev-profile IPC optimization ────────────────────────────────────────────── +# Every command result is serialised to JSON and handed to the webview, and a +# browse of a large table is megabytes of it. At opt-level 0 serde_json's +# generated code is ~20x slower than release, so a dev build spent most of a +# row fetch inside serialisation rather than in the database. These crates are +# dependencies, so raising them costs nothing on incremental rebuilds of our +# own code. +[profile.dev.package.serde_json] +opt-level = 3 +[profile.dev.package.serde] +opt-level = 3 +[profile.dev.package.itoa] +opt-level = 3 +[profile.dev.package.ryu] +opt-level = 3 +[profile.dev.package.mimalloc] +opt-level = 3 +[profile.dev.package.libmimalloc-sys] +opt-level = 3 +# Row decoding: sqlx reads every column of every row through these. +[profile.dev.package.sqlx-core] +opt-level = 3 +[profile.dev.package.sqlx-postgres] +opt-level = 3 +[profile.dev.package.sqlx-mysql] +opt-level = 3 +[profile.dev.package.sqlx-sqlite] +opt-level = 3 +[profile.dev.package.rust_decimal] +opt-level = 3 +[profile.dev.package.chrono] +opt-level = 3 +[profile.dev.package.uuid] +opt-level = 3 +[profile.dev.package.base64] +opt-level = 3 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0daea3c..7f761e4e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,14 @@ +// Global allocator. +// +// The default (glibc malloc) keeps a per-thread arena and does not return it to +// the OS. Serialising one large result set therefore left roughly 200MB of +// resident arenas behind for the rest of the session, and the process never +// shrank back after you moved on to a smaller table. mimalloc decommits pages it +// no longer needs and is measurably faster on the allocation-heavy row-decode +// and JSON paths, which is most of what a browse does. +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + mod app_lock; mod cloudflare; mod commands; From 023bd7a5560ef121e1f1560d33537490f1583fed Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 27 Aug 2026 17:50:43 +0545 Subject: [PATCH 04/14] fix(schema): dedupe in-flight reverse-FK loads and drop stale replies loadIncomingForeignKeys checked the cache before awaiting but only populated it once the reply came back, so the guard could not see a request that had been fired and not yet answered. Restoring a session therefore fired one reverse-FK query per restored tab at the same instant: measured nine concurrent calls totalling 37.7s of backend time against a D1 database, where each call is several HTTP round trips to Cloudflare. Everything the user was actually waiting for queued behind them. Concurrent callers now share one promise. A generation counter guards the write, because the cache is keyed by "schema.table" alone and a reply that lands after a connection change would otherwise serve one database's reverse FKs for a same-named table in another. The three cache resets go through one helper so the counter can never be missed. --- src/lib/components/StudioShell.svelte | 57 ++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 518fb711..2b0f90c8 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -3883,16 +3883,53 @@ let rowSearch = $state('') incomingFkCache = next } - /** Load incoming FKs for a table into the cache (no-op if already cached). */ + /** + * Requests already in flight, keyed the same way as the cache. + * + * The cache is only populated once the request comes back, so the `has(key)` + * guard below could not see a request that had been fired but not yet + * answered. Restoring a session therefore fired one reverse-FK query per + * restored tab at the same instant - measured at nine concurrent calls + * totalling 37s of backend time against a D1 database, where each call is + * several HTTP round trips to Cloudflare. Everything the user was actually + * waiting for queued behind them. + * @type {Map>} + */ + const _incomingFkInFlight = new Map() + /** Bumped whenever the cache is dropped, so a reply from before the drop is discarded. */ + let _incomingFkGen = 0 + + /** Load incoming FKs for a table into the cache (no-op if cached or in flight). */ async function loadIncomingForeignKeys(schema, table) { const key = `${schema}.${table}` if (incomingFkCache.has(key)) return - try { - const result = await getIncomingForeignKeys(schema, table) - setIncomingFkCache(key, result ?? []) - } catch { - setIncomingFkCache(key, []) - } + const pending = _incomingFkInFlight.get(key) + if (pending) return pending + const gen = _incomingFkGen + const p = (async () => { + /** @type {any[]} */ + let result = [] + try { + result = (await getIncomingForeignKeys(schema, table)) ?? [] + } catch { + result = [] + } + // The connection or schema may have changed while this was in flight; the + // cache is keyed by "schema.table" alone, so writing now would serve one + // database's reverse FKs for a same-named table in another. + if (gen !== _incomingFkGen) return + _incomingFkInFlight.delete(key) + setIncomingFkCache(key, result) + })() + _incomingFkInFlight.set(key, p) + return p + } + + /** Drop the reverse-FK cache and abandon anything still in flight for it. */ + function resetIncomingFkCache() { + incomingFkCache = new Map() + _incomingFkInFlight.clear() + _incomingFkGen++ } /** @@ -4256,7 +4293,7 @@ let rowSearch = $state('') * catalog to have changed. */ function invalidateCatalog() { _catalog.invalidate(connectionPrefix(persistConnectionId)) - incomingFkCache = new Map() + resetIncomingFkCache() } /** @param {{ force?: boolean }} [opts] */ @@ -5579,7 +5616,7 @@ let rowSearch = $state('') // resident forever). The ConnectionModal connect path lands here without // going through clearConnectionState, so reset them in both places. tableColumnsCache = new Map() - incomingFkCache = new Map() + resetIncomingFkCache() _catalog.clear() // The connection is live: render the shell NOW (setting `connection` above // dropped the reconnect overlay) with the welcome tab open and the sidebar @@ -6015,7 +6052,7 @@ let rowSearch = $state('') triggers = [] sequences = [] tableColumnsCache = new Map() - incomingFkCache = new Map() + resetIncomingFkCache() _catalog.clear() _sqlHintsLoadedFor = '' activeSchema = 'public' From f806311f97d3e2c615269d43807162cf22326860 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 27 Aug 2026 17:50:43 +0545 Subject: [PATCH 05/14] perf(scroll): resolve the reduced-motion query once, not per wheel tick createSmoothScroll called matchMedia('(prefers-reduced-motion: reduce)') from push(), so every wheel tick paid about 5us of style resolution on the one path that must not touch the style system. A MediaQueryList stays live on its own, so resolving it once is equivalent. --- src/lib/smooth-scroll.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/smooth-scroll.js b/src/lib/smooth-scroll.js index 48c5707a..469ef73d 100644 --- a/src/lib/smooth-scroll.js +++ b/src/lib/smooth-scroll.js @@ -63,9 +63,13 @@ export function createSmoothScroll(el) { const maxTop = () => Math.max(0, el.scrollHeight - el.clientHeight) const maxLeft = () => Math.max(0, el.scrollWidth - el.clientWidth) const clamp = (/** @type {number} */ v, /** @type {number} */ max) => (v < 0 ? 0 : v > max ? max : v) - /** Someone who asked for less motion gets none: jump, don't ease. */ - const reduceMotion = () => - typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + /** Someone who asked for less motion gets none: jump, don't ease. + * The query is resolved once and kept live by the MediaQueryList itself - + * calling matchMedia() per wheel tick cost ~5us of style resolution each time, + * on the one path that must not touch the style system. */ + const motionQuery = + typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null + const reduceMotion = () => motionQuery !== null && motionQuery.matches /** Adopt the element's current position - after a programmatic jump, a drag of * the scrollbar, or a keyboard scroll, our position is stale and would yank the From 8ae37c8dc72ab6653051fd1bd26a7bca603872d7 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 27 Aug 2026 17:50:43 +0545 Subject: [PATCH 06/14] docs: write up the performance pass and what is left Measurements, what changed and why, the items I deliberately left alone, and the things I ruled out so they do not get re-checked. Also records how to reproduce the profiling, since the instrumentation itself is not in the tree. --- TODO_TODAY.md | 199 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 TODO_TODAY.md diff --git a/TODO_TODAY.md b/TODO_TODAY.md new file mode 100644 index 00000000..93285815 --- /dev/null +++ b/TODO_TODAY.md @@ -0,0 +1,199 @@ +# TODO today: performance pass + +Working notes so I can pick this up on another machine. Branch: +`perf/render-and-memory`, based on `feat/advisor-and-large-table-performance` +(not on `master`, because these changes build on the advisor/windowing work +already on that branch). + +## Why I started + +The app felt laggy and low-framerate across the board, the data table worst of +all. I profiled it instead of guessing, using a temporary in-page probe that +POSTed frame times and canvas call counts to a local sink, plus a remote-eval +channel so I could drive the running app from the shell. All of that +instrumentation is removed again; nothing in this branch ships it. + +Test data: local Postgres on `127.0.0.1:5441` (`stroke_vec`), tables `events` +(1M rows), `articles` (100k), `openai_docs` (10k, pgvector embeddings). +Display is 60Hz, so the frame budget is 16.6ms. + +## What the numbers actually said + +The canvas render path was already well optimised for wide-but-plain tables. +`events` held a clean 60fps before I touched anything (p95 17ms, draw ~6.5ms). +So the general "everything is slow" feeling was not the row count. + +Two things were real and measured: + +1. **Text-heavy tables dropped frames.** `openai_docs` ran at p50 18ms / + p95 22ms / max 30ms, i.e. 45-55fps. `measureText` was being called 113 times + per frame, shaping 16,014 characters through HarfBuzz, costing 3.08ms of an + 11.7ms draw. It was recomputing the identical truncation every single frame. + +2. **Memory was never given back.** Opening one 1M-row table took the app from + 45MB/48MB (idle baseline, measured against a second dev instance) to 587MB + Rust / 891MB WebKit. Closing every tab released nothing. A full webview + reload released nothing. On the Rust side the cause was visible in + `/proc/PID/smaps`: four ~50MB glibc malloc arenas, one per worker thread, + retained for the rest of the session. + +## Done (in this branch) + +- **Vite dep-optimizer crash.** `canvas-confetti` is only reachable through a + lazy `await import()` on the license pages, so Vite's initial scan missed it. + Discovering it mid-session re-ran the optimizer, rehashed every pre-bundled + chunk, and the webview then 404'd on the old `runtime-*` / `index-client-*` / + `Icon-*` paths. Losing the Svelte runtime chunk killed every page at once. + Declared it in `optimizeDeps.include`. `vite.config.js`. + +- **Truncation cache.** `truncText` now memoises by (font, column width, + string) in `_truncCache`. Measured on `openai_docs`: `measureText` 113 -> 31 + calls/frame, 16,014 -> 1,521 chars/frame, 3.08ms -> 0.46ms; draw 11.7ms -> + 8.7ms; frames p50 18ms -> 17ms, p95 22ms -> 17ms, max 30ms -> 20ms, and only + 1-3 of 118 frames now exceed 18ms. This is the single biggest win here. + Deliberately survives a `rows` swap (that is what keeps windowed scrolling on + cache hits) and is dropped on a column change. `DataTable.svelte`. + +- **Per-column type predicates moved into `_colCache`.** `isBooleanType`, + `isSqlArrayType`, `isVectorType`, `isGeometryType` and the right-alignment + test were each running per cell per frame: four regexes and roughly six + throwaway strings per cell, about 40,000 regex executions a second while + scrolling, all of it a pure function of the column's declared type. + `DataTable.svelte`. + +- **Frame-constant draw context.** `drawCell` was reading about 20 values + straight off the reactive graph per cell. A `$derived` read is not a property + access, it re-checks the derived against every dependency's write version, so + a 150-cell viewport paid roughly 3,000 graph reads a frame for values that + cannot change mid-frame. They are now snapshotted once per frame into the + existing `bodyC` object. `DataTable.svelte`. + +- **Row loop invariants.** `geom.cols.length` was in the loop condition, so the + `geom` derived was re-entered once per column per row. The pinned-column pass + also scanned every column of every row even with nothing pinned; pinned + columns are now collected once per frame and the loop is skipped entirely when + the list is empty. `DataTable.svelte`. + +- **mimalloc as global allocator.** `src-tauri/src/lib.rs` + + `src-tauri/Cargo.toml`. Same open-scroll-seek workload as before, measured: + Rust retained growth **527MB -> 27MB**. This is the fix for the process never + shrinking after you move on from a big table. + +- **Dev-profile optimisation for the IPC path.** `serde_json`, `serde`, `itoa`, + `ryu`, the four `sqlx` crates, `rust_decimal`, `chrono`, `uuid`, `base64` now + build at `opt-level = 3` in the dev profile. Every command result is + serialised to JSON for the webview and a browse is megabytes of it; at + opt-level 0 that generated code is roughly 20x slower than release, so a dev + build spent most of a row fetch inside serialisation rather than in the + database. These are dependencies, so it costs nothing on incremental rebuilds + of our own code. Follows the existing crypto block in the same file. + +- **Reverse-FK request storm.** `loadIncomingForeignKeys` checked the cache + before awaiting but only populated it after, so restoring a session fired one + query per restored tab at the same instant. Measured nine concurrent calls + totalling 37.7s of backend time against D1, where each call is several HTTP + round trips to Cloudflare; everything the user was actually waiting for queued + behind them. Added in-flight dedup plus a generation counter so a reply from + before a connection change is discarded instead of writing another database's + FKs into the cache under the same `schema.table` key. `StudioShell.svelte`. + +- **`matchMedia` off the wheel path.** `createSmoothScroll` called + `matchMedia('(prefers-reduced-motion: reduce)')` on every wheel tick, about + 5us of style resolution each time, on the one path that must not touch the + style system. Resolved once and kept live by the MediaQueryList. + `smooth-scroll.js`. + +Verification so far: `npx vitest run` is 467/467 green, `npx vite build` +is clean, and the app runs with all of this in place. + +## Remaining + +Ordered by expected payoff. + +1. **Finish the Rust verification.** I stopped a `cargo check` partway to write + this up. `cargo check --no-default-features` and a `tauri:build` still need to + pass, and mimalloc needs a look on macOS and Windows before this merges. It + is a one-line global allocator swap so I expect it to be fine, but I have + only exercised it on Linux. + +2. **Scroll blitting, the big remaining render lever.** After the truncation + cache, `fillText` is what is left: 167 calls and 6,243 characters per frame, + 4.8ms of an 8.7ms draw, about 29us per call. That is Cairo rasterising glyphs + on the CPU and no amount of JS tuning touches it. The real fix is to stop + redrawing text that has not moved: on a vertical scroll of one or two rows, + `ctx.drawImage(canvas, 0, dy)` to blit the unchanged band and repaint only + the newly exposed strip. That should cut `fillText` from ~167/frame to ~10 + during a smooth scroll, i.e. most of the remaining draw cost. It needs + careful dirty-rect bookkeeping for hover highlights, the focused-cell ring + and sub-pixel offsets, which is why I did not start it under time pressure. + +3. **WebKit's idle memory, unexplained.** A fresh process with no table open and + the chunk prefetch disabled still sat at 370MB Rust / 836-1038MB WebKit, with + only 505 DOM nodes and 246 modules loaded. `smaps` shows it is all allocator + memory (234MB `[heap]` plus 346MB across 6,215 anonymous mappings), not + DOM/JS I can point at. I could not attribute this from outside the process + and I could not rule out my own probe contaminating it. Next step is a clean + run with no instrumentation at all, and a release build for comparison, since + a lot of it may just be dev-mode module overhead. + +4. **The 24-chunk startup prefetch** (`StudioShell.svelte`, the `warmers` + array). It unconditionally pulls Monaco, ECharts twice, marked+shiki twice, + mermaid, cytoscape, katex and 20-odd page components into memory whether or + not they are ever opened, and a warmed chunk can never be freed. That sits + oddly next to the idle-teardown code right above it, which unmounts views + after 3 minutes hidden specifically to reclaim memory. My attempt to measure + the cost was too noisy to be worth acting on, so I left it alone rather than + regress first-open latency on a guess. Worth doing properly: measure on a + release build, then make it engine-aware (no `RedisKeyspacePage` on a + Postgres connection) and trim the tail. + +5. **Monaco is importing far more than it uses.** `import * as monaco from + 'monaco-editor'` pulls every language contribution, which is why the bundle + carries `ts.worker` at 6.7MB, `css.worker` at 1MB, `html.worker` at 703KB and + a 2.5MB main chunk, plus 3.7MB of `monaco-themes`. The app only ever uses + sql, pgsql, json, javascript/typescript and rust. Importing + `monaco-editor/esm/vs/editor/editor.api` with an explicit language list would + cut this substantially. I skipped it because `MonacoTextView` takes its + language as a prop and I could not prove the full set of values it receives, + so getting it wrong silently breaks syntax highlighting somewhere. + +6. **Eased scrolling is the default** (`nativeScroll: false`). It puts a + non-passive wheel listener in front of every tick and animates `scrollTop` + from JS across roughly 20 frames per notch, reading `scrollHeight` each frame + (a forced layout, measured at 15us). The repo's own notes already flag this + mechanism as a stutter source. It measured small here, and it is a deliberate + product choice with a setting behind it, so I did not touch the default. It + is worth an A/B on a slower machine. + +7. **`[will-change:transform]` on nine DOM scroll containers.** The repo's own + DataTable notes say that promoting a scroller like this created a heavy + compositing layer that lagged vertical scroll, and it is the reason + `RowExpandViewer` has to portal its context menu out. No promoted layers were + live at idle when I checked, so I could not measure a cost and left it. Needs + a real before/after on a long list. + +## Things I ruled out, so nobody re-checks them + +- No CSS animations run at idle. `document.getAnimations()` returned 0 running, + so there is no continuous compositor churn from a stray spinner or shimmer. +- Only one element carries `will-change` at idle (the sidebar, + `scroll-position`), so the app is not drowning in composited layers. +- Byte-aware row windowing works. `events` fetched in 9 windows over a + scroll-and-seek workload rather than one giant response. +- The per-frame canvas call counts are fine outside text: 35 `fillRect` and 70 + `stroke` per frame. Rects and lines are not the problem, glyphs are. + +## How to reproduce the measurements + +The probe is gone, but it was small: a module imported first from `src/main.js` +that `fetch`-POSTed frame-time percentiles and canvas call counts to a local +python sink on 127.0.0.1:9911, and polled the same server for a JS snippet to +`new Function`-eval inside the page. That eval channel is what let me open +tables, dispatch synthetic `WheelEvent`s and read results back without touching +the GUI, which mattered because `hyprctl dispatch` is broken on this machine by +a lua plugin and no input tool could drive the window. + +To count canvas work, patch `CanvasRenderingContext2D.prototype` from the eval +channel (`measureText`, `fillText`, `fillRect`, `stroke`, and the `font` +setter), reset the counters, dispatch ~100 wheel events one per animation frame, +then divide by the draw count. Per-frame counts are the useful unit, not totals. From acd0823b16bb5bb99fc6e1cb49244e772df8cedd Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 31 Aug 2026 08:55:49 +0545 Subject: [PATCH 07/14] fix(catalog): drop catalog replies that belong to the previous connection Switching database could leave the sidebar, schema catalog and structure pane showing the database you just left. The loaders write whatever comes back from their await, and on a slow link or a slower machine the previous connection's reply lands after the new one is already up. The schema checks several of them already do are not a guard against this, and that is what made it look handled: clearConnectionState() resets activeSchema to 'public', so a Postgres-to-Postgres switch leaves `activeSchema === schema` true and the stale payload is applied anyway. Table and column names survive a switch too - two databases routinely hold a public.users - so those comparisons pass as well. It is not only what is on screen. The backend resolves the pool from a single global ACTIVE slot at execution time, and loadTables writes its result into the catalog cache, so a late reply could also be filed under whichever connection id happened to be current and served from there for the rest of its TTL. loadSchemas, loadTables, loadSchemaCatalog, reloadCatalogKind and loadStructure now capture persistConnectionId before their first await and drop the reply if it changed - the same idiom resolveRowCounts already uses through its cache key. loadTables guards its catch and its finally too, so a stale failure cannot write an error or clear the spinner the new connection's load just put up. Row loading needed nothing: its per-tab load token plus the resetTabs() in clearConnectionState already discard rows from a superseded connection. --- src/lib/components/StudioShell.svelte | 49 ++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 2b0f90c8..a432637e 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -3992,8 +3992,30 @@ let rowSearch = $state('') await openTableTab(refSchema, fk.referencedTable, { filters, resetQuery: true }) } + /** + * True when the reply to a request issued while `conn` was connected belongs + * to a database that is no longer the one on screen. + * + * Every catalog loader needs this, and the `activeSchema === schemaAtCall` + * check some of them already do is NOT a substitute: clearConnectionState() + * resets activeSchema to 'public', so switching between two PostgreSQL + * databases leaves that comparison true and a late reply from the old database + * is applied to the new one - wrong table list, wrong catalog, and a cache + * entry written under whichever connection id happened to be current. The + * window is invisible over a local socket and wide open on a slow link or a + * slower machine, which is exactly where it gets reported from. + * + * Capture `persistConnectionId` before the first await, compare after. + * @param {string} conn + */ + function connectionMoved(conn) { + return persistConnectionId !== conn + } + async function loadSchemas() { + const connAtCall = persistConnectionId const list = await listSchemas() + if (connectionMoved(connAtCall)) return schemas = list if (list.length === 0) { activeSchema = '' @@ -4032,6 +4054,7 @@ let rowSearch = $state('') const mySeq = ++_structureSeq const targetSchema = activeSchema const targetTable = activeTable + const connAtCall = persistConnectionId const driver = dbType // 'postgres' | 'mysql' | 'sqlite' | 'd1' try { const s = targetSchema.replace(/'/g, "''") @@ -4134,7 +4157,10 @@ let rowSearch = $state('') ]) } - if (activeTable === targetTable && activeSchema === targetSchema) { + // Table and schema names both survive a connection switch - two databases + // routinely hold a `public.users` - so the connection has to be checked on + // its own or one database's columns get shown for the other's table. + if (activeTable === targetTable && activeSchema === targetSchema && !connectionMoved(connAtCall)) { structureColumns = rows.map((row) => ({ ordinalPosition: Number(row[0]) || 0, name: String(row[1] ?? ''), @@ -4148,6 +4174,7 @@ let rowSearch = $state('') } } catch (e) { if (String(e).includes('Query cancelled')) return + if (connectionMoved(connAtCall)) return toast.error('Could not load table structure', { description: String(e) }) if (activeTable === targetTable) structureColumns = [] } finally { @@ -4248,11 +4275,15 @@ let rowSearch = $state('') if (force) _catalog.invalidate(key) const hit = /** @type {SchemaCatalog | undefined} */ (_catalog.get(key, CATALOG_TTL_MS)) if (hit) { applySchemaCatalog(hit); return } + const connAtCall = persistConnectionId const [idx, enm, trg, seq] = await Promise.all([ fetchIndexes(schema), fetchEnums(schema), fetchTriggers(schema), fetchSequences(schema), ]) // The schema can change while four requests are in flight; a late payload - // must not overwrite the catalog of wherever the user has since gone. + // must not overwrite the catalog of wherever the user has since gone. The + // connection can change too, and that check has to be separate - the schema + // is 'public' on both sides of a Postgres-to-Postgres switch. + if (connectionMoved(connAtCall)) return const payload = { indexes: idx, enums: enm, triggers: trg, sequences: seq } _catalog.set(key, payload) if (activeSchema === schema) applySchemaCatalog(payload) @@ -4264,11 +4295,12 @@ let rowSearch = $state('') async function reloadCatalogKind(kind) { const schema = activeSchema if (!schema) return + const connAtCall = persistConnectionId const fetched = kind === 'indexes' ? await fetchIndexes(schema) : kind === 'enums' ? await fetchEnums(schema) : kind === 'triggers' ? await fetchTriggers(schema) : await fetchSequences(schema) - if (activeSchema !== schema) return + if (activeSchema !== schema || connectionMoved(connAtCall)) return if (kind === 'indexes') indexes = fetched else if (kind === 'enums') enums = fetched else if (kind === 'triggers') triggers = fetched @@ -4306,6 +4338,7 @@ let rowSearch = $state('') // Captured once: activeSchema can change while the fetch is in flight, and // the background count pass must target the schema this list came from. const schemaAtCall = activeSchema + const connAtCall = persistConnectionId const key = catalogKey(persistConnectionId, 'tables', schemaAtCall) if (force) invalidateCatalog() const cached = force ? undefined : /** @type {any[] | undefined} */ (_catalog.get(key, TABLE_LIST_TTL_MS)) @@ -4320,6 +4353,11 @@ let rowSearch = $state('') error = '' try { const list = await listTables(schemaAtCall) + // Dropped, not applied: writing `tables` now would put the previous + // database's tables in the sidebar of the one just connected, and the + // _catalog.set below would file them under whichever connection id is + // current - poisoning that connection's cache for the whole TTL. + if (connectionMoved(connAtCall)) return tables = list .map((t) => ({ name: t.name ?? t.table_name ?? '', @@ -4333,10 +4371,13 @@ let rowSearch = $state('') activeTable = tables[0]?.name ?? null } } catch (e) { + if (connectionMoved(connAtCall)) return error = String(e) tables = [] } finally { - loadingTables = false + // A stale reply must not clear the spinner the *new* connection's load + // just put up, so this is guarded too - `return` above still runs it. + if (!connectionMoved(connAtCall)) loadingTables = false } } // The list arrives with unknown (null) counts so it renders immediately; From 6dd747976aecb0a1929d4f97bc2de9503586673f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 31 Aug 2026 08:56:10 +0545 Subject: [PATCH 08/14] perf(rows): reuse the row count across page changes Paging through a filtered table re-ran COUNT(*) on every page for a number that was already on screen. A page change cannot change how many rows match the predicate, so every one of those counts after the first was work the server carried for an answer we had. Measured on the 1M-row `events` fixture with a `kind LIKE '%click%'` filter: the COUNT(*) is 182ms cold and 31.8ms warm, against 0.79ms for the page of rows it accompanies - roughly 40x the query it was riding along with, and on a remote database another round trip on top. It never showed up as a blocked paint because the count is already deferred (rows paint first, the count streams in behind them), only as load nobody could see. The unfiltered path was already handled: it takes the pg_class.reltuples estimate above 100k rows rather than counting. This is the filtered half, where the count has to be exact and so cannot be estimated - only reused. A count is now remembered against the view it was actually counted for - connection, schema, table and predicate together. The predicate alone would not do; two tables routinely carry the same filter, and reusing one's count for the other would show the wrong number of rows. Reuse means the -1 "counting" sentinel a non-counting load returns can no longer be written over a known total, or every page change would blank it and force the count this avoids. Anything that can change how many rows match clears the record instead: a filtered insert (that branch does not adjust the total itself - whether the new row matches is the database's call), hand-written DML, and a cell edit while a filter is active, which can move a row in or out of the result. Deletes and unfiltered inserts already adjust the total correctly in place and need nothing. --- src/lib/components/StudioShell.svelte | 53 +++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index a432637e..e32627d4 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -4494,6 +4494,24 @@ let rowSearch = $state('') } /** The predicate `total` belongs to; '' until a count lands. */ let _totalSig = $state('') + /** + * The view a known `total` was actually counted for: connection, schema, + * table and predicate together. The predicate alone is not enough - two + * tables routinely carry the same filter, and reusing one's count for the + * other would show the wrong number of rows. + * @param {string} schema @param {string} table @param {string} sig + */ + function rowCountKey(schema, table, sig) { + return `${persistConnectionId}\u0000${schema}\u0000${table}\u0000${sig}` + } + /** Set only when a count lands; cleared by anything that can change how many + * rows match. While it matches the view being loaded, the count on screen is + * the answer and the background COUNT(*) is skipped. */ + let _countedView = '' + /** Row data may have changed under the current predicate - count again. */ + function invalidateRowCount() { + _countedView = '' + } const rowPredicateSig = $derived(predicateSig(apiSearch(rowSearch), rowFilters)) /** `total` was counted under the search + filters now on screen. */ const totalIsForThisView = $derived(total > 0 && _totalSig === rowPredicateSig) @@ -5144,6 +5162,8 @@ let rowSearch = $state('') // Frozen with the query: the count this load produces belongs to THIS // predicate, even if the user edits the filters while it is in flight. const ownerSig = predicateSig(apiSearch(rowSearch), rowFilters) + // Identity of the view this load is for, for reusing a count across pages. + const viewKey = rowCountKey(ownerSchema, ownerTable, ownerSig) const data = await getTableRows( ownerSchema, ownerTable, wantsWindow ? WINDOW_PROBE : effectivePageSize, @@ -5326,7 +5346,12 @@ let rowSearch = $state('') _infiniteRows = fetched // total = -1 means "counting" (Postgres, non-blocking). Other engines // return a real total here; refreshRowCount() then no-ops for them. - total = Number(data.total ?? 0) + // When the count for this exact view is already known, keep it rather + // than dropping back to the sentinel: without this, every page change + // would blank the total and force the COUNT(*) the skip below exists to + // avoid, and the footer would flicker through "counting" on each page. + const incoming = Number(data.total ?? 0) + total = incoming >= 0 ? incoming : (_countedView === viewKey ? total : incoming) _totalSig = ownerSig } queryMs = ranMs @@ -5344,7 +5369,18 @@ let rowSearch = $state('') } // Fire-and-forget: fill the total in the background so the count never // delays the rows. Windowed loads already have a real total from the fetch. - if (!windowed) void refreshRowCount(ownerTabId, seq, ownerSchema, ownerTable, ownerQuery, ownerSig) + // A page change cannot change how many rows match the predicate, so the + // count already on screen is the answer. Re-asking is a full COUNT(*) per + // page: measured on the 1M-row `events` fixture with a `kind LIKE` filter + // at 182ms cold / 32ms warm, against 0.79ms for the page of rows it + // accompanies - and on a remote database it is another round trip on top. + // The count is background work, so this never showed up as a blocked + // paint, only as load the server was carrying for an answer we had. + // `keepScroll` is the live refresh: rows may have changed underneath, so + // that always recounts. + if (!windowed && (keepScroll || _countedView !== viewKey)) { + void refreshRowCount(ownerTabId, seq, ownerSchema, ownerTable, ownerQuery, ownerSig, viewKey) + } // Mirror the landed result into the owning tab so the spinner in the tab // strip stops and a later switch away/back doesn't refetch it. patchTableTab(ownerTabId ?? '', { loadingRows: false, error: '' }) @@ -5387,7 +5423,7 @@ let rowSearch = $state('') * @param {string | null} tabId @param {number} seq @param {string} schema * @param {string} table @param {{ search?: string, searchIsRegex?: boolean, filters?: any[] }} query */ - async function refreshRowCount(tabId, seq, schema, table, query, sig = '') { + async function refreshRowCount(tabId, seq, schema, table, query, sig = '', viewKey = '') { if (!table) return try { const n = await countTableRows(schema, table, query) @@ -5397,6 +5433,7 @@ let rowSearch = $state('') if (tabId !== activeTabId) return total = n _totalSig = sig + _countedView = viewKey const maxPage = Math.max(1, Math.ceil(total / effectivePageSize) || 1) if (page > maxPage) page = maxPage } @@ -6475,6 +6512,9 @@ let rowSearch = $state('') saveActiveTabState() toast.success('Row inserted') } else { + // This branch does not adjust `total` (whether the new row matches the + // active filter is the database's call), so the count has to be redone. + invalidateRowCount() await loadRows() toast.success('Row inserted', { description: hasActiveFilters @@ -6500,6 +6540,9 @@ let rowSearch = $state('') const _start = Date.now() try { await executeSqlMulti(sql) + // Hand-written DML: rows may have been inserted or deleted, or edited so + // they no longer match the active filter. Nothing here can be assumed. + invalidateRowCount() recordActivity({ type: 'row_save', title: `Applied edited SQL${activeTable ? ` on ${activeTable}` : ''}`, schema: activeSchema, table: activeTable ?? '', durationMs: Date.now() - _start, success: true }) await loadRows() toast.success('Changes applied') @@ -6530,6 +6573,10 @@ let rowSearch = $state('') // rows is $state.raw, so the in-place assignment above doesn't notify the // canvas; bump dataVersion to force the grid to repaint the edited cell. dataVersion++ + // With a filter or search active, the edited value may have moved this row + // in or out of the result - so the total is no longer known to be right. + // Unfiltered, editing a cell cannot change how many rows there are. + if (rowSearch.trim() !== '' || activeFilters(rowFilters).length > 0) invalidateRowCount() saveActiveTabState() recordActivity({ type: 'row_save', title: `Updated ${col.name} in ${activeTable}`, schema: activeSchema, table: activeTable, durationMs: Date.now() - _saveStart, success: true }) } catch (e) { From d7a12f7a9dd65a1de820c31d802fcc09a4105390 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 31 Aug 2026 08:56:10 +0545 Subject: [PATCH 09/14] perf(startup): warm only the chunks this connection can open The idle prefetch pulled all 24 lazy page chunks in whatever the engine, and a warmed chunk is never freed again - which sat badly next to the idle-teardown directly above it, that unmounts views after 3 minutes hidden to reclaim memory. Measured this time, on a release build, against the manifest's static import graph (warming a chunk pulls its static imports; its own dynamic imports stay lazy). The eager entry graph is 2.71MB/25 chunks and the warm set adds 6.01MB/49 - but the shape is not what the earlier guess assumed. 5.35MB of it is two entries: SqlConsole drags in monaco at 3.78MB and AiChat the markdown/highlight stack at 1.57MB. The other 22 pages cost 0.66MB between them, 0.01-0.11MB each. So trimming the rarely-opened tail buys almost nothing and would regress first-open latency on 21 pages to save half a megabyte; it stays, and the measurement is now in the comment so it does not get re-derived. What the numbers do justify is the engine gate. On a Redis connection every relational page is unreachable UI - monaco included - so about 4.3MB of that 6.01MB was being pinned for tabs that do not exist. Each entry is now gated on the same isRedis / hasSchemaExplorer / hasSecurity flags the tab affordances use, RedisKeyspacePage warms only on Redis and goes first there, and the pump waits for a connection: before one exists the engine is unknown and no tab can be opened anyway. --- src/lib/components/StudioShell.svelte | 81 +++++++++++++++++---------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index e32627d4..38a51d1e 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -5767,41 +5767,60 @@ let rowSearch = $state('') // Warm the lazy page/panel chunks during browser idle time so the first // navigation to a tab is instant instead of paying a cold chunk fetch+parse. - // These chunks pull in heavy deps (monaco, echarts, marked+shiki), so we warm - // ONE per idle slot - never blocking interaction. Ordered by how commonly each - // is opened; the monaco-backed editors come first since they dominate latency. - // If the user opens a page sooner, import() dedups to the same promise and - // resolves immediately. Fire-and-forget; failures are harmless. + // We warm ONE per idle slot - never blocking interaction. Ordered by how + // commonly each is opened; the monaco-backed editors come first since they + // dominate latency. If the user opens a page sooner, import() dedups to the + // same promise and resolves immediately. Fire-and-forget; failures are harmless. + // + // Measured on a release build against the manifest's static import graph - + // warming a chunk pulls its static imports, its own dynamic imports stay lazy. + // The eager entry graph is 2.71MB/25 chunks; the full warm set adds 6.01MB/49. + // But 5.35MB of that is two entries: SqlConsole drags in monaco (3.78MB) and + // AiChat the markdown/highlight stack (1.57MB). The other 22 pages cost 0.66MB + // between them, 0.01-0.11MB each - so trimming that tail buys nothing and only + // costs first-open latency, which is why it is all still here. + // + // What is worth skipping is whatever this engine cannot open at all. A warmed + // chunk is never freed again (which already sits badly beside the idle-teardown + // above), and on a Redis connection every relational page is unreachable UI - + // monaco included, so ~4.3MB of the 6.01MB was being pinned for tabs that do + // not exist. Hence the gate per entry, and hence waiting for a connection: + // before one exists the engine is unknown and no tab can be opened anyway. // // Keep these specifiers identical to the {#await import('./X.svelte')} blocks // below so Vite resolves them to the same chunk. - onMount(() => { - const warmers = [ - () => import('./SqlConsole.svelte'), // monaco - () => import('./AiSidebar.svelte'), // marked + shiki - () => import('./AiChat.svelte'), // marked + shiki - () => import('./OrmRunner.svelte'), // monaco - () => import('./TableJsonView.svelte'), // monaco - data view mode - () => import('./TableTextView.svelte'), // monaco - data view mode - () => import('./StructureView.svelte'), - () => import('./SchemaPage.svelte'), - () => import('./ChartsPage.svelte'), // echarts - () => import('./DashboardPage.svelte'), - () => import('./SecurityPage.svelte'), - () => import('./SearchPage.svelte'), - () => import('./InstanceInsightsPage.svelte'), - () => import('./ObjectsPage.svelte'), - () => import('./DiagramsPage.svelte'), // echarts - () => import('./EntityRelationPage.svelte'), - () => import('./DataDiffPage.svelte'), // monaco - () => import('./NotebookEditor.svelte'), - () => import('./JsonViewerPage.svelte'), - () => import('./ExtensionsPage.svelte'), - () => import('./BackupPage.svelte'), - () => import('./LogsPage.svelte'), - () => import('./SchemaTimelinePage.svelte'), - () => import('./RedisKeyspacePage.svelte'), + $effect(() => { + if (!connection) return + // [reachable on this engine, loader]. Gates mirror the tab affordances in + // the empty-state grid and the palette; keep them in sync when a page moves. + /** @type {Array<[boolean, () => Promise]>} */ + const candidates = [ + [isRedis, () => import('./RedisKeyspacePage.svelte')], // the only page Redis has + [!isRedis, () => import('./SqlConsole.svelte')], // monaco + [true, () => import('./AiSidebar.svelte')], // marked + shiki + [true, () => import('./AiChat.svelte')], // marked + shiki + [!isRedis, () => import('./OrmRunner.svelte')], // monaco + [!isRedis, () => import('./TableJsonView.svelte')], // monaco - data view mode + [!isRedis, () => import('./TableTextView.svelte')], // monaco - data view mode + [!isRedis, () => import('./StructureView.svelte')], + [hasSchemaExplorer, () => import('./SchemaPage.svelte')], + [!isRedis, () => import('./ChartsPage.svelte')], // echarts + [!isRedis, () => import('./DashboardPage.svelte')], + [hasSecurity, () => import('./SecurityPage.svelte')], + [!isRedis, () => import('./SearchPage.svelte')], + [!isRedis, () => import('./InstanceInsightsPage.svelte')], + [!isRedis, () => import('./ObjectsPage.svelte')], + [!isRedis, () => import('./DiagramsPage.svelte')], // echarts + [!isRedis, () => import('./EntityRelationPage.svelte')], + [!isRedis, () => import('./DataDiffPage.svelte')], // monaco + [!isRedis, () => import('./NotebookEditor.svelte')], + [!isRedis, () => import('./JsonViewerPage.svelte')], + [!isRedis, () => import('./ExtensionsPage.svelte')], + [!isRedis, () => import('./BackupPage.svelte')], + [true, () => import('./LogsPage.svelte')], + [!isRedis, () => import('./SchemaTimelinePage.svelte')], ] + const warmers = candidates.filter(([reachable]) => reachable).map(([, load]) => load) const ric = window.requestIdleCallback ?? ((/** @type {Function} */ fn) => setTimeout(() => fn(), 200)) const cancel = window.cancelIdleCallback ?? clearTimeout let i = 0 From 2f595995fa94547d06cb8d5b448ff4d570c47ee0 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 31 Aug 2026 08:56:35 +0545 Subject: [PATCH 10/14] perf(grid): blit the unchanged band on a vertical scroll After the truncation cache, fillText was the whole remaining draw cost: 167 calls and 6,243 glyphs per frame, 4.8ms of an 8.7ms draw, about 29us a call. That is Cairo rasterising glyphs on the CPU, and no amount of JS tuning reaches it. The only way past it is to stop redrawing text that has not moved. On a frame that differs from the last only by a vertical scroll, the body is a rigid translation of what is already on the canvas: one drawImage moves the still-valid band and only the strip the scroll uncovered is repainted, so a one-row step paints one row instead of every visible one. Nothing new was needed to know when that is legal. The scroll loop already separated a position-only frame from a content change through _loopNeedsDraw, which scheduleDraw sets for every content, hover, focus, selection, theme and geometry change; the loop offers draw() a delta only when that flag is clear and the horizontal offset is unchanged. draw() adds the geometric half of the precondition - the body is not a rigid translation while skeletons animate, while an insert draft adds a band above row 0, or when an expanded row makes row heights non-uniform. The copy runs with the transform reset, source and destination the same size, so it is an exact 1:1 move of whole device pixels. Going through the DPR-scaled transform instead would let a fractional viewport size or a 1.5x DPR make source and destination differ by a fraction of a pixel; drawImage would then resample the band, and because each frame copies the previous one that blur compounds over a scroll into visibly smeared text. This is the trap in the approach, and the guards on dy and the header offset landing on whole device pixels are what keep it exact. The strip is rounded outward and the repainted band is widened a pixel at each edge, so a separator or focus ring bleeding outside its own row cannot leave a seam. emitVisibleRange still runs on every frame, blit or not, because row windowing depends on it; _imgWanted is left to accumulate rather than cleared, so a thumbnail that merely survived the scroll is not dropped. The loop now forces a full repaint when scrolling stops. Any artifact this path could produce therefore lives only while the content is moving and is gone within one frame of the user letting go - it can never persist in a resting grid. Verified arithmetically rather than by eye: across 2,280 combinations of DPR, header height, viewport height and dy, every body pixel in the new frame is either copied from exactly the right source row or falls inside the repainted strip. Watching a real scroll is still owed. --- src/lib/components/DataTable.svelte | 144 ++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 9 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 3d1bde08..7f44b563 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -3544,6 +3544,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Repaint requested (via scheduleDraw) while the scroll loop owns the frame - // the loop draws it instead of a second rAF double-painting the same frame. let _loopNeedsDraw = false + // Vertical content delta, in CSS px, for a frame that differs from the last by + // NOTHING BUT a vertical scroll - draw() may then blit the unchanged band + // instead of repainting it (see the blit block there). Only the scroll loop + // ever sets this, and draw() zeroes it on entry, so every other path into + // draw() is a full repaint by construction. + let _blitDy = 0 function startScrollLoop() { _scrollLoopDeadline = performance.now() + 200 @@ -3556,8 +3562,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (!el || !_ctx || _fatalError || performance.now() > _scrollLoopDeadline) { _scrollLoopId = 0 _isScrolling = false - // A repaint requested on the loop's last frame must not be dropped. - if (_loopNeedsDraw) { _loopNeedsDraw = false; scheduleDraw() } + _loopNeedsDraw = false + // Always repaint in full once scrolling stops, even if nothing asked for + // it. The blit path below reuses pixels from the previous frame, so this + // is what guarantees the view the user is left looking at was rendered + // from scratch - any artifact a blit could produce lives only while the + // content is still moving, and never in a resting grid. + _blitDy = 0 + scheduleDraw() return } // Snap to whole CSS pixels. The canvas is sticky-pinned at the viewport's @@ -3567,8 +3579,19 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const st = Math.round(el.scrollTop) const sl = Math.round(el.scrollLeft) if (st !== _loopLastTop || sl !== _loopLastLeft || _loopNeedsDraw) { + const prevVirt = _scrollTop _physScrollTop = st _scrollTop = physToVirt(st) + // Offer draw() a blit only when this frame is a pure vertical move of the + // last one: same horizontal offset, a previous frame to copy from, and no + // repaint pending (scheduleDraw sets _loopNeedsDraw for every content, + // hover, focus, selection, theme and geometry change, so that flag is the + // single "something other than position changed" signal). draw() applies + // the remaining, geometric preconditions. + _blitDy = + sl === _loopLastLeft && _loopLastTop >= 0 && !_loopNeedsDraw + ? _scrollTop - prevVirt + : 0 _scrollLeft = sl _loopLastTop = st _loopLastLeft = sl @@ -4110,9 +4133,53 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const ctx = _ctx const read = _readColor if (!ctx || !read || !canvasEl || !colorProbe) return + + // ── Scroll blitting ────────────────────────────────────────────────────── + // After the truncation cache, `fillText` is the whole remaining draw cost: + // measured at 167 calls / 6,243 glyphs per frame, 4.8ms of an 8.7ms draw, + // ~29us a call. That is Cairo rasterising glyphs on the CPU and no amount of + // JS tuning reaches it. The only way past it is to stop redrawing text that + // has not moved - on a frame that differs from the last one *only* by a + // vertical scroll, the body is a rigid translation of what is already on the + // canvas, so one drawImage moves the still-valid band and we repaint just the + // strip the scroll uncovered. A one- or two-row step then paints one or two + // rows instead of every visible one. + // + // The scroll loop opens the gate (pure vertical move, no repaint pending); + // everything below is the geometric half of the precondition - the body must + // be a rigid translation, which it is not when skeleton rows are animating, + // when an insert draft adds a band above row 0, when an expanded row makes + // row heights non-uniform, or when dy is fractional (reachable only on a + // table large enough for the scroll range to be compressed), which would land + // text on half-pixels and smear it. `_sawSkeleton` is still the *previous* + // frame's value here; it is reset a few lines down. + const dy = _blitDy + _blitDy = 0 + const blitBodyH = Math.max(0, _viewportHeight - HEADER_H) + // Backing-store pixels per CSS pixel. Both the header offset and the scroll + // delta have to land on whole device pixels for the copy to be exact; at the + // usual DPR of 1 or 2 they always do, and on a fractional DPR we simply fall + // back to a full repaint for the frames where they do not. + const blitScale = _viewportWidth > 0 ? ctx.canvas.width / _viewportWidth : 0 + const canBlit = + dy !== 0 && + Number.isInteger(dy) && + Math.abs(dy) < blitBodyH && + blitScale > 0 && + Number.isInteger(dy * blitScale) && + Number.isInteger(HEADER_H * blitScale) && + !_sawSkeleton && + !newRowDrafts && + expandedRows.size === 0 && + visibleColumns.length > 0 + // Rebuilt as cells paint; pumpCellImages() below uses it to fetch only what // is actually on screen and to protect those thumbnails from eviction. - _imgWanted.clear() + // A blit frame paints only the exposed strip, so clearing here would drop + // every thumbnail that merely survived the scroll; the set is instead left to + // accumulate and is rebuilt by the next full frame, which the loop guarantees + // arrives as soon as scrolling stops. + if (!canBlit) _imgWanted.clear() // Fonts are measured off the live DOM probe, so they already reflect the // app zoom (.text-ui-* sizes resolve against --app-font-size). The layout // constants (ROW_HEIGHT, HEADER_H, …) scale by the same canvasZoom factor, @@ -4154,11 +4221,56 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const RED = 'rgb(239, 68, 68)' ctx.imageSmoothingEnabled = false - // The panel colour is opaque and covers the whole canvas, so this single - // fill also clears the previous frame - a separate clearRect would just be a - // second full-surface pass (measurable on WebKitGTK's CPU-rendered canvas). - ctx.fillStyle = cPanel - ctx.fillRect(0, 0, W, H) + // Strip of body the scroll uncovered, in viewport px - the only part that + // needs rows painted on a blit frame. Empty (stripH 0) on a full frame. + let stripTop = 0 + let stripH = 0 + if (canBlit) { + // The copy is done in backing-store pixels with the transform reset, source + // and destination the same size, so it is always an exact 1:1 move of whole + // device pixels. Going through the DPR-scaled transform instead would let a + // fractional viewport size or a 1.5x DPR make source and destination differ + // by a fraction of a pixel - drawImage would then *resample* the band, and + // because each frame copies the previous one that blur would compound over a + // scroll into visibly smeared text. `blitScale` is read off the canvas + // rather than from devicePixelRatio because syncCanvasSurface caps it at 2. + const devH = ctx.canvas.height + const headDev = HEADER_H * blitScale + const kDev = dy * blitScale // integer: canBlit required it + // dy > 0 means the content moved up: keep the lower band, expose the bottom. + const srcTop = headDev + (kDev > 0 ? kDev : 0) + const dstTop = headDev + (kDev > 0 ? 0 : -kDev) + const keepDev = devH - Math.max(srcTop, dstTop) + ctx.save() + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.drawImage( + ctx.canvas, + 0, srcTop, ctx.canvas.width, keepDev, + 0, dstTop, ctx.canvas.width, keepDev, + ) + ctx.restore() + // Back in CSS px for the strip. Rounded outward so a fractional viewport + // height can only ever make us repaint a hair more than was uncovered, + // never leave a sliver of stale pixels behind. + if (kDev > 0) { + stripTop = Math.floor((dstTop + keepDev) / blitScale) + stripH = Math.ceil(H - stripTop) + } else { + stripTop = HEADER_H + stripH = Math.ceil(-dy) + } + // Clear only what the blit did not cover. The header repaints its own + // background (drawHeaderRow starts with a full-width fill), and everything + // between is either blitted or in the strip. + ctx.fillStyle = cPanel + ctx.fillRect(0, stripTop, W, stripH) + } else { + // The panel colour is opaque and covers the whole canvas, so this single + // fill also clears the previous frame - a separate clearRect would just be a + // second full-surface pass (measurable on WebKitGTK's CPU-rendered canvas). + ctx.fillStyle = cPanel + ctx.fillRect(0, 0, W, H) + } const usedW = Math.max(0, Math.min(W, geom.totalWidth - _scrollLeft)) const navName = focusedCol !== null ? navigableColumns[focusedCol]?.name : null @@ -4284,11 +4396,25 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let i = rowIndexAtY(rowTops, n, bodyTopY, ROW_HEIGHT) const firstVis = i let lastVis = i + // On a blit frame every row outside the exposed strip is already on the + // canvas, translated into place. A row straddling the strip edge is + // repainted whole: the half that was blitted is overpainted with identical + // pixels, which is cheaper than clipping and cannot leave a seam. The loop + // itself still runs to completion - emitVisibleRange drives row windowing + // and must see the true visible range on every frame, blit or not. + // Widened by a pixel at each edge: a row's separators and focus ring sit on + // half-pixel coordinates and can bleed one pixel outside its own band, so + // the neighbouring row is repainted too rather than risk a seam where the + // blit met the strip. Worst case that is one extra row at each end. + const repaintTop = stripTop - 1 + const repaintBot = stripTop + stripH + 1 for (; i < n; i++) { const ry = rowViewportY(i) if (ry >= H) break if (ry + ROW_HEIGHT <= HEADER_H) continue - drawBodyRow(ctx, i, ry, bodyC) + if (!canBlit || (ry < repaintBot && ry + ROW_HEIGHT > repaintTop)) { + drawBodyRow(ctx, i, ry, bodyC) + } lastVis = i } if (n > 0) emitVisibleRange(firstVis, Math.min(lastVis, n - 1)) From da8e9f911df8cea7985a151993c66f4a7391542b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 31 Aug 2026 08:56:35 +0545 Subject: [PATCH 11/14] perf(monaco): trim the editor entry to the languages the app has monaco-editor's default entry is an everything-bundle: all 80-odd basic languages plus the css, html, json and typescript services, each service dragging in its own web worker. The app displays six languages. The blocker last time was not being able to prove that, because MonacoTextView takes its language as a prop. It is provable. Only two call sites are dynamic: OrmSchemaPage passes sql, rust (Monaco has no Prisma grammar and the DSL reads correctly as Rust) or typescript, and TableTextView passes stroke-csv, stroke-tsv, markdown or json. Every other site passes a literal. The full set is sql, json, javascript, typescript, rust and markdown - markdown included, which the earlier note on this missed. src/lib/monaco.js composes the entry by hand out of the same pieces editor.main.js uses: edcore.main.js for the editor and every feature contribution with no languages, the six language contributions, and the json and typescript services republished onto monaco.languages.* by hand, which editor.main.js does itself and the contributions do not do for themselves. The basic-languages registrations for javascript and typescript have to stay even though the service also targets them - the service only hooks onLanguage, so without them it never fires. All eleven importers now go through this module. Measured over dist/assets: 34,821,807 -> 32,585,794 bytes, 2.24MB smaller, 81 chunks gone. css.worker at 1.05MB and html.worker at 719KB are among them, both of which were being emitted for grammars nothing in the app can open. ts.worker stays - OrmRunner genuinely uses languages.typescript.javascriptDefaults for its IntelliSense. Adding a language now means adding its contribution here. An unregistered language id does not error, it silently renders as plaintext, so that file is the first place to look if highlighting ever goes missing. --- src/lib/components/DataDiffPage.svelte | 2 +- src/lib/components/JsonCellLightbox.svelte | 2 +- src/lib/components/JsonViewer.svelte | 2 +- src/lib/components/JsonViewerPage.svelte | 2 +- src/lib/components/MonacoTextView.svelte | 2 +- src/lib/components/OrmRunner.svelte | 2 +- src/lib/components/SecuritySqlModal.svelte | 2 +- src/lib/components/SqlEditor.svelte | 2 +- src/lib/monaco-delimited.js | 2 +- src/lib/monaco-env.js | 2 +- src/lib/monaco-themes.js | 2 +- src/lib/monaco.js | 51 ++++++++++++++++++++++ 12 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 src/lib/monaco.js diff --git a/src/lib/components/DataDiffPage.svelte b/src/lib/components/DataDiffPage.svelte index 146a7d35..c0ff3a69 100644 --- a/src/lib/components/DataDiffPage.svelte +++ b/src/lib/components/DataDiffPage.svelte @@ -1,5 +1,5 @@