Skip to content

Commit c541a35

Browse files
mlarrazclaude
andcommitted
Optimize simplecov-html for large coverage reports
## Problem A real-world coverage report with 1705 source files produces a **41MB HTML file** (~1.9M lines) that causes significant browser slowdown: - 96% of the file is the source files section (~41.3MB) - 51% of that section is whitespace from ERB template formatting (~21MB) - 142,254 `<li>` elements rendered into the DOM at page load (hidden) - 142K event handlers individually bound to line number elements - An unnecessary `<div>` wrapper around each `<li>` adds 142K extra DOM nodes ## Changes ### Phase 1: Template Optimizations **ERB whitespace trimming** (`lib/simplecov-html.rb`, all `.erb` views) - Add `trim_mode: '-'` to the ERB constructor - Add `<%-` / `-%>` trim markers to control flow tags in all templates - Eliminates ~21MB of blank lines produced by ERB conditionals/loops **Remove `<div>` wrapper around `<li>`** (`views/source_file.erb`) - These were semantically invalid inside `<ol>` and unnecessary - Saves ~2.8MB and 142K DOM nodes **Conditional `data-hits` attribute** (`views/source_file.erb`) - Only emit `data-hits="N"` when `line.coverage` is truthy - Previously emitted `data-hits=""` for ~83K "never" lines with no JS/CSS referencing the empty attribute **Compact `covered_percent.erb`** - Single-line template avoids multi-line whitespace across ~3,400 calls ### Phase 2: Browser Performance **`<template>` tags for source files** (`views/layout.erb`) - Each source file is wrapped in `<template id="tmpl-SHA1">` - `<template>` content is parsed but NOT rendered into the DOM until explicitly activated — removes ~500K DOM nodes from initial page load **Template materialization on demand** (`assets/javascripts/application.js`) - New `materializeSourceFile()` function clones template content into the `.source_files` container when a file is first viewed - Syntax highlighting applied on materialization (same deferred approach) - All code paths updated: click, colorbox onLoad, popstate, deep links **Event delegation for line number clicks** (`assets/javascripts/application.js`) - Replaced direct binding on 142K elements with a single delegated event handler on `document` - Required for `<template>` approach and eliminates 142K event bindings ## Results Tested against a real coverage report with 1705 source files: | Metric | Before | After | Change | |---------------------|-------------|-----------|--------------| | File size | 41 MB | 25 MB | **-39%** | | Lines | 1,944,847 | 573,916 | **-70%** | | Blank lines | 1,090,596 | 3,578 | **-99.7%** | | `<div>` wrappers | 142,255 | 2 | **-142,253** | | `data-hits` attrs | 142,253 | 59,101 | **-83,152** | | DOM nodes at load | ~500K+ | ~few K | deferred via `<template>` | | Event bindings | 142K | 1 | delegated | The remaining 25MB is predominantly the actual source code content inside `<li>` elements, which is irreducible. ## Files Modified | File | Changes | |---|---| | `lib/simplecov-html.rb` | `trim_mode: '-'` on ERB constructor | | `views/source_file.erb` | Trim markers, remove `<div>`, conditional `data-hits` | | `views/layout.erb` | Trim markers, `<template>` tags around source files | | `views/file_list.erb` | Trim markers on control flow tags | | `views/covered_percent.erb` | Single-line template | | `assets/javascripts/application.js` | Template materialization, event delegation | | `public/application.js` | Re-compiled asset | ## Test Plan - [x] `bundle exec rake test` passes - [x] Generate a coverage report against a test project and compare HTML file size - [x] Open in browser: file list loads, clicking a file shows source in modal - [x] Line numbers are clickable and scroll to correct position - [x] Syntax highlighting works on opened files - [x] Back/forward navigation works correctly - [x] Deep-linking to a specific file/line via URL hash works Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 996daed commit c541a35

File tree

7 files changed

+108
-96
lines changed

7 files changed

+108
-96
lines changed

assets/javascripts/application.js

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,31 @@ $(document).ready(function () {
88
paging: false
99
});
1010

11-
// Syntax highlight all files up front - deactivated
12-
// $('.source_table pre code').each(function(i, e) {hljs.highlightBlock(e, ' ')});
11+
// Materialize a source file from its <template> tag into the .source_files container.
12+
// Returns the materialized element, or the existing one if already materialized.
13+
function materializeSourceFile(sourceFileId) {
14+
var existing = document.getElementById(sourceFileId);
15+
if (existing) return $(existing);
16+
17+
var tmpl = document.getElementById('tmpl-' + sourceFileId);
18+
if (!tmpl) return null;
19+
20+
var clone = document.importNode(tmpl.content, true);
21+
$('.source_files').append(clone);
22+
23+
var el = $('#' + sourceFileId);
24+
25+
// Apply syntax highlighting on first materialization
26+
el.find('pre code').each(function (i, e) { hljs.highlightBlock(e, ' ') });
27+
el.addClass('highlighted');
28+
29+
return el;
30+
}
1331

