Skip to content

Commit b9c6c5d

Browse files
committed
perf: 03 sheet window, sort keys, delegation, incremental sheetView
03-3.1 Holdings and History render in 300-row windows that grow on scroll 03-3.3 sort keys are computed once per row, not per comparison 03-3.4 one delegated listener per event type; Watchlist appends chunks 03-3.9 sheet row objects are built once and reused as pages arrive
1 parent ff0700d commit b9c6c5d

1 file changed

Lines changed: 129 additions & 64 deletions

File tree

‎src/main.tsx‎

Lines changed: 129 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ const SORTS_KEY = 'wisdomtree-tab-sorts';
120120
const SITE_STATE_KEY = 'wisdomtree-site-state';
121121
const HOLDINGS_CONCURRENCY = 6; // bounded whole-catalog aggregation workers
122122
const WATCHLIST_CHUNK = 250; // rows per rendered Watchlist DOM chunk
123+
const SHEET_CHUNK = 300; // rows per rendered Holdings / History DOM chunk (loaded pages beyond it mount on scroll)
124+
let sheetChunkSig = '';
125+
let sheetRenderedCount = 0;
126+
let sheetShownTotal = 0; // filtered rows of the shown sheet at the last render (how far the DOM window can grow)
123127
const DEFAULT_SELECTED_TICKERS: string[] = []; // start clean: no pre-selected funds
124128

