Skip to content
This repository was archived by the owner on Sep 7, 2026. It is now read-only.
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ jobs:
- name: Install dependencies
run: npm ci

# Runs before the build so a broken analysis fails the run without
# producing a Pages artifact from it.
- name: Test
run: npm test

- name: Build
run: npm run build

Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This project is a lightweight front-end viewer for webpack output generated with
- Module dependency graph and chunk relationships
- Asset and bundle size breakdowns
- Warning and error inspection
- Hints for common optimization issues
- Hints for common optimization issues, including circular dependencies
- Upload a generated stats file directly in the app

## Requirements
Expand Down Expand Up @@ -75,6 +75,16 @@ Deployment is automatic: every push to `master` builds the site and publishes it
to GitHub Pages via `.github/workflows/ci.yml`. Pull requests build but never
publish. There is no manual deploy step.

```bash
npm test
```

Runs the unit tests in [`test/`](test). They check the circular dependency
detection against [`app/pages/upload/example3.json`](app/pages/upload/example3.json),
a small hand-written stats file that covers every hint of the hints page and is
loadable in the app as the "hint test cases" example. CI runs this before the
build, so a failing test stops the run and nothing is deployed.

## Typical workflow

1. Build your application with webpack in profiling mode.
Expand Down
203 changes: 203 additions & 0 deletions app/findCircularDependencies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Finds circular dependencies in the module graph.
//
// Only modules inside a strongly connected component can be part of a cycle,
// so the search is limited to the non-trivial components found by Tarjan's
// algorithm. Inside a component every elementary circuit is enumerated from
// its lowest module, which reports each circuit exactly once. The number of
// circuits can grow exponentially with the size of a component, so both the
// number of reported cycles and the search effort are capped.

// Reasons a module lists for referring to itself, e.g. `exports` in a
// CommonJS module. They are not dependencies on another module and would
// otherwise turn a large part of the graph into one-module cycles.
var SELF_REFERENCE_TYPES = {
"cjs self exports reference": true,
"module decorator": true
};

var DEFAULT_MAX_CYCLES = 4;
var DEFAULT_MAX_STEPS = 10000;

// Both algorithms below are written with an explicit stack instead of
// recursion, because the graph can be deeper than the JS call stack allows.

function buildAdjacency(modules) {
var nodeByUid = {};
modules.forEach(function(module, node) {
if (typeof module.uid === "number") nodeByUid[module.uid] = node;
});
return modules.map(function(module) {
var edges = [];
var seen = {};
(module.dependencies || []).forEach(function(dependency) {
if (SELF_REFERENCE_TYPES[dependency.type] === true) return;
var node = nodeByUid[dependency.moduleUid];
// Multiple references to the same module are a single edge here,
// the first one is kept to point at the source location.
if (typeof node !== "number" || seen[node]) return;
seen[node] = true;
edges.push({ node: node, dependency: dependency });
});
return edges;
});
}

function findStronglyConnectedComponents(adjacency) {
var index = [];
var lowlink = [];
var onStack = [];
var componentOfNode = [];
var stack = [];
var components = [];
var nextIndex = 0;
adjacency.forEach(function(_, root) {
if (typeof index[root] === "number") return;
var work = [{ node: root, edge: 0 }];
while (work.length > 0) {
var frame = work[work.length - 1];
var node = frame.node;
if (frame.edge === 0) {
index[node] = lowlink[node] = nextIndex++;
onStack[node] = true;
stack.push(node);
}
var edges = adjacency[node];
var descended = false;
while (frame.edge < edges.length) {
var next = edges[frame.edge++].node;
if (typeof index[next] !== "number") {
work.push({ node: next, edge: 0 });
descended = true;
break;
}
if (onStack[next] && index[next] < lowlink[node])
lowlink[node] = index[next];
}
if (descended) continue;
if (lowlink[node] === index[node]) {
var component = [];
var member;
do {
member = stack.pop();
onStack[member] = false;
componentOfNode[member] = components.length;
component.push(member);
} while (member !== node);
components.push(
component.sort(function(a, b) {
return a - b;
})
);
}
work.pop();
if (work.length > 0) {
var parent = work[work.length - 1].node;
if (lowlink[node] < lowlink[parent]) lowlink[parent] = lowlink[node];
}
}
});
return { components: components, componentOfNode: componentOfNode };
}

// Enumerates the elementary circuits of one component. A circuit is only
// reported when it is entered through its lowest module, so rotations of the
// same circuit are not reported twice.
function findCyclesInComponent(adjacency, componentOfNode, component, state) {
var componentId = componentOfNode[component[0]];
for (var i = 0; i < component.length; i++) {
var start = component[i];
var path = [start];
var takenEdges = [];
var nextEdge = [0];
var onPath = {};
onPath[start] = true;
while (path.length > 0) {
var depth = path.length - 1;
var node = path[depth];
var edges = adjacency[node];
var descended = false;
while (nextEdge[depth] < edges.length) {
if (state.steps++ >= state.maxSteps) {
state.truncated = true;
return;
}
var edge = edges[nextEdge[depth]++];
var next = edge.node;
// Modules below the start are covered by an earlier start, and
// modules of other components can never lead back here.
if (next < start || componentOfNode[next] !== componentId) continue;
if (next === start) {
state.cycles.push({
nodes: path.slice(),
dependencies: takenEdges.slice(0, depth).concat(edge.dependency)
});
// One cycle more than requested is collected to know for sure
// that there are more cycles than the reported ones.
if (state.cycles.length > state.maxCycles) {
state.truncated = true;
return;
}
continue;
}
if (onPath[next]) continue;
takenEdges[depth] = edge.dependency;
path.push(next);
nextEdge.push(0);
onPath[next] = true;
descended = true;
break;
}
if (descended) continue;
delete onPath[node];
path.pop();
nextEdge.pop();
}
}
}