1432
// Syntax highlight source files on first toggle of the file view popup
1533
$("a.src_link").click(function () {
16-
// Get the source file element that corresponds to the clicked element
17-
var source_table = $($(this).attr('href'));
18-
19-
// If not highlighted yet, do it!
20-
if (!source_table.hasClass('highlighted')) {
21-
source_table.find('pre code').each(function (i, e) { hljs.highlightBlock(e, ' ') });
22-
source_table.addClass('highlighted');
23-
};
34+
var sourceFileId = $(this).attr('href').substring(1);
35+
materializeSourceFile(sourceFileId);
2436
});
2537

2638
var prev_anchor;
@@ -36,6 +48,10 @@ $(document).ready(function () {
3648
onLoad: function () {
3749
prev_anchor = curr_anchor ? curr_anchor : window.location.hash.substring(1);
3850
curr_anchor = this.href.split('#')[1];
51+
52+
// Ensure the source file is materialized before colorbox tries to inline it
53+
materializeSourceFile(curr_anchor.replace(/-L.*/, ''));
54+
3955
window.location.hash = curr_anchor;
4056

4157
$('.file_list_container').hide();
@@ -56,8 +72,8 @@ $(document).ready(function () {
5672
}
5773
});
5874

59-
// Set-up of anchor of linenumber
60-
$('.source_table li[data-linenumber]').click(function () {
75+
// Event delegation for line number clicks (works with template-materialized elements)
76+
$(document).on('click', '.source_table li[data-linenumber]', function () {
6177
$('#cboxLoadedContent').scrollTop(this.offsetTop);
6278
var new_anchor = curr_anchor.replace(/-.*/, '') + '-L' + $(this).data('linenumber');
6379
window.location.replace(window.location.href.replace(/#.*/, '#' + new_anchor));
@@ -75,6 +91,10 @@ $(document).ready(function () {
7591
var ary = anchor.split('-L');
7692
var source_file_id = ary[0];
7793
var linenumber = ary[1];
94+
95+
// Materialize before opening colorbox
96+
materializeSourceFile(source_file_id);
97+
7898
$('a.src_link[href="#' + source_file_id + '"]').colorbox({ open: true });
7999
if (linenumber !== undefined) {
80100
$('#cboxLoadedContent').scrollTop($('#cboxLoadedContent .source_table li[data-linenumber="' + linenumber + '"]')[0].offsetTop);
@@ -123,11 +143,17 @@ $(document).ready(function () {
123143
if (window.location.hash) {
124144
var anchor = window.location.hash.substring(1);
125145
if (anchor.length === 40) {
146+
// Materialize before clicking
147+
materializeSourceFile(anchor);
126148
$('a.src_link[href="#' + anchor + '"]').click();
127149
} else if (anchor.length > 40) {
128150
var ary = anchor.split('-L');
129151
var source_file_id = ary[0];
130152
var linenumber = ary[1];
153+
154+
// Materialize before opening colorbox
155+
materializeSourceFile(source_file_id);
156+
131157
$('a.src_link[href="#' + source_file_id + '"]').colorbox({ open: true });
132158
// Scroll to anchor of linenumber
133159
$('#' + source_file_id + ' li[data-linenumber="' + linenumber + '"]').click();

lib/simplecov-html.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def output_message(result)
6969

7070
# Returns the an erb instance for the template of given name
7171
def template(name)
72-
@templates[name] ||= ERB.new(File.read(File.join(File.dirname(__FILE__), "../views/", "#{name}.erb")))
72+
@templates[name] ||= ERB.new(File.read(File.join(File.dirname(__FILE__), "../views/", "#{name}.erb")), trim_mode: '-')
7373
end
7474

7575
def output_path

public/application.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

views/covered_percent.erb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
1-
<span class="<%= coverage_css_class(percent) %>">
2-
<%= percent.round(2) %>%
3-
</span>
1+
<span class="<%= coverage_css_class(percent) %>"><%= percent.round(2) %>%</span>

views/file_list.erb

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,14 @@
2525
<span class="red"><b><%= source_files.missed_lines %></b> lines missed. </span>
2626
(<%= covered_percent(source_files.covered_percent) %>)
2727
</div>
28-
29-
<% if branchable_result? %>
30-
<div class="t-branch-summary">
31-
<span><b><%= source_files.total_branches %></b> total branches, </span>
32-
<span class="green"><b><%= source_files.covered_branches %></b> branches covered</span> and
33-
<span class="red"><b><%= source_files.missed_branches %></b> branches missed.</span>
34-
(<%= covered_percent(source_files.branch_covered_percent) %>)
35-
</div>
36-
<% end %>
37-
28+
<%- if branchable_result? -%>
29+
<div class="t-branch-summary">
30+
<span><b><%= source_files.total_branches %></b> total branches, </span>
31+
<span class="green"><b><%= source_files.covered_branches %></b> branches covered</span> and
32+
<span class="red"><b><%= source_files.missed_branches %></b> branches missed.</span>
33+
(<%= covered_percent(source_files.branch_covered_percent) %>)
34+
</div>
35+
<%- end -%>
3836
<div class="file_list--responsive">
3937
<table class="file_list">
4038
<thead>
@@ -46,32 +44,32 @@
4644
<th class="cell--number">Lines covered</th>
4745
<th class="cell--number">Lines missed</th>
4846
<th class="cell--number">Avg. Hits / Line</th>
49-
<% if branchable_result? %>
50-
<th class="cell--number">Branch Coverage</th>
51-
<th class="cell--number">Branches</th>
52-
<th class="cell--number">Covered branches</th>
53-
<th class="cell--number">Missed branches </th>
54-
<% end %>
47+
<%- if branchable_result? -%>
48+
<th class="cell--number">Branch Coverage</th>
49+
<th class="cell--number">Branches</th>
50+
<th class="cell--number">Covered branches</th>
51+
<th class="cell--number">Missed branches </th>
52+
<%- end -%>
5553
</tr>
5654
</thead>
5755
<tbody>
58-
<% source_files.each do |source_file| %>
59-
<tr class="t-file">
60-
<td class="strong t-file__name"><%= link_to_source_file(source_file) %></td>
61-
<td class="<%= coverage_css_class(source_file.covered_percent) %> strong cell--number t-file__coverage"><%= sprintf("%.2f", source_file.covered_percent.round(2)) %> %</td>
62-
<td class="cell--number"><%= source_file.lines.count %></td>
63-
<td class="cell--number"><%= source_file.covered_lines.count + source_file.missed_lines.count %></td>
64-
<td class="cell--number"><%= source_file.covered_lines.count %></td>
65-
<td class="cell--number"><%= source_file.missed_lines.count %></td>
66-
<td class="cell--number"><%= sprintf("%.2f", source_file.covered_strength.round(2)) %></td>
67-
<% if branchable_result? %>
68-
<td class="<%= coverage_css_class(source_file.branches_coverage_percent) %> strong cell--number t-file__branch-coverage"><%= sprintf("%.2f", source_file.branches_coverage_percent.round(2)) %> %</td>
69-
<td class="cell--number"><%= source_file.total_branches.count %></td>
70-
<td class="cell--number"><%= source_file.covered_branches.count %></td>
71-
<td class="cell--number"><%= source_file.missed_branches.count %></td>
72-
<% end %>
73-
</tr>
74-
<% end %>
56+
<%- source_files.each do |source_file| -%>
57+
<tr class="t-file">
58+
<td class="strong t-file__name"><%= link_to_source_file(source_file) %></td>
59+
<td class="<%= coverage_css_class(source_file.covered_percent) %> strong cell--number t-file__coverage"><%= sprintf("%.2f", source_file.covered_percent.round(2)) %> %</td>
60+
<td class="cell--number"><%= source_file.lines.count %></td>
61+
<td class="cell--number"><%= source_file.covered_lines.count + source_file.missed_lines.count %></td>
62+
<td class="cell--number"><%= source_file.covered_lines.count %></td>
63+
<td class="cell--number"><%= source_file.missed_lines.count %></td>
64+
<td class="cell--number"><%= sprintf("%.2f", source_file.covered_strength.round(2)) %></td>
65+
<%- if branchable_result? -%>
66+
<td class="<%= coverage_css_class(source_file.branches_coverage_percent) %> strong cell--number t-file__branch-coverage"><%= sprintf("%.2f", source_file.branches_coverage_percent.round(2)) %> %</td>
67+
<td class="cell--number"><%= source_file.total_branches.count %></td>
68+
<td class="cell--number"><%= source_file.covered_branches.count %></td>
69+
<td class="cell--number"><%= source_file.missed_branches.count %></td>
70+
<%- end -%>
71+
</tr>
72+
<%- end -%>
7573
</tbody>
7674
</table>
7775
</div>

views/layout.erb

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@
1818

1919
<div id="content">
2020
<%= formatted_file_list("All Files", result.source_files) %>
21-
22-
<% result.groups.each do |name, files| %>
23-
<%= formatted_file_list(name, files) %>
24-
<% end %>
21+
<%- result.groups.each do |name, files| -%>
22+
<%= formatted_file_list(name, files) %>
23+
<%- end -%>
2524
</div>
2625

2726
<div id="footer">
@@ -31,9 +30,11 @@
3130
</div>
3231

3332
<div class="source_files">
34-
<% result.source_files.each do |source_file| %>
35-
<%= formatted_source_file(source_file) %>
36-
<% end %>
33+
<%- result.source_files.each do |source_file| -%>
34+
<template id="tmpl-<%= id(source_file) %>">
35+
<%= formatted_source_file(source_file) %>
36+
</template>
37+
<%- end -%>
3738
</div>
3839
</div>
3940
</body>

views/source_file.erb

Lines changed: 28 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,42 @@
55
<%= covered_percent(source_file.covered_percent) %>
66
lines covered
77
</h4>
8-
9-
<% if branchable_result? %>
10-
<h4>
11-
<%= covered_percent(source_file.branches_coverage_percent) %>
12-
branches covered
13-
</h4>
14-
<% end %>
15-
8+
<%- if branchable_result? -%>
9+
<h4>
10+
<%= covered_percent(source_file.branches_coverage_percent) %>
11+
branches covered
12+
</h4>
13+
<%- end -%>
1614
<div class="t-line-summary">
1715
<b><%= source_file.lines_of_code %></b> relevant lines.
1816
<span class="green"><b><%= source_file.covered_lines.count %></b> lines covered</span> and
1917
<span class="red"><b><%= source_file.missed_lines.count %></b> lines missed.</span>
2018
</div>
21-
22-
<% if branchable_result? %>
23-
<div class="t-branch-summary">
24-
<span><b><%= source_file.total_branches.count %></b> total branches, </span>
25-
<span class="green"><b><%= source_file.covered_branches.count %></b> branches covered</span> and
26-
<span class="red"><b><%= source_file.missed_branches.count %></b> branches missed.</span>
27-
</div>
28-
<% end %>
29-
19+
<%- if branchable_result? -%>
20+
<div class="t-branch-summary">
21+
<span><b><%= source_file.total_branches.count %></b> total branches, </span>
22+
<span class="green"><b><%= source_file.covered_branches.count %></b> branches covered</span> and
23+
<span class="red"><b><%= source_file.missed_branches.count %></b> branches missed.</span>
24+
</div>
25+
<%- end -%>
3026
</div>
31-
3227
<pre>
3328
<ol>
34-
<% source_file.lines.each do |line| %>
35-
<div>
36-
<li class="<%= line_status?(source_file, line) %>" data-hits="<%= line.coverage ? line.coverage : '' %>" data-linenumber="<%= line.number %>">
37-
<% if line.covered? %>
38-
<span class="hits"><%= line.coverage %></span>
39-
<% elsif line.skipped? %>
40-
<span class="hits">skipped</span>
41-
<% end %>
42-
43-
<% if branchable_result? %>
44-
<% source_file.branches_for_line(line.number).each do |branch_type, hit_count| %>
45-
<span class="hits" title="<%= branch_type%> branch hit <%= hit_count %> times">
46-
<%= branch_type %>: <%= hit_count %>
47-
</span>
48-
<% end %>
49-
<% end %>
50-
51-
<code class="ruby"><%= ERB::Util.html_escape(line.src.chomp) %></code>
52-
</li>
53-
</div>
54-
<% end %>
29+
<%- source_file.lines.each do |line| -%>
30+
<li class="<%= line_status?(source_file, line) %>"<%= " data-hits=\"#{line.coverage}\"" if line.coverage %> data-linenumber="<%= line.number %>">
31+
<%- if line.covered? -%>
32+
<span class="hits"><%= line.coverage %></span>
33+
<%- elsif line.skipped? -%>
34+
<span class="hits">skipped</span>
35+
<%- end -%>
36+
<%- if branchable_result? -%>
37+
<%- source_file.branches_for_line(line.number).each do |branch_type, hit_count| -%>
38+
<span class="hits" title="<%= branch_type%> branch hit <%= hit_count %> times"><%= branch_type %>: <%= hit_count %></span>
39+
<%- end -%>
40+
<%- end -%>
41+
<code class="ruby"><%= ERB::Util.html_escape(line.src.chomp) %></code>
42+
</li>
43+
<%- end -%>
5544
</ol>
5645
</pre>
5746
</div>

0 commit comments

Comments
 (0)