Skip to content

Commit 3de2abd

Browse files
committed
Modernize HTML report frontend
Replace the `(window as any)._simplecov*` global stash with a typed module-level `renderState` cache so the on-demand source-file materializer reads from a single object rather than four loosely typed properties. Add `defer` to both `application.js` and `coverage_data.js` so the parser does not block on the head script, and assign `content.innerHTML` once via `join` instead of `+=` in a loop (the latter forced an O(n^2) re-parse on reports with many groups). With `defer`, the `init()` fallback for "SIMPLECOV_DATA isn't ready yet" is unreachable and goes away. Bump `tsconfig.json` `lib` to ES2023 so the SHA-1 hex conversion in `hash.ts` can use `padStart` directly and `jumpToMissedLine` can use the native `Array.prototype.findLast` instead of a hand-rolled helper. Simplify `getVisibleChild`, `jumpToMissedLine`, and the dark-mode preflight into more idiomatic shapes, and rebuild the compiled assets under `public/`.
1 parent 31ff3b8 commit 3de2abd

6 files changed

Lines changed: 79 additions & 93 deletions

File tree

html_frontend/src/app.ts

Lines changed: 46 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -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

8899
const 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 {
535547
const sortState: Record<string, SortEntry> = {};
536548

537549
function 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

547554
function getSortValue(td: Element | null): number | string {
@@ -746,23 +753,24 @@ function updateCoverageCells(
746753
function 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.
10231026
async 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

html_frontend/src/hash.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@
1111
// upfront so the synchronous render path can look them up freely.
1212
export async function hash(str: string): Promise<string> {
1313
const bytes = new TextEncoder().encode(str);
14-
const buf = await crypto.subtle.digest('SHA-1', bytes);
15-
let out = '';
16-
for (const b of new Uint8Array(buf, 0, 4)) {
17-
out += ('0' + b.toString(16)).slice(-2);
18-
}
19-
return out;
14+
const digest = await crypto.subtle.digest('SHA-1', bytes);
15+
return Array.from(new Uint8Array(digest, 0, 4), (b) => b.toString(16).padStart(2, '0')).join('');
2016
}

html_frontend/src/index.html

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
<!DOCTYPE html>
22
<html lang="en">
33
<head>
4-
<meta charset="utf-8" />
5-
<meta name="viewport" content="width=device-width, initial-scale=1" />
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
66
<title>Code Coverage</title>
7-
<script src="application.js" type="text/javascript"></script>
8-
<link href="application.css" media="screen, projection, print" rel="stylesheet" type="text/css" />
9-
<script type="text/javascript">
10-
(function() {
11-
var pref = localStorage.getItem('simplecov-dark-mode');
12-
if (pref === 'dark') {
13-
document.documentElement.classList.add('dark-mode');
14-
} else if (pref === 'light') {
15-
document.documentElement.classList.add('light-mode');
16-
}
17-
})();
7+
<script src="application.js" defer></script>
8+
<link href="application.css" rel="stylesheet">
9+
<script>
10+
// Apply the saved dark/light preference before paint to avoid a flash.
11+
const pref = localStorage.getItem('simplecov-dark-mode');
12+
if (pref === 'dark' || pref === 'light') {
13+
document.documentElement.classList.add(`${pref}-mode`);
14+
}
1815
</script>
1916
</head>
2017

@@ -50,6 +47,6 @@
5047
<div class="source-dialog__body" id="source-dialog-body" tabindex="0"></div>
5148
</dialog>
5249

53-
<script src="coverage_data.js" type="text/javascript"></script>
50+
<script src="coverage_data.js" defer></script>
5451
</body>
5552
</html>

html_frontend/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"compilerOptions": {
33
"target": "ES2015",
4-
"lib": ["ES2015", "DOM"],
4+
"lib": ["ES2023", "DOM"],
55
"strict": true,
66
"noEmit": true
77
},

0 commit comments

Comments
 (0)