Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
116 changes: 116 additions & 0 deletions app/graphs/index.js
Original file line number Diff line number Diff line change
@@ -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]);
}
};
}
76 changes: 76 additions & 0 deletions app/graphs/settings.js
Original file line number Diff line number Diff line change
@@ -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.
}
}
2 changes: 1 addition & 1 deletion app/pages/chunk/page.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion app/pages/chunks/page.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion app/pages/module/page.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
2 changes: 1 addition & 1 deletion app/pages/modules/page.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
14 changes: 14 additions & 0 deletions app/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions test/graphSettings.test.js
Original file line number Diff line number Diff line change
@@ -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);
});