From 4135529b4d722d33a6d12331f3c428432cc32749 Mon Sep 17 00:00:00 2001 From: Even Stensberg Date: Fri, 28 Aug 2026 13:14:02 +0200 Subject: [PATCH] feat: hidden modules The modules page gains a filter: a name substring or a /regexp/, and a switch for hiding node_modules. The state is shared with the module graph, so the table and the graph show the same thing and the filter survives a walk through single modules and chunks, where the legend says how much is being held back. Whether a module is third-party is decided by its file rather than its whole name, or a source file built by a loader from node_modules would be hidden along with the loader. The filter bar sits above a table that used to start at the window edge, which put the gutter question on the table: .page carries it now, so the pages that bring a container-fluid and the pages that are a bare table line up on the same left edge. Co-Authored-By: Claude Opus 5 --- README.md | 1 + app/graphs/modules.js | 56 +++++++++++++-- app/moduleFilter.js | 106 +++++++++++++++++++++++++++ app/pages/modules/modules.pug | 54 ++++---------- app/pages/modules/page.js | 50 ++++++++++++- app/pages/modules/table.pug | 42 +++++++++++ app/pages/select/application.pug | 4 +- app/pages/upload/application.pug | 4 +- app/style.css | 50 ++++++++++++- test/moduleFilter.test.js | 119 +++++++++++++++++++++++++++++++ 10 files changed, 434 insertions(+), 52 deletions(-) create mode 100644 app/moduleFilter.js create mode 100644 app/pages/modules/table.pug create mode 100644 test/moduleFilter.test.js diff --git a/README.md b/README.md index 9b6005f..4a98727 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This project is a lightweight front-end viewer for webpack output generated with - Overview of build timing, module counts, chunk counts, and assets - Module dependency graph and chunk relationships - Asset and bundle size breakdowns +- Filter the module list and graph by name or regexp, or hide `node_modules` - Warning and error inspection - Hints for common optimization issues, including circular dependencies - Upload a generated stats file directly in the app diff --git a/app/graphs/modules.js b/app/graphs/modules.js index d1f983a..8f0c636 100644 --- a/app/graphs/modules.js +++ b/app/graphs/modules.js @@ -7,6 +7,7 @@ var forceAtlas2 = require("graphology-layout-forceatlas2"); var rescale = require("./rescale"); var theme = require("./theme"); var legend = require("./legend"); +var moduleFilter = require("../moduleFilter"); var formatSize = require("../formatSize"); var percentageToColor = require("../percentageToColor").greenRed; var percentageToColor2 = require("../percentageToColor").blue; @@ -224,11 +225,41 @@ var CHUNK_SELECTION_LEGEND = { ] }; +// The filter is set on the modules page but stays on while browsing single +// modules and chunks, where there is no filter box in sight. Saying so in the +// legend is what keeps a half-empty graph from looking like a bug. +function filterGroup() { + if (!moduleFilter.isActive()) return null; + var summary = moduleFilter.summary(app.stats.modules); + return { + title: "filter", + items: [ + { + text: + "showing " + + summary.visible + + " of " + + summary.total + + " modules, set on the modules page" + } + ] + }; +} + +// The module whose page is open is drawn whether or not it passes the filter: +// it can be reached from a link on another module or from a chunk, and hiding +// the very thing the page is about would only look broken. +function isDrawn(module) { + if (!module) return false; + return moduleFilter.isVisible(module) || module.uid === activeModuleUid; +} + +var currentSelectionGroup = null; + function showLegend(selectionGroup) { - legend.render( - legendElement, - selectionGroup ? GRAPH_LEGEND.concat(selectionGroup) : GRAPH_LEGEND - ); + currentSelectionGroup = selectionGroup; + var groups = GRAPH_LEGEND.concat(selectionGroup || [], filterGroup() || []); + legend.render(legendElement, groups); } showLegend(null); @@ -270,6 +301,10 @@ var s = new Sigma(graph, element, { labelRenderedSizeThreshold: 6, nodeReducer: function(node, data) { var display = Object.assign({}, data); + if (!isDrawn(data.module)) { + display.hidden = true; + return display; + } if (data.highlighted) display.label = data.fullLabel; if (!selection) return display; @@ -309,6 +344,14 @@ var s = new Sigma(graph, element, { }, edgeReducer: function(edge, data) { var display = Object.assign({}, data); + // An edge is only meaningful while both of its modules are on screen. + if ( + !isDrawn(app.mapModulesUid[data.sourceModuleUid]) || + !isDrawn(app.mapModulesUid[data.targetModuleUid]) + ) { + display.hidden = true; + return display; + } // sigma 1's `edgeColor: "target"` setting: an explicit edge colour wins, // otherwise the edge takes the colour of its target node. var color = @@ -341,6 +384,11 @@ var s = new Sigma(graph, element, { } }); +moduleFilter.onChange(function() { + showLegend(currentSelectionGroup); + s.refresh(); +}); + var layout = new FA2Layout(graph, { settings: Object.assign(forceAtlas2.inferSettings(graph), { // Carried over from the old web_modules/sigma.js shim and show() below. diff --git a/app/moduleFilter.js b/app/moduleFilter.js new file mode 100644 index 0000000..d854105 --- /dev/null +++ b/app/moduleFilter.js @@ -0,0 +1,106 @@ +// Shared filter for the module list and the module graph (webpack/analyse#11). +// +// A build of any size arrives with far more modules than the ones being looked +// for, and until now the only way to see just your own was to edit the stats +// file by hand. The filter state lives here rather than in the page, so the +// table and the graph always agree on what is showing, and the graph keeps the +// filter while navigating between modules and chunks. + +var listeners = []; + +exports.query = ""; +exports.hideThirdParty = false; +// Set when the query looks like a regular expression but does not compile. The +// filter falls back to showing everything, so a half-typed pattern never +// blanks the page. +exports.error = null; + +var match = null; + +exports.set = function set(changes) { + if ("query" in changes) exports.query = changes.query; + if ("hideThirdParty" in changes) + exports.hideThirdParty = changes.hideThirdParty; + match = compile(exports.query); + listeners.forEach(function(listener) { + listener(); + }); +}; + +exports.onChange = function onChange(listener) { + listeners.push(listener); +}; + +exports.isActive = function isActive() { + return exports.hideThirdParty || (!!match && !exports.error); +}; + +exports.isVisible = function isVisible(module) { + if (exports.hideThirdParty && isThirdParty(module)) return false; + if (!match || exports.error) return true; + return match(module); +}; + +exports.summary = function summary(modules) { + var result = { visible: 0, total: 0, visibleSize: 0, totalSize: 0 }; + modules.forEach(function(module) { + var size = module.size || 0; + result.total++; + result.totalSize += size; + if (!exports.isVisible(module)) return; + result.visible++; + result.visibleSize += size; + }); + return result; +}; + +// "/^\.\/src/i" is read as a regular expression, anything else as a +// case-insensitive substring, which is what a filter box is expected to do. +function compile(query) { + exports.error = null; + query = (query || "").trim(); + if (!query) return null; + var re = query.match(/^\/(.*)\/([a-z]*)$/); + if (re) { + try { + var expression = new RegExp(re[1], re[2]); + return function(module) { + return expression.test(module.name || module.identifier || ""); + }; + } catch (err) { + exports.error = err.message; + return null; + } + } + var needle = query.toLowerCase(); + return function(module) { + return ( + (module.name || "").toLowerCase().indexOf(needle) >= 0 || + (module.identifier || "").toLowerCase().indexOf(needle) >= 0 + ); + }; +} + +// A module name puts its loaders in front of the resource +// ("css-loader!./a.css") and a context module puts its mode and matcher behind +// the directory ("./dir sync ^\.\/.*$"), so what has to be tested is neither +// the whole name nor simply its last piece. Getting this wrong would hide a +// source file only because a third-party loader built it. +function resourceOf(module) { + var name = module.name || module.identifier || ""; + var parts = name.split("!"); + var resource = parts[parts.length - 1]; + var context = resource.match( + /^(\S+) (?:sync|eager|weak|lazy|lazy-once|optional)\b/ + ); + return context ? context[1] : resource; +} + +// `~` is how webpack 1 wrote node_modules in the names it reported. +var THIRD_PARTY = /(^|[\\/])(node_modules|~)[\\/]/; + +function isThirdParty(module) { + return THIRD_PARTY.test(resourceOf(module)); +} + +exports.isThirdParty = isThirdParty; diff --git a/app/pages/modules/modules.pug b/app/pages/modules/modules.pug index d70df85..30da05a 100644 --- a/app/pages/modules/modules.pug +++ b/app/pages/modules/modules.pug @@ -1,40 +1,14 @@ -table.table.table-condensed - thead - tr - th id - th name - th.sortable-th.size-th size - th chunks - th flags - tbody - each module in stats.modules - tr - td - if typeof module.uid === "number" - a.btn.btn-success(href=`#module/${module.uid}`)= module.id - else - span.btn.btn-success= module.id - td: pre: code= module.name.split("!").join("\n") - td= require("../../formatSize")(module.size) - td - each chunk in module.chunks - a.btn.btn-info(href=`#chunk/${encodeURIComponent(chunk)}`)= chunk - = " " - td - if module.built - span.label.label-success built - = " " - if !module.cacheable - span.label.label-warning not cacheable - = " " - if module.prefetched - span.label.label-success prefetched - = " " - if module.failed - span.label.label-danger failed - = " " - if module.warnings - span.label.label-warning= module.warnings + " warnings" - = " " - if module.errors - span.label.label-danger= module.errors + " errors" +.module-filter + label.module-filter-label(for="module-filter-query") filter + input#module-filter-query.form-control.input-sm.module-filter-query( + type="text", + placeholder="part of a name, or /regexp/", + value=query, + autocomplete="off" + ) + label.module-filter-option(title="Modules whose file lives in node_modules, written ~ in webpack 1 stats") + input.module-filter-third-party(type="checkbox", checked=hideThirdParty) + | hide node_modules + span.module-filter-summary + span.module-filter-error.text-danger +.modules-table diff --git a/app/pages/modules/page.js b/app/pages/modules/page.js index 60dba5d..fdc7f8e 100644 --- a/app/pages/modules/page.js +++ b/app/pages/modules/page.js @@ -1,17 +1,47 @@ var app = require("../../app"); var modulesGraph = require("../../graphs/modules"); +var moduleFilter = require("../../moduleFilter"); +var formatSize = require("../../formatSize"); function renderTable() { - $(".page").html( - require("./modules.pug")({ - stats: app.stats + $(".modules-table").html( + require("./table.pug")({ + modules: app.stats.modules.filter(moduleFilter.isVisible) }) ); } +function renderSummary() { + var summary = moduleFilter.summary(app.stats.modules); + $(".module-filter-summary").text( + moduleFilter.isActive() + ? "showing " + + summary.visible + + " of " + + summary.total + + " modules, " + + formatSize(summary.visibleSize) + + " of " + + formatSize(summary.totalSize) + : summary.total + " modules, " + formatSize(summary.totalSize) + ); + // An unfinished regexp is a normal thing to have in a filter box, so it is + // reported next to the field rather than emptying the table. + $(".module-filter-error").text( + moduleFilter.error ? "not a valid regexp: " + moduleFilter.error : "" + ); +} + module.exports = function() { document.title = "modules"; + $(".page").html( + require("./modules.pug")({ + query: moduleFilter.query, + hideThirdParty: moduleFilter.hideThirdParty + }) + ); renderTable(); + renderSummary(); var sortDir; $(document).on("click", ".size-th", function() { @@ -21,11 +51,25 @@ module.exports = function() { }); renderTable(); }); + // The graph follows the same filter through moduleFilter, so only the table + // and the summary are redrawn here. + $(document).on("input", ".module-filter-query", function() { + moduleFilter.set({ query: this.value }); + renderTable(); + renderSummary(); + }); + $(document).on("change", ".module-filter-third-party", function() { + moduleFilter.set({ hideThirdParty: this.checked }); + renderTable(); + renderSummary(); + }); modulesGraph.show(); modulesGraph.setNormal(); return function() { $(document).off("click", ".size-th"); + $(document).off("input", ".module-filter-query"); + $(document).off("change", ".module-filter-third-party"); modulesGraph.hide(); }; }; diff --git a/app/pages/modules/table.pug b/app/pages/modules/table.pug new file mode 100644 index 0000000..ee09cc2 --- /dev/null +++ b/app/pages/modules/table.pug @@ -0,0 +1,42 @@ +table.table.table-condensed + thead + tr + th id + th name + th.sortable-th.size-th size + th chunks + th flags + tbody + each module in modules + tr + td + if typeof module.uid === "number" + a.btn.btn-success(href=`#module/${module.uid}`)= module.id + else + span.btn.btn-success= module.id + td: pre: code= module.name.split("!").join("\n") + td= require("../../formatSize")(module.size) + td + each chunk in module.chunks + a.btn.btn-info(href=`#chunk/${encodeURIComponent(chunk)}`)= chunk + = " " + td + if module.built + span.label.label-success built + = " " + if !module.cacheable + span.label.label-warning not cacheable + = " " + if module.prefetched + span.label.label-success prefetched + = " " + if module.failed + span.label.label-danger failed + = " " + if module.warnings + span.label.label-warning= module.warnings + " warnings" + = " " + if module.errors + span.label.label-danger= module.errors + " errors" +if modules.length === 0 + p.text-muted No module matches the filter. diff --git a/app/pages/select/application.pug b/app/pages/select/application.pug index 7939200..aee0546 100644 --- a/app/pages/select/application.pug +++ b/app/pages/select/application.pug @@ -9,8 +9,8 @@ nav.navbar.navbar-default li: a(href="#warnings") Warnings li: a(href="#errors") Errors li: a(href="#hints") Hints -#sigma-modules(style="width: 99%; height: 500px; display:none;") -#sigma-chunks(style="width: 99%; height: 500px; display:none;") +#sigma-modules(style="display:none;") +#sigma-chunks(style="display:none;") .page .modal.fade: .modal-dialog: .modal-content diff --git a/app/pages/upload/application.pug b/app/pages/upload/application.pug index a15ba20..83c622e 100644 --- a/app/pages/upload/application.pug +++ b/app/pages/upload/application.pug @@ -9,8 +9,8 @@ nav.navbar.navbar-default li: a(href="#warnings") Warnings li: a(href="#errors") Errors li: a(href="#hints") Hints -#sigma-modules(style="width: 99%; height: 500px; display:none;") -#sigma-chunks(style="width: 99%; height: 500px; display:none;") +#sigma-modules(style="display:none;") +#sigma-chunks(style="display:none;") .page .modal.fade: .modal-dialog: .modal-content diff --git a/app/style.css b/app/style.css index 1e4616f..08e0970 100644 --- a/app/style.css +++ b/app/style.css @@ -1,3 +1,23 @@ +/* Every page renders into .page, but only some of them bring a Bootstrap + .container-fluid of their own; the rest are a bare table and used to sit + flush against the window edge. The gutter belongs to .page so that both + kinds line up, and a container that carries its own padding gives it up. */ +.page { + padding: 0 15px; +} +.page > .container-fluid { + padding-left: 0; + padding-right: 0; +} + +/* The graphs live beside .page in the shell rather than inside it, so they + need the same gutter to share its left edge. */ +#sigma-modules, +#sigma-chunks { + height: 500px; + margin: 0 15px; +} + table pre { margin-bottom: 0px; } @@ -13,7 +33,7 @@ table pre { flex-wrap: wrap; align-items: center; gap: 4px 18px; - margin: 0 0 10px 8px; + margin: 0 15px 10px; color: #777; font-size: 12px; line-height: 18px; @@ -58,3 +78,31 @@ table pre { text-align: center; font-size: 14px; } + +/* Filter bar above the module table (webpack/analyse#11). */ +.module-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 14px; + margin-bottom: 10px; +} +.module-filter-label { + margin: 0; + color: #555; +} +.module-filter-query { + width: 260px; +} +/* Bootstrap makes every label bold and pushes it off its checkbox. */ +.module-filter-option { + margin: 0; + font-weight: normal; +} +.module-filter-summary, +.module-filter-error { + font-size: 12px; +} +.module-filter-summary { + color: #777; +} diff --git a/test/moduleFilter.test.js b/test/moduleFilter.test.js new file mode 100644 index 0000000..cf951ba --- /dev/null +++ b/test/moduleFilter.test.js @@ -0,0 +1,119 @@ +// Checks the module filter behind the modules page and the module graph. +// Run with `npm test`. +var test = require("node:test"); +var assert = require("node:assert"); + +var moduleFilter = require("../app/moduleFilter"); +var example = require("../app/pages/upload/example1.json"); + +function reset() { + moduleFilter.set({ query: "", hideThirdParty: false }); +} + +function mod(name, size) { + return { name: name, identifier: name, size: size || 100 }; +} + +var SOURCE = mod("./src/app.js"); +var STYLE = mod("(webpack)/~/css-loader!./lib/stylesheet.css"); +var VENDOR = mod("./node_modules/lodash/index.js"); +var VENDOR_TILDE = mod("(webpack)/~/style-loader/addStyles.js"); +var CONTEXT = mod( + "(webpack)/test/cases/loaders sync " + + "(webpack)/node_modules/raw-loader/x.js ^\\.\\/.*$" +); + +function visible(modules) { + return modules.filter(moduleFilter.isVisible).map(function(module) { + return module.name; + }); +} + +test("shows everything when nothing is filtered", function() { + reset(); + assert.strictEqual(moduleFilter.isActive(), false); + assert.deepStrictEqual(visible([SOURCE, VENDOR]), [SOURCE.name, VENDOR.name]); +}); + +test("matches a query as a case-insensitive substring", function() { + reset(); + moduleFilter.set({ query: "STYLEsheet" }); + assert.deepStrictEqual(visible([SOURCE, STYLE, VENDOR]), [STYLE.name]); + assert.strictEqual(moduleFilter.isActive(), true); +}); + +test("reads a query wrapped in slashes as a regexp", function() { + reset(); + moduleFilter.set({ query: "/\\.css$/" }); + assert.deepStrictEqual(visible([SOURCE, STYLE, VENDOR]), [STYLE.name]); + moduleFilter.set({ query: "/^\\.\\/SRC/i" }); + assert.deepStrictEqual(visible([SOURCE, STYLE, VENDOR]), [SOURCE.name]); +}); + +test("keeps showing everything while a regexp is unfinished", function() { + reset(); + moduleFilter.set({ query: "/[unclosed/" }); + assert.ok(moduleFilter.error, "the broken pattern is reported"); + assert.strictEqual(moduleFilter.isActive(), false); + assert.deepStrictEqual(visible([SOURCE, VENDOR]), [SOURCE.name, VENDOR.name]); + // And recovers once it parses. + moduleFilter.set({ query: "/lodash/" }); + assert.strictEqual(moduleFilter.error, null); + assert.deepStrictEqual(visible([SOURCE, VENDOR]), [VENDOR.name]); +}); + +test("hides third-party modules by their file, not their loaders", function() { + reset(); + moduleFilter.set({ hideThirdParty: true }); + // STYLE is a source file that a loader from node_modules built, and CONTEXT + // is a directory of source files whose matcher names such a loader. Testing + // the whole name would hide both. + assert.deepStrictEqual( + visible([SOURCE, STYLE, CONTEXT, VENDOR, VENDOR_TILDE]), + [SOURCE.name, STYLE.name, CONTEXT.name] + ); +}); + +test("combines the query with the third-party switch", function() { + reset(); + moduleFilter.set({ query: "index", hideThirdParty: true }); + assert.deepStrictEqual(visible([SOURCE, VENDOR, mod("./src/index.js")]), [ + "./src/index.js" + ]); +}); + +test("counts what is showing and what it weighs", function() { + reset(); + var modules = [mod("./src/a.js", 100), mod("./node_modules/b.js", 400)]; + moduleFilter.set({ hideThirdParty: true }); + assert.deepStrictEqual(moduleFilter.summary(modules), { + visible: 1, + total: 2, + visibleSize: 100, + totalSize: 500 + }); +}); + +test("holds up against a real stats file", function() { + reset(); + moduleFilter.set({ hideThirdParty: true }); + var summary = moduleFilter.summary(example.modules); + assert.ok( + summary.visible < summary.total, + "example1 does contain third-party modules" + ); + assert.ok(summary.visible > summary.total / 2, "and is mostly its own code"); + var byName = {}; + example.modules.forEach(function(module) { + byName[module.name] = module; + }); + assert.strictEqual( + moduleFilter.isVisible(byName["(webpack)/~/style-loader/addStyles.js"]), + false + ); + assert.strictEqual( + moduleFilter.isVisible(byName["./lib/stylesheet.css"]), + true + ); + reset(); +});