125129
const DETAIL_TABS: Array<{ key: string; label: string }> = [
@@ -1096,12 +1100,21 @@ function filteredCatalogFunds(): FundRow[] {
10961100
}
10971101

10981102
/** Sheet rows (holdings, history, distributions) as objects with the cells in col0..colN, as shown (search, sort, column filters). */
1103+
/** Row objects of a sheet are built once per source row and reused while pages are appended in place (see fetchNextSheetPage). */
1104+
const sheetRowObjects: WeakMap<string[][], any[]> = new WeakMap();
1105+
10991106
function sheetView(scope: string, headers: string[], sourceRows: string[][], numericHeaders: string[]): any[] {
1100-
return tableView(scope, sheetFilterColumns(headers, numericHeaders), sourceRows, '', () => sourceRows.map((row, sourceIndex) => {
1101-
const cells: Record<string, unknown> = { values: row, searchIndex: row.join(' ').toLowerCase(), rank: sourceIndex };
1102-
headers.forEach((header, index) => { cells[`col${index}`] = row[index] ?? ''; });
1103-
return cells;
1104-
}));
1107+
return tableView(scope, sheetFilterColumns(headers, numericHeaders), sourceRows, '', () => {
1108+
let built = sheetRowObjects.get(sourceRows);
1109+
if (!built || built.length > sourceRows.length) { built = []; sheetRowObjects.set(sourceRows, built); }
1110+
for (let sourceIndex = built.length; sourceIndex < sourceRows.length; sourceIndex++) {
1111+
const row = sourceRows[sourceIndex];
1112+
const cells: Record<string, unknown> = { values: row, searchIndex: row.join(' ').toLowerCase(), rank: sourceIndex };
1113+
for (let index = 0; index < headers.length; index++) cells[`col${index}`] = row[index] ?? '';
1114+
built.push(cells);
1115+
}
1116+
return built.slice();
1117+
});
11051118
}
11061119

11071120
// =========================================================================
@@ -2046,9 +2059,20 @@ function activeSheetTab(): 'holdings' | 'history' | null {
20462059
function maybeLoadMoreRows(): void {
20472060
const sheet = activeSheetTab();
20482061
if (!sheet) return;
2062+
if (growSheetChunk()) return; // loaded rows first, the network second
20492063
void loadNextSheetPage(sheet);
20502064
}
20512065

2066+
/** Mounts the next SHEET_CHUNK of already loaded sheet rows; false when every filtered row is in the DOM. */
2067+
function growSheetChunk(): boolean {
2068+
const sheet = activeSheetTab();
2069+
if (!sheet || sheetRenderedCount + SHEET_CHUNK >= sheetShownTotal) return false;
2070+
sheetRenderedCount += SHEET_CHUNK;
2071+
renderDetailTable(sheet);
2072+
renderStaticLoadSentinel();
2073+
return true;
2074+
}
2075+
20522076
function renderStaticLoadSentinel(): void {
20532077
const sheet = activeSheetTab();
20542078
if (!sheet || !state.activeFundTicker) {
@@ -2060,7 +2084,7 @@ function renderStaticLoadSentinel(): void {
20602084
el.staticLoadSentinel.classList.add('hidden');
20612085
return;
20622086
}
2063-
const more = entry.nextPage < entry.manifest.pages.length;
2087+
const more = entry.nextPage < entry.manifest.pages.length || sheetRenderedCount + SHEET_CHUNK < sheetShownTotal;
20642088
el.staticLoadSentinel.classList.toggle('hidden', !more);
20652089
el.staticLoadStatus.textContent = entry.loading ? 'Loading more rows…' : more ? 'Scroll or click to load more rows…' : '';
20662090
}
@@ -2499,18 +2523,38 @@ function compareValues(a: unknown, b: unknown): number {
24992523
return as.localeCompare(bs, undefined, { numeric: true });
25002524
}
25012525

2526+
const SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true });
2527+
const HISTORY_DATE_RE = /^\d{1,2}-[A-Za-z]{3}-\d{4}$/;
2528+
2529+
type SortKeys = { empty: boolean; num: number | null; text: string; date: number };
2530+
type SortKeyed = SortKeys & { row: any };
2531+
2532+
/** The comparison keys of one cell, computed once per row (not once per comparison). */
2533+
function sortKeyOf(value: unknown): SortKeys {
2534+
if (isEmptySortValue(value)) return { empty: true, num: null, text: '', date: NaN };
2535+
const num = numberOrNull(value);
2536+
const text = String(value ?? '');
2537+
const date = HISTORY_DATE_RE.test(text) ? Date.parse(text.replace(/-/g, ' ')) : NaN;
2538+
return { empty: false, num, text, date };
2539+
}
2540+
25022541
function sortRows(rows: any[]): any[] {
25032542
if (state.sortKey === 'rank') return rows;
25042543
const direction = state.sortDir === 'asc' ? 1 : -1;
25052544
const key = state.sortKey;
2506-
return [...rows].sort((a, b) => {
2507-
const av = sortValue(a, key);
2508-
const bv = sortValue(b, key);
2509-
const ae = isEmptySortValue(av);
2510-
const be = isEmptySortValue(bv);
2511-
if (ae || be) return ae && be ? 0 : ae ? 1 : -1;
2512-
return compareValues(av, bv) * direction;
2545+
const keyed: SortKeyed[] = new Array(rows.length);
2546+
for (let i = 0; i < rows.length; i++) keyed[i] = { row: rows[i], ...sortKeyOf(sortValue(rows[i], key)) };
2547+
keyed.sort((a, b) => {
2548+
if (a.empty || b.empty) return a.empty && b.empty ? 0 : a.empty ? 1 : -1; // unavailable values last in both directions
2549+
let order: number;
2550+
if (a.num !== null && b.num !== null) order = a.num - b.num;
2551+
else if (!Number.isNaN(a.date) && !Number.isNaN(b.date)) order = a.date - b.date; // History dates ("21-Aug-2026") chronologically
2552+
else order = SORT_COLLATOR.compare(a.text, b.text);
2553+
return order * direction;
25132554
});
2555+
const out: any[] = new Array(keyed.length);
2556+
for (let i = 0; i < keyed.length; i++) out[i] = keyed[i].row;
2557+
return out;
25142558
}
25152559

25162560
function isEmptySortValue(value: unknown): boolean {
@@ -2662,21 +2706,6 @@ function renderFundsTable(): void {
26622706
}).join('');
26632707
}
26642708

2665-
el.tableBody.querySelectorAll('input[data-checkbox]').forEach((checkbox: any) => {
2666-
checkbox.addEventListener('change', (event: any) => {
2667-
event.stopPropagation();
2668-
const ticker = checkbox.dataset.checkbox || '';
2669-
toggleFund(ticker);
2670-
});
2671-
checkbox.addEventListener('click', (event: any) => event.stopPropagation());
2672-
});
2673-
2674-
el.tableBody.querySelectorAll('button[data-blacklist]').forEach((button: any) => {
2675-
button.addEventListener('click', (event: any) => {
2676-
event.stopPropagation();
2677-
blacklistTickers([button.dataset.blacklist || '']);
2678-
});
2679-
});
26802709

26812710
renderCatalogStatus(rows);
26822711
}
@@ -2856,8 +2885,35 @@ function watchlistChunkSignature(rows: WatchlistRow[]): string {
28562885
function growWatchlistChunk(): void {
28572886
const rows = getVisibleWatchlistRows();
28582887
if (watchlistRenderedCount + WATCHLIST_CHUNK >= rows.length) return;
2888+
const from = watchlistRenderedCount + WATCHLIST_CHUNK;
28592889
watchlistRenderedCount += WATCHLIST_CHUNK;
2860-
renderWatchlistTable();
2890+
const moreRow = el.tableBody.querySelector('#watchlist-more-row');
2891+
if (!moreRow || watchlistChunkSignature(rows) !== watchlistChunkSig) { renderWatchlistTable(); return; } // rows changed: full render
2892+
const to = Math.min(rows.length, watchlistRenderedCount + WATCHLIST_CHUNK);
2893+
moreRow.insertAdjacentHTML('beforebegin', rows.slice(from, to).map((row, i) => watchlistRowHtml(row, from + i)).join(''));
2894+
const moreCell = moreRow.querySelector('td');
2895+
if (to >= rows.length) moreRow.remove();
2896+
else if (moreCell) moreCell.textContent = `Scroll or click to load more rows… (${rows.length - to} remaining)`;
2897+
}
2898+
2899+
/** One Watchlist row; shared by the full render and the append-only growWatchlistChunk. */
2900+
function watchlistRowHtml(row: WatchlistRow, index: number): string {
2901+
return `
2902+
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition border-b border-slate-100 dark:border-slate-700/30">
2903+
<td class="py-2.5 px-4 text-slate-400 dark:text-slate-500 text-xs text-center font-mono">${rowNumber(row, index)}</td>
2904+
<td class="watchlist-sticky-col watchlist-sticky-ticker py-2.5 px-4 font-mono font-semibold text-blue-600 dark:text-blue-400">${escapeHtml(row.symbol)}</td>
2905+
<td class="py-2.5 px-4 text-slate-700 dark:text-slate-300 font-medium" title="${escapeHtml(row.name)}">${escapeHtml(row.name || '—')}</td>
2906+
<td class="py-2.5 px-4 text-slate-700 dark:text-slate-300">
2907+
<div class="flex flex-wrap gap-1 max-w-md">
2908+
${row.funds.map((ticker: string) => `<a href="javascript:void(0)" data-activate-fund="${escapeHtml(ticker)}" title="Open ${escapeHtml(ticker)} detail tabs" class="font-mono text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800 rounded-full px-2 py-0.5 hover:underline">${escapeHtml(ticker)}</a>`).join('')}
2909+
</div>
2910+
</td>
2911+
<td class="py-2.5 px-4 text-right font-mono text-slate-700 dark:text-slate-300">${row.fundCount}</td>
2912+
<td class="py-2.5 px-4 text-right font-mono text-slate-700 dark:text-slate-300">${row.weightSum.toFixed(3)}%</td>
2913+
<td class="py-2.5 px-4 text-right font-mono text-slate-700 dark:text-slate-300">${row.maxWeight.toFixed(3)}%</td>
2914+
<td class="py-2.5 px-4 font-mono text-slate-600 dark:text-slate-400 text-xs">${escapeHtml(row.identifier || '—')}</td>
2915+
</tr>
2916+
`;
28612917
}
28622918

28632919
function renderWatchlistTable(): void {
@@ -2896,22 +2952,7 @@ function renderWatchlistTable(): void {
28962952
: 'Holdings data is not available yet. Run bun ./scripts/update-data.ts to refresh the WisdomTree feed.'
28972953
}${currentQuery() ? ' No rows match your search.' : ''}</td></tr>`;
28982954
} else {
2899-
let html = visibleRows.map((row, index) => `
2900-
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition border-b border-slate-100 dark:border-slate-700/30">
2901-
<td class="py-2.5 px-4 text-slate-400 dark:text-slate-500 text-xs text-center font-mono">${rowNumber(row, index)}</td>
2902-
<td class="watchlist-sticky-col watchlist-sticky-ticker py-2.5 px-4 font-mono font-semibold text-blue-600 dark:text-blue-400">${escapeHtml(row.symbol)}</td>
2903-
<td class="py-2.5 px-4 text-slate-700 dark:text-slate-300 font-medium" title="${escapeHtml(row.name)}">${escapeHtml(row.name || '—')}</td>
2904-
<td class="py-2.5 px-4 text-slate-700 dark:text-slate-300">
2905-
<div class="flex flex-wrap gap-1 max-w-md">
2906-
${row.funds.map((ticker: string) => `<a href="javascript:void(0)" data-activate-fund="${escapeHtml(ticker)}" title="Open ${escapeHtml(ticker)} detail tabs" class="font-mono text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800 rounded-full px-2 py-0.5 hover:underline">${escapeHtml(ticker)}</a>`).join('')}
2907-
</div>
2908-
</td>
2909-
<td class="py-2.5 px-4 text-right font-mono text-slate-700 dark:text-slate-300">${row.fundCount}</td>
2910-
<td class="py-2.5 px-4 text-right font-mono text-slate-700 dark:text-slate-300">${row.weightSum.toFixed(3)}%</td>
2911-
<td class="py-2.5 px-4 text-right font-mono text-slate-700 dark:text-slate-300">${row.maxWeight.toFixed(3)}%</td>
2912-
<td class="py-2.5 px-4 font-mono text-slate-600 dark:text-slate-400 text-xs">${escapeHtml(row.identifier || '—')}</td>
2913-
</tr>
2914-
`).join('');
2955+
let html = visibleRows.map((row, index) => watchlistRowHtml(row, index)).join('');
29152956
if (loading) {
29162957
html += `<tr><td colspan="8" class="py-3 text-center text-xs text-slate-400 dark:text-slate-500">Loading holdings of the remaining selected ETFs…</td></tr>`;
29172958
}
@@ -2921,14 +2962,6 @@ function renderWatchlistTable(): void {
29212962
el.tableBody.innerHTML = html;
29222963
}
29232964

2924-
el.tableBody.querySelectorAll('a[data-activate-fund]').forEach((link: any) => {
2925-
link.addEventListener('click', (event: any) => {
2926-
event.stopPropagation();
2927-
activateFund(link.dataset.activateFund || '');
2928-
});
2929-
});
2930-
const moreRow = el.tableBody.querySelector('#watchlist-more-row');
2931-
if (moreRow) moreRow.addEventListener('click', () => growWatchlistChunk());
29322965

29332966
const queryText = currentQuery() ? ` matching “${currentQuery()}”` : '';
29342967
setStatus(`Watchlist built from ${state.selected.size} selected ETF${state.selected.size === 1 ? '' : 's'}: ${rows.length} ticker${rows.length === 1 ? '' : 's'}${queryText}. Deduplicated by ticker, or by identifier for bond rows.${rankHint(rows.length)}`, 'success');
@@ -2991,6 +3024,11 @@ function renderSheetTable(fund: FundRow, sheet: 'holdings' | 'history'): void {
29913024

29923025
const headers = entry.headers;
29933026
const rows = sheetView(sheet, headers, entry.rows, NUMERIC_SHEET_HEADERS);
3027+
const chunkSig = [sheetKey(sheet), state.sortKey, state.sortDir, currentQuery(), columnFilterSig(sheet)].join('|');
3028+
if (chunkSig !== sheetChunkSig) { sheetChunkSig = chunkSig; sheetRenderedCount = 0; }
3029+
sheetShownTotal = rows.length;
3030+
const shownRows = rows.slice(0, sheetRenderedCount + SHEET_CHUNK);
3031+
const cellClasses = headers.map(header => NUMERIC_SHEET_HEADERS.includes(header) ? 'text-right font-mono text-slate-700 dark:text-slate-300' : 'text-slate-700 dark:text-slate-300');
29943032

29953033
el.tableHead.innerHTML = `
29963034
<tr>
@@ -3003,16 +3041,21 @@ function renderSheetTable(fund: FundRow, sheet: 'holdings' | 'history'): void {
30033041
if (!rows.length) {
30043042
el.tableBody.innerHTML = `<tr><td colspan="${headers.length + 1}" class="py-12 text-center text-slate-400 dark:text-slate-500">No rows match your search${entry.loading ? ' (still loading…)' : ''}.</td></tr>`;
30053043
} else {
3006-
el.tableBody.innerHTML = rows.map((row, index) => {
3044+
const parts: string[] = new Array(shownRows.length);
3045+
for (let index = 0; index < shownRows.length; index++) {
3046+
const row = shownRows[index];
30073047
const values: string[] = row.values || [];
3008-
return `
3009-
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition border-b border-slate-100 dark:border-slate-700/30">
3010-
<td class="py-2.5 px-4 text-slate-400 dark:text-slate-500 text-xs text-center font-mono">${rowNumber(row, index)}</td>
3011-
${values.map((cell, columnIndex) => `
3012-
<td class="py-2.5 px-4 ${NUMERIC_SHEET_HEADERS.includes(headers[columnIndex]) ? 'text-right font-mono text-slate-700 dark:text-slate-300' : 'text-slate-700 dark:text-slate-300'}">${escapeHtml(cell === '' ? '—' : cell)}</td>
3013-
`).join('')}
3014-
</tr>
3015-
`;}).join('');
3048+
let cells = '';
3049+
for (let columnIndex = 0; columnIndex < values.length; columnIndex++) {
3050+
const cell = values[columnIndex];
3051+
cells += `<td class="py-2.5 px-4 ${cellClasses[columnIndex] || 'text-slate-700 dark:text-slate-300'}">${escapeHtml(cell === '' ? '—' : cell)}</td>`;
3052+
}
3053+
parts[index] = `<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition border-b border-slate-100 dark:border-slate-700/30"><td class="py-2.5 px-4 text-slate-400 dark:text-slate-500 text-xs text-center font-mono">${rowNumber(row, index)}</td>${cells}</tr>`;
3054+
}
3055+
if (rows.length > shownRows.length) {
3056+
parts.push(`<tr id="sheet-more-row" class="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 transition"><td colspan="${headers.length + 1}" class="py-3 text-center text-xs text-slate-400 dark:text-slate-500">Scroll or click to show more rows… (${(rows.length - shownRows.length).toLocaleString('en-US')} loaded rows not shown yet)</td></tr>`);
3057+
}
3058+
el.tableBody.innerHTML = parts.join('');
30163059
}
30173060

30183061
el.tickerCount.textContent = fund.ticker;
@@ -4064,6 +4107,26 @@ function bindEvents(): void {
40644107
});
40654108
el.blacklistClearBtn.addEventListener('click', clearBlacklist);
40664109

4110+
// One delegated listener per event type instead of per-row listeners re-attached on every render.
4111+
el.tableBody.addEventListener('change', (event: any) => {
4112+
const target = event.target;
4113+
const checkbox = target && target.closest ? target.closest('input[data-checkbox]') : null;
4114+
if (!checkbox) return;
4115+
event.stopPropagation();
4116+
toggleFund(checkbox.dataset.checkbox || '');
4117+
});
4118+
el.tableBody.addEventListener('click', (event: any) => {
4119+
const target = event.target;
4120+
if (!target || !target.closest) return;
4121+
if (target.closest('input[data-checkbox]')) { event.stopPropagation(); return; }
4122+
const blacklist = target.closest('button[data-blacklist]');
4123+
if (blacklist) { event.stopPropagation(); blacklistTickers([blacklist.dataset.blacklist || '']); return; }
4124+
const link = target.closest('a[data-activate-fund]');
4125+
if (link) { event.stopPropagation(); activateFund(link.dataset.activateFund || ''); return; }
4126+
if (target.closest('#watchlist-more-row')) { growWatchlistChunk(); return; }
4127+
if (target.closest('#sheet-more-row')) growSheetChunk();
4128+
});
4129+
40674130
// Paginated sheets: append more rows as the sentinel scrolls into view.
40684131
if (typeof IntersectionObserver === 'function') {
40694132
const observer = new IntersectionObserver(
@@ -4085,10 +4148,12 @@ function bindEvents(): void {
40854148
}
40864149
const sheet = activeSheetTab();
40874150
if (!sheet || !state.activeFundTicker) return;
4151+
const distanceToBottom = el.tableScroll.scrollHeight - el.tableScroll.scrollTop - el.tableScroll.clientHeight;
4152+
if (distanceToBottom >= 600) return;
4153+
if (growSheetChunk()) return; // mount loaded rows before fetching the next page
40884154
const entry = sheetState.get(sheetKey(sheet));
40894155
if (!entry || entry.loading || entry.nextPage >= entry.manifest.pages.length) return;
4090-
const distanceToBottom = el.tableScroll.scrollHeight - el.tableScroll.scrollTop - el.tableScroll.clientHeight;
4091-
if (distanceToBottom < 600) void loadNextSheetPage(sheet);
4156+
void loadNextSheetPage(sheet);
40924157
}, { passive: true });
40934158

40944159
// Keep only the table scrolling: refit on viewport changes and whenever

0 commit comments

Comments
 (0)