diff --git a/README.md b/README.md index c12bfd7..3d32ccd 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This project is a lightweight front-end viewer for webpack output generated with - Asset and bundle size breakdowns - Filter the module list and graph by name or regexp, or hide `node_modules` - Sort any table of modules, chunks or assets by size, name or id +- Turn the graphs off, for builds too large to lay one out - Warning and error inspection - Hints for common optimization issues, including circular dependencies - Upload a generated stats file directly in the app @@ -166,6 +167,12 @@ Each one draws a legend underneath itself, and it says the same as this: | Black, red, green (module graph) | With a module open: the module itself, what requires it, and what it requires. With a chunk open: the modules in the chunk, and the edges into and out of it | | Grey | Everything outside the current selection | +A graph is only worth drawing while it can be read, and on a very large build +the force layout can take long enough to look like a hang. The control under +each graph turns it off and on, the choice is remembered in this browser, and +a build of more than 5000 modules or chunks starts with the graphs off. With +them off the graph is never built at all, so the rest of the app stays quick. + ## Typical workflow 1. Build your application with webpack in profiling mode. diff --git a/app/graphs/index.js b/app/graphs/index.js new file mode 100644 index 0000000..9aa8471 --- /dev/null +++ b/app/graphs/index.js @@ -0,0 +1,116 @@ +// The way the pages reach a graph (webpack/analyse#38). +// +// Both graph modules do their work as they load: they walk every module or +// chunk, build the graphology graph, start sigma and spin up a layout worker. +// A page that requires one directly therefore pays for it whether or not the +// reader wants to see it, which is the whole problem on a large build. Going +// through here keeps that work behind settings.enabled(), and puts a control +// beside the graph so the choice can be made and remembered from the page. + +var settings = require("./settings"); + +exports.settings = settings; + +exports.modules = create( + function() { + return require("./modules"); + }, + "sigma-modules", + function() { + return settings.moduleCount() + " modules"; + } +); + +exports.chunks = create( + function() { + return require("./chunks"); + }, + "sigma-chunks", + function() { + return settings.chunkCount() + " chunks"; + } +); + +function create(load, containerId, describe) { + var element = document.getElementById(containerId); + var graph = null; + var showing = false; + // Built here rather than on first use so that it always ends up after the + // legend, which the graph module inserts directly below the container when + // it eventually loads. + var toggle = document.createElement("div"); + toggle.className = "graph-toggle"; + toggle.style.display = "none"; + element.parentNode.insertBefore(toggle, element.nextSibling); + $(toggle).on("click", "button", function() { + settings.set(!settings.enabled()); + }); + // The selection the page asked for, kept so that turning the graphs back + // on lands on the module or chunk the reader is actually looking at. + var selection = null; + + function apply() { + if (!showing) return; + if (settings.enabled()) { + // The require runs the graph module, so it only happens here. + if (!graph) graph = load(); + graph.show(); + if (selection) graph[selection.method].apply(graph, selection.args); + } else if (graph) { + graph.hide(); + } + render(); + } + + function select(method, args) { + selection = { method: method, args: args }; + if (graph && settings.enabled()) graph[method].apply(graph, args); + } + + function render() { + toggle.style.display = showing ? "" : "none"; + if (!showing) return; + var on = settings.enabled(); + toggle.textContent = ""; + var button = document.createElement("button"); + button.type = "button"; + button.className = "btn btn-default btn-xs"; + button.textContent = on ? "hide graph" : "show graph"; + toggle.appendChild(button); + if (on) return; + var note = document.createElement("span"); + note.className = "graph-toggle-note"; + var remembered = " The choice is remembered in this browser."; + note.textContent = settings.tooBig() + ? "The graph is off: " + + describe() + + " take a long time to lay out." + + remembered + : "The graph is off." + remembered; + toggle.appendChild(note); + } + + settings.onChange(apply); + + return { + show: function() { + showing = true; + apply(); + }, + hide: function() { + showing = false; + selection = null; + if (graph) graph.hide(); + render(); + }, + setNormal: function() { + select("setNormal", []); + }, + setActiveModule: function(uid) { + select("setActiveModule", [uid]); + }, + setActiveChunk: function(id) { + select("setActiveChunk", [id]); + } + }; +} diff --git a/app/graphs/settings.js b/app/graphs/settings.js new file mode 100644 index 0000000..47a33a4 --- /dev/null +++ b/app/graphs/settings.js @@ -0,0 +1,76 @@ +// Whether the graphs are drawn at all (webpack/analyse#38). +// +// The layout builds a node per module and an edge per reason and then runs a +// force simulation over them, which a build of tens of thousands of modules +// can turn into a hang or a dead tab. Until now the only way out was to fork +// the app and comment the graphs out, so the choice lives here instead: it is +// remembered per browser, and a build large enough to be a problem starts with +// the graphs off, because crashing on first sight of a stats file is a poor +// way to find out that a setting exists. + +var app = require("../app"); + +var STORAGE_KEY = "analyse.graphs"; + +// Roughly where the force layout stops settling in a reasonable time. The two +// bundled examples, at 256 and 1034 modules, are nowhere near it. +var TOO_BIG = 5000; + +var listeners = []; +// null while the reader has not said either way, which leaves the size of the +// build to decide. +var choice = read(); + +exports.enabled = function enabled() { + if (choice !== null) return choice; + return !exports.tooBig(); +}; + +exports.tooBig = function tooBig() { + return exports.moduleCount() > TOO_BIG || exports.chunkCount() > TOO_BIG; +}; + +exports.moduleCount = function moduleCount() { + return count(app.stats && app.stats.modules); +}; + +exports.chunkCount = function chunkCount() { + return count(app.stats && app.stats.chunks); +}; + +exports.set = function set(on) { + choice = !!on; + write(choice); + listeners.forEach(function(listener) { + listener(); + }); +}; + +exports.onChange = function onChange(listener) { + listeners.push(listener); +}; + +function count(list) { + return list ? list.length : 0; +} + +function read() { + try { + var stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === "on") return true; + if (stored === "off") return false; + } catch (err) { + // Storage can be unavailable: private windows, blocked cookies, or no + // browser at all under the tests. The choice then lasts as long as the + // page does, which is still better than not being able to make it. + } + return null; +} + +function write(on) { + try { + window.localStorage.setItem(STORAGE_KEY, on ? "on" : "off"); + } catch (err) { + // As above. + } +} diff --git a/app/pages/chunk/page.js b/app/pages/chunk/page.js index 106cd45..8429b57 100644 --- a/app/pages/chunk/page.js +++ b/app/pages/chunk/page.js @@ -1,5 +1,5 @@ var app = require("../../app"); -var modulesGraph = require("../../graphs/modules"); +var modulesGraph = require("../../graphs").modules; var sortableTable = require("../../sortableTable"); module.exports = function (id) { diff --git a/app/pages/chunks/page.js b/app/pages/chunks/page.js index af31563..0c4889d 100644 --- a/app/pages/chunks/page.js +++ b/app/pages/chunks/page.js @@ -1,5 +1,5 @@ var app = require("../../app"); -var chunksGraph = require("../../graphs/chunks"); +var chunksGraph = require("../../graphs").chunks; var sortableTable = require("../../sortableTable"); module.exports = function() { diff --git a/app/pages/module/page.js b/app/pages/module/page.js index aa1f674..7b0b4b7 100644 --- a/app/pages/module/page.js +++ b/app/pages/module/page.js @@ -1,5 +1,5 @@ var app = require("../../app"); -var modulesGraph = require("../../graphs/modules"); +var modulesGraph = require("../../graphs").modules; module.exports = function(id) { id = parseInt(id, 10); diff --git a/app/pages/modules/page.js b/app/pages/modules/page.js index 2f3bd1b..60185b0 100644 --- a/app/pages/modules/page.js +++ b/app/pages/modules/page.js @@ -1,5 +1,5 @@ var app = require("../../app"); -var modulesGraph = require("../../graphs/modules"); +var modulesGraph = require("../../graphs").modules; var moduleFilter = require("../../moduleFilter"); var formatSize = require("../../formatSize"); var sortableTable = require("../../sortableTable"); diff --git a/app/style.css b/app/style.css index 0401b8a..8de293b 100644 --- a/app/style.css +++ b/app/style.css @@ -94,6 +94,20 @@ table pre { font-size: 14px; } +/* The control that turns a graph off and on (webpack/analyse#38). Sits under + the legend, in the same quiet register. */ +.graph-toggle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + margin: 0 15px 10px; + font-size: 12px; +} +.graph-toggle-note { + color: #777; +} + /* Filter bar above the module table (webpack/analyse#11). */ .module-filter { display: flex; diff --git a/test/graphSettings.test.js b/test/graphSettings.test.js new file mode 100644 index 0000000..4d3c2bd --- /dev/null +++ b/test/graphSettings.test.js @@ -0,0 +1,82 @@ +// Checks when the graphs draw themselves and when they stay out of the way. +// Run with `npm test`. +var test = require("node:test"); +var assert = require("node:assert"); + +var app = require("../app/app"); + +// The setting remembers a choice for as long as the page lives, so each test +// asks for a module of its own rather than inheriting the last one's answer. +function fresh() { + delete require.cache[require.resolve("../app/graphs/settings")]; + return require("../app/graphs/settings"); +} + +function stats(modules, chunks) { + app.stats = { modules: new Array(modules), chunks: new Array(chunks) }; +} + +test("draws the graphs for a build of an ordinary size", function() { + stats(1034, 155); + var settings = fresh(); + assert.strictEqual(settings.tooBig(), false); + assert.strictEqual(settings.enabled(), true); +}); + +test("keeps them off for a build big enough to hang the tab", function() { + stats(50000, 12); + var settings = fresh(); + assert.strictEqual(settings.tooBig(), true); + assert.strictEqual(settings.enabled(), false); +}); + +test("counts chunks as well as modules", function() { + stats(10, 50000); + var settings = fresh(); + assert.strictEqual(settings.tooBig(), true); + assert.strictEqual(settings.enabled(), false); +}); + +test("lets the reader overrule either default", function() { + stats(50000, 12); + var big = fresh(); + big.set(true); + assert.strictEqual(big.enabled(), true, "asked for the graph anyway"); + assert.strictEqual(big.tooBig(), true, "and is still told why it was off"); + + stats(100, 2); + var small = fresh(); + small.set(false); + assert.strictEqual(small.enabled(), false, "asked for no graph"); +}); + +test("tells the graphs when the choice changes", function() { + stats(100, 2); + var settings = fresh(); + var changes = 0; + settings.onChange(function() { + changes++; + }); + settings.set(false); + settings.set(true); + assert.strictEqual(changes, 2); +}); + +test("survives having nowhere to remember the choice", function() { + // No window in node, so reading and writing the choice both throw; the + // setting has to carry on with the choice held in memory only. + stats(100, 2); + var settings = fresh(); + assert.doesNotThrow(function() { + settings.set(false); + }); + assert.strictEqual(settings.enabled(), false); +}); + +test("copes with stats that carry no modules or chunks", function() { + app.stats = null; + var settings = fresh(); + assert.strictEqual(settings.moduleCount(), 0); + assert.strictEqual(settings.chunkCount(), 0); + assert.strictEqual(settings.enabled(), true); +});