Skip to content

Commit 1313375

Browse files
authored
fix: deterministic findGraphRoots regardless of edge ordering (#20452)
1 parent 7039d0a commit 1313375

File tree

8 files changed

+84
-57
lines changed

8 files changed

+84
-57
lines changed

.changeset/giant-turkeys-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
fix: deterministic findGraphRoots regardless of edge ordering

.changeset/small-dingos-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Fixed deterministic search for graph roots regardless of edge order.

lib/util/findGraphRoots.js

Lines changed: 38 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ const NO_MARKER = 0;
99
const IN_PROGRESS_MARKER = 1;
1010
const DONE_MARKER = 2;
1111
const DONE_MAYBE_ROOT_CYCLE_MARKER = 3;
12-
const DONE_AND_ROOT_MARKER = 4;
1312

1413
/**
1514
* @template T
@@ -32,9 +31,9 @@ class Node {
3231
this.item = item;
3332
/** @type {Nodes<T>} */
3433
this.dependencies = new Set();
35-
this.marker = NO_MARKER;
36-
/** @type {Cycle<T> | undefined} */
37-
this.cycle = undefined;
34+
/** @type {Cycle<T>} */
35+
this.cycle = new Cycle();
36+
this.cycle.nodes.add(this);
3837
this.incoming = 0;
3938
}
4039
}
@@ -46,6 +45,7 @@ class Cycle {
4645
constructor() {
4746
/** @type {Nodes<T>} */
4847
this.nodes = new Set();
48+
this.marker = NO_MARKER;
4949
}
5050
}
5151

@@ -83,11 +83,6 @@ module.exports = (items, getDependencies) => {
8383
}
8484
}
8585

