@@ -120,6 +120,10 @@ const SORTS_KEY = 'wisdomtree-tab-sorts';
120120const SITE_STATE_KEY = 'wisdomtree-site-state' ;
121121const HOLDINGS_CONCURRENCY = 6 ; // bounded whole-catalog aggregation workers
122122const 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)
123127const DEFAULT_SELECTED_TICKERS : string [ ] = [ ] ; // start clean: no pre-selected funds
124128
125129const 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+
10991106function 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 {
20462059function 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+
20522076function 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 - Z a - 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+
25022541function 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
25162560function 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 {
28562885function 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
28632919function 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