module.exports = function findCircularDependencies(modules, options) {
options = options || {};
var adjacency = buildAdjacency(modules);
var scc = findStronglyConnectedComponents(adjacency);
var state = {
cycles: [],
steps: 0,
truncated: false,
maxCycles: options.maxCycles || DEFAULT_MAX_CYCLES,
maxSteps: options.maxSteps || DEFAULT_MAX_STEPS
};
var moduleCount = 0;
var componentCount = 0;
scc.components.forEach(function(component) {
var isCyclic =
component.length > 1 ||
adjacency[component[0]].some(function(edge) {
return edge.node === component[0];
});
if (!isCyclic) return;
moduleCount += component.length;
componentCount++;
if (state.truncated) return;
findCyclesInComponent(adjacency, scc.componentOfNode, component, state);
});
var cycles = state.cycles.slice(0, state.maxCycles).map(function(cycle) {
return {
modules: cycle.nodes.map(function(node) {
return modules[node];
}),
dependencies: cycle.dependencies
};
});
// Short cycles are the easiest ones to understand and to break.
cycles.sort(function(a, b) {
if (a.modules.length !== b.modules.length)
return a.modules.length - b.modules.length;
return a.modules[0].uid - b.modules[0].uid;
});
return {
cycles: cycles,
truncated: state.truncated,
moduleCount: moduleCount,
componentCount: componentCount
};
};
40 changes: 39 additions & 1 deletion app/pages/hints/hints.pug
Original file line number Diff line number Diff line change
@@ -1,8 +1,46 @@
.container-fluid
.row
.col-md-12
if multiChunks.length === 0 && multiRefs.length === 0 && longChains.length === 0
if circularDependencies.cycles.length === 0 && multiChunks.length === 0 && multiRefs.length === 0 && longChains.length === 0
h2 Everything seem to be fine.
if circularDependencies.cycles.length > 0
h2 Circular dependencies
p These modules require each other in a loop. One of them is evaluated before the module it depends on has finished evaluating, so it may read an export that is still <code>undefined</code>. Break the loop by moving the shared part into a module of its own, or by moving the access to the export into a function that runs later.
p
= `${circularDependencies.moduleCount} modules are part of ${circularDependencies.componentCount} group${circularDependencies.componentCount === 1 ? "" : "s"} of modules that (indirectly) require each other.`
if circularDependencies.truncated
= ` Only the first ${circularDependencies.cycles.length} cycles are listed, fix these and check again.`
for cycle in circularDependencies.cycles
table.table.table-bordered
thead
tr
th module
th name
th type
th user request
th location
th requires
tbody
for module, idx in cycle.modules
- var dependency = cycle.dependencies[idx]
- var next = cycle.modules[(idx + 1) % cycle.modules.length]
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= dependency.type
td: if dependency.userRequest
pre: code= dependency.userRequest.split("!").join("\n")
td: if dependency.loc
code= dependency.loc
td
if typeof next.uid === "number"
a.btn.btn-success(href=`#module/${next.uid}`)= next.id
else
span.btn.btn-success= next.id
if multiChunks.length > 0
h2 Module in multiple chunks
p Check if it is a good idea to move modules into a common parent. You may want to use <code>require.include</code> or insert them into the parents <code>require.ensure</code> array.
Expand Down
4 changes: 4 additions & 0 deletions app/pages/hints/page.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
var app = require("../../app");
var findById = require("../../findById");
var findCircularDependencies = require("../../findCircularDependencies");

module.exports = function() {
document.title = "hints";
Expand Down Expand Up @@ -31,6 +32,8 @@ module.exports = function() {
return b.saving - a.saving;
});

var circularDependencies = findCircularDependencies(app.stats.modules);

var multiChunks = [];
app.stats.modules.forEach(function(module) {
if (module.chunks.length >= 2) {
Expand Down Expand Up @@ -70,6 +73,7 @@ module.exports = function() {
$(".page").html(
require("./hints.pug")({
stats: app.stats,
circularDependencies: circularDependencies,
multiRefs: multiRefs,
multiChunks: multiChunks,
longChains: longChains
Expand Down
1 change: 1 addition & 0 deletions app/pages/upload/application.pug
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ nav.navbar.navbar-default
label(for="example") Examples
div: button(type="btn btn-default", id="example1") webpack 1 test cases
div: button(type="btn btn-default", id="example2") webpack 5 test cases
div: button(type="btn btn-default", id="example3") hint test cases
.modal-footer
Loading