86-
// Set of current root modules
87-
// items will be removed if a new reference to it has been found
88-
/** @type {Nodes<T>} */
89-
const roots = new Set();
90-
9186
// Set of current cycles without references to it
9287
// cycles will be removed if a new reference to it has been found
9388
// that is not part of the cycle
@@ -96,12 +91,12 @@ module.exports = (items, getDependencies) => {
9691

9792
// For all non-marked nodes
9893
for (const selectedNode of itemToNode.values()) {
99-
if (selectedNode.marker === NO_MARKER) {
94+
if (selectedNode.cycle.marker === NO_MARKER) {
10095
// deep-walk all referenced modules
10196
// in a non-recursive way
10297

10398
// start by entering the selected node
104-
selectedNode.marker = IN_PROGRESS_MARKER;
99+
selectedNode.cycle.marker = IN_PROGRESS_MARKER;
105100

106101
// keep a stack to avoid recursive walk
107102
/** @type {StackEntry<T>[]} */
@@ -122,97 +117,85 @@ module.exports = (items, getDependencies) => {
122117
const dependency =
123118
/** @type {Node<T>} */
124119
(topOfStack.openEdges.pop());
125-
switch (dependency.marker) {
120+
const cycle = dependency.cycle;
121+
switch (cycle.marker) {
126122
case NO_MARKER:
127123
// dependency has not be visited yet
128124
// mark it as in-progress and recurse
129125
stack.push({
130126
node: dependency,
131127
openEdges: [...dependency.dependencies]
132128
});
133-
dependency.marker = IN_PROGRESS_MARKER;
129+
cycle.marker = IN_PROGRESS_MARKER;
134130
break;
135131
case IN_PROGRESS_MARKER: {
136132
// It's a in-progress cycle
137-
let cycle = dependency.cycle;
138-
if (!cycle) {
139-
cycle = new Cycle();
140-
cycle.nodes.add(dependency);
141-
dependency.cycle = cycle;
142-
}
143133
// set cycle property for each node in the cycle
144134
// if nodes are already part of a cycle
145135
// we merge the cycles to a shared cycle
136+
/** @type {Set<Cycle<T>>} */
137+
const toMerge = new Set();
146138
for (
147139
let i = stack.length - 1;
148-
stack[i].node !== dependency;
140+
stack[i].node.cycle !== cycle;
149141
i--
150142
) {
151-
const node = stack[i].node;
152-
if (node.cycle) {
153-
if (node.cycle !== cycle) {
154-
// merge cycles
155-
for (const cycleNode of node.cycle.nodes) {
156-
cycleNode.cycle = cycle;
157-
cycle.nodes.add(cycleNode);
158-
}
159-
}
160-
} else {
161-
node.cycle = cycle;
162-
cycle.nodes.add(node);
143+
toMerge.add(stack[i].node.cycle);
144+
}
145+
for (const cycleToMerge of toMerge) {
146+
// merge cycles
147+
for (const cycleNode of cycleToMerge.nodes) {
148+
cycleNode.cycle = cycle;
149+
cycle.nodes.add(cycleNode);
163150
}
164151
}
165152
// don't recurse into dependencies
166153
// these are already on the stack
167154
break;
168155
}
169-
case DONE_AND_ROOT_MARKER:
170-
// This node has be visited yet and is currently a root node
171-
// But as this is a new reference to the node
172-
// it's not really a root
173-
// so we have to convert it to a normal node
174-
dependency.marker = DONE_MARKER;
175-
roots.delete(dependency);
176-
break;
177156
case DONE_MAYBE_ROOT_CYCLE_MARKER:
178157
// This node has be visited yet and
179158
// is maybe currently part of a completed root cycle
180159
// we found a new reference to the cycle
181160
// so it's not really a root cycle
182161
// remove the cycle from the root cycles
183162
// and convert it to a normal node
184-
rootCycles.delete(/** @type {Cycle<T>} */ (dependency.cycle));
185-
dependency.marker = DONE_MARKER;
163+
rootCycles.delete(/** @type {Cycle<T>} */ (cycle));
164+
cycle.marker = DONE_MARKER;
186165
break;
187166
// DONE_MARKER: nothing to do, don't recurse into dependencies
188167
}
189168
} else {
190169
// All dependencies of the current node has been visited
191170
// we leave the node
192171
stack.pop();
193-
topOfStack.node.marker = DONE_MARKER;
172+
// Only mark the cycle as done when we leave the outermost
173+
// node of the cycle on the stack
174+
if (
175+
stack.length === 0 ||
176+
topOfStack.node.cycle !== stack[stack.length - 1].node.cycle
177+
) {
178+
topOfStack.node.cycle.marker = DONE_MARKER;
179+
}
194180
}
195181
}
196182
const cycle = selectedNode.cycle;
197-
if (cycle) {
198-
for (const node of cycle.nodes) {
199-
node.marker = DONE_MAYBE_ROOT_CYCLE_MARKER;
200-
}
201-
rootCycles.add(cycle);
202-
} else {
203-
selectedNode.marker = DONE_AND_ROOT_MARKER;
204-
roots.add(selectedNode);
205-
}
183+
cycle.marker = DONE_MAYBE_ROOT_CYCLE_MARKER;
184+
rootCycles.add(cycle);
206185
}
207186
}
208187

188+
// Set of root modules
189+
/** @type {T[]} */
190+
const roots = [];
191+
209192
// Extract roots from root cycles
210193
// We take the nodes with most incoming edges
211194
// inside of the cycle
212195
for (const cycle of rootCycles) {
213196
let max = 0;
214197
/** @type {Nodes<T>} */
215-
const cycleRoots = new Set();
198+
const cycleRoots = new Set(cycle.nodes);
216199
const nodes = cycle.nodes;
217200
for (const node of nodes) {
218201
for (const dep of node.dependencies) {
@@ -228,14 +211,12 @@ module.exports = (items, getDependencies) => {
228211
}
229212
}
230213
for (const cycleRoot of cycleRoots) {
231-
roots.add(cycleRoot);
214+
roots.push(cycleRoot.item);
232215
}
233216
}
234217

235218
// When roots were found, return them
236-
if (roots.size > 0) {
237-
return Array.from(roots, (r) => r.item);
238-
}
219+
if (roots.length > 0) return roots;
239220

240221
throw new Error("Implementation of findGraphRoots is broken");
241222
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { getC } from "./c";
2+
import { getB } from "./b";
3+
4+
export function getA() {
5+
return "a";
6+
}
7+
8+
export function getCombined() {
9+
return getA() + getB() + getC();
10+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { getC } from "./c";
2+
3+
export function getB() {
4+
return "b" + getC();
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { getA } from "./a";
2+
3+
export function getC() {
4+
return "c" + getA();
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getCombined } from "./a";
2+
3+
it("should produce deterministic output with cyclic dependencies", () => {
4+
const result = getCombined();
5+
expect(result).toContain("a");
6+
expect(result).toContain("b");
7+
expect(result).toContain("c");
8+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"use strict";
2+
3+
/** @type {import("../../../../").Configuration} */
4+
module.exports = {
5+
optimization: {
6+
concatenateModules: true
7+
}
8+
};

0 commit comments

Comments
 (0)