@@ -83,6 +83,17 @@ declare global {
8383 }
8484}
8585
86+ // Module-level state populated by renderPage() and consumed by the
87+ // on-demand source-file materializer. Holding it here (typed) avoids
88+ // hanging caches off the global Window object.
89+ interface RenderState {
90+ idToFilename : Record < string , string > ;
91+ coverage : Record < string , FileCoverage > ;
92+ branchCoverage : boolean ;
93+ methodCoverage : boolean ;
94+ }
95+ let renderState : RenderState | null = null ;
96+
8697// --- Constants ------------------------------------------------
8798
8899const MAX_BAR_WIDTH = 240 ;
@@ -483,25 +494,26 @@ function renderPage(data: CoverageData): void {
483494
484495 if ( branchCoverage ) document . body . setAttribute ( 'data-branch-coverage' , 'true' ) ;
485496
486- // Content: file lists
497+ // Content: file lists. Building the full markup in memory and assigning
498+ // innerHTML once avoids the O(n^2) re-parse that `innerHTML += ...` in a
499+ // loop would trigger on reports with many groups.
487500 const content = document . getElementById ( 'content' ) ! ;
488- content . innerHTML = renderFileList ( 'All Files' , allFiles , data . total , data . coverage , branchCoverage , methodCoverage ) ;
489-
501+ const fileListSections = [
502+ renderFileList ( 'All Files' , allFiles , data . total , data . coverage , branchCoverage , methodCoverage ) ,
503+ ] ;
490504 for ( const groupName of Object . keys ( data . groups ) ) {
491505 const group = data . groups [ groupName ] ;
492- const groupFiles = group . files || [ ] ;
493- content . innerHTML += renderFileList ( groupName , groupFiles , group , data . coverage , branchCoverage , methodCoverage ) ;
506+ fileListSections . push (
507+ renderFileList ( groupName , group . files || [ ] , group , data . coverage , branchCoverage , methodCoverage )
508+ ) ;
494509 }
510+ content . innerHTML = fileListSections . join ( '' ) ;
495511
496- // Build id → filename lookup map for O(1) source file materialization
512+ // Cache the lookup map and coverage data so the on-demand source file
513+ // materializer can resolve an id back to its FileCoverage in O(1).
497514 const idToFilename : Record < string , string > = { } ;
498- for ( const fn of allFiles ) {
499- idToFilename [ fileId ( fn ) ] = fn ;
500- }
501- ( window as any ) . _simplecovIdMap = idToFilename ;
502- ( window as any ) . _simplecovFiles = data . coverage ;
503- ( window as any ) . _simplecovBranchCoverage = branchCoverage ;
504- ( window as any ) . _simplecovMethodCoverage = methodCoverage ;
515+ for ( const fn of allFiles ) idToFilename [ fileId ( fn ) ] = fn ;
516+ renderState = { idToFilename, coverage : data . coverage , branchCoverage, methodCoverage } ;
505517
506518 // Footer
507519 const timestamp = new Date ( meta . timestamp ) ;
@@ -535,13 +547,8 @@ interface SortEntry {
535547const sortState : Record < string , SortEntry > = { } ;
536548
537549function getVisibleChild ( row : Element , index : number ) : Element | null {
538- let count = 0 ;
539- for ( let i = 0 ; i < row . children . length ; i ++ ) {
540- if ( ( row . children [ i ] as HTMLElement ) . style . display === 'none' ) continue ;
541- if ( count === index ) return row . children [ i ] ;
542- count ++ ;
543- }
544- return null ;
550+ const visible = Array . from ( row . children ) . filter ( ( c ) => ( c as HTMLElement ) . style . display !== 'none' ) ;
551+ return visible [ index ] ?? null ;
545552}
546553
547554function getSortValue ( td : Element | null ) : number | string {
@@ -746,23 +753,24 @@ function updateCoverageCells(
746753function materializeSourceFile ( sourceFileId : string ) : HTMLElement | null {
747754 const existing = document . getElementById ( sourceFileId ) ;
748755 if ( existing ) return existing ;
756+ if ( ! renderState ) return null ;
749757
750- const idMap = ( window as any ) . _simplecovIdMap as Record < string , string > ;
751- const coverage = ( window as any ) . _simplecovFiles as Record < string , FileCoverage > ;
752- const branchCov = ( window as any ) . _simplecovBranchCoverage as boolean ;
753- const methodCov = ( window as any ) . _simplecovMethodCoverage as boolean ;
754-
755- const targetFilename = idMap [ sourceFileId ] ;
758+ const targetFilename = renderState . idToFilename [ sourceFileId ] ;
756759 if ( ! targetFilename ) return null ;
757760
758- const html = renderSourceFile ( targetFilename , coverage [ targetFilename ] , branchCov , methodCov ) ;
761+ const html = renderSourceFile (
762+ targetFilename ,
763+ renderState . coverage [ targetFilename ] ,
764+ renderState . branchCoverage ,
765+ renderState . methodCoverage ,
766+ ) ;
759767 const container = document . querySelector ( '.source_files' ) ! ;
760768 const wrapper = document . createElement ( 'div' ) ;
761769 wrapper . innerHTML = html ;
762770 const el = wrapper . firstElementChild as HTMLElement ;
763771 container . appendChild ( el ) ;
764772
765- $$ ( 'pre code' , el ) . forEach ( e => { hljs . highlightElement ( e as HTMLElement ) ; } ) ;
773+ $$ ( 'pre code' , el ) . forEach ( ( e ) => hljs . highlightElement ( e as HTMLElement ) ) ;
766774 return el ;
767775}
768776
@@ -866,21 +874,14 @@ function jumpToMissedLine(direction: 1 | -1): void {
866874 const lines = getMissedLines ( ) ;
867875 if ( ! lines . length ) return ;
868876
869- const scrollTop = dialogBody . scrollTop ;
870- const midpoint = scrollTop + dialogBody . clientHeight / 2 ;
877+ const midpoint = dialogBody . scrollTop + dialogBody . clientHeight / 2 ;
878+ // The -10 bias on the backward search keeps the currently-centered line
879+ // from counting as its own "previous" hit when we're sitting on it.
880+ const target = direction === 1
881+ ? lines . find ( ( li ) => li . offsetTop > midpoint ) || lines [ 0 ]
882+ : lines . findLast ( ( li ) => li . offsetTop < midpoint - 10 ) || lines [ lines . length - 1 ] ;
871883
872- if ( direction === 1 ) {
873- const next = lines . find ( li => li . offsetTop > midpoint ) ;
874- const target = next || lines [ 0 ] ;
875- dialogBody . scrollTop = target . offsetTop - dialogBody . clientHeight / 3 ;
876- } else {
877- let prev : HTMLElement | null = null ;
878- for ( let i = lines . length - 1 ; i >= 0 ; i -- ) {
879- if ( lines [ i ] . offsetTop < midpoint - 10 ) { prev = lines [ i ] ; break ; }
880- }
881- const target = prev || lines [ lines . length - 1 ] ;
882- dialogBody . scrollTop = target . offsetTop - dialogBody . clientHeight / 3 ;
883- }
884+ dialogBody . scrollTop = target . offsetTop - dialogBody . clientHeight / 3 ;
884885}
885886
886887// --- Source file dialog ----------------------------------------
@@ -1019,15 +1020,10 @@ function initDarkMode(): void {
10191020
10201021// --- Initialization -------------------------------------------
10211022
1022- // Wait for coverage data to be available, then render
1023+ // Render the coverage page. Both `application.js` and `coverage_data.js`
1024+ // use `defer`, so `coverage_data.js` is guaranteed to have populated
1025+ // `window.SIMPLECOV_DATA` by the time `DOMContentLoaded` fires.
10231026async function init ( ) : Promise < void > {
1024- if ( ! window . SIMPLECOV_DATA ) {
1025- // Data not loaded yet - the coverage_data.js script tag is at the end of body,
1026- // so if DOMContentLoaded fires first, wait for it
1027- window . addEventListener ( 'load' , init ) ;
1028- return ;
1029- }
1030-
10311027 const data = window . SIMPLECOV_DATA ;
10321028
10331029 // Show loading indicator
0 commit comments