Skip to content

fix: deterministic findGraphRoots regardless of edge ordering#20452

Merged
alexander-akait merged 4 commits intowebpack:mainfrom
veeceey:fix/issue-20445-findGraphRoots-nondeterministic
Feb 16, 2026
Merged

fix: deterministic findGraphRoots regardless of edge ordering#20452
alexander-akait merged 4 commits intowebpack:mainfrom
veeceey:fix/issue-20445-findGraphRoots-nondeterministic

Conversation

@veeceey
Copy link
Contributor

@veeceey veeceey commented Feb 14, 2026

Fixes #20445

findGraphRoots could return different root sets for the same graph depending on edge ordering. This caused nondeterministic chunk names across webpack runs, which breaks deployments when HTML and JS bundles aren't deployed atomically.

Root cause

DFS visit markers were stored per-node. When the DFS discovers a partial cycle first (e.g. A->C->A in a graph where A,B,C all form a cycle), C gets marked DONE before B is processed. When B->C is later encountered, C is already "done" so B never joins the cycle. This produces an incomplete cycle and wrong root selection depending on which edges happen to be traversed first.

Repro from the issue:

const g1 = {"A":["C","B"], "B":["C"], "C":["A"]}
const g2 = {"A":["B","C"], "B":["C"], "C":["A"]}
findGraphRoots(Object.keys(g1), m => g1[m]) // ['C']
findGraphRoots(Object.keys(g2), m => g2[m]) // ['C', 'A'] -- wrong

Fix

Move markers from individual nodes to cycles. Each node starts in its own singleton cycle, and cycles are merged as back-edges are found during DFS. Since all nodes in a merged cycle share the same marker object, the algorithm correctly identifies all cycle members regardless of traversal order.

Also added unit tests for findGraphRoots - it previously had no test coverage.

…dge ordering

The findGraphRoots algorithm could return different root sets for the
same graph depending on the order edges were listed. This caused
nondeterministic chunk names between webpack runs, breaking
HTML->JS links in deployments.

The root cause was that DFS visit markers were per-node instead of
per-cycle. When nodes A,B,C form a cycle but the DFS discovers a
partial cycle first (e.g. A->C->A), node C gets marked as DONE before
B is processed. Later when B->C is encountered, C is already "done"
so B never joins the cycle, producing an incomplete cycle and wrong
root selection.

The fix moves markers from nodes to cycles. Each node starts in its
own singleton cycle, and cycles are merged as back-edges are found.
Since all nodes in a cycle share the same marker, the algorithm
correctly identifies all cycle members regardless of traversal order.

Fixes webpack#20445
@changeset-bot
Copy link

changeset-bot bot commented Feb 14, 2026

🦋 Changeset detected

Latest commit: aae7729

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@linux-foundation-easycla
Copy link

linux-foundation-easycla bot commented Feb 14, 2026

CLA Signed

The committers listed above are authorized under a signed CLA.

@alexander-akait
Copy link
Member

Please accept CLA and add a test case

@veeceey
Copy link
Contributor Author

veeceey commented Feb 15, 2026

Thanks for the review! I've added a changeset now. The test case was already included in the original commit — test/findGraphRoots.unittest.js covers the exact repro from the issue plus several other scenarios (chains, diamonds, self-references, separate cycles, external references, etc.).

Working on the CLA as well.

Copy link
Member

@alexander-akait alexander-akait left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rewrite tests and accept CLA, no need to send a new PR without accepting CLA, because we can't merge your fixes

const g2 = { A: ["B"], B: ["C"], C: ["D"], D: ["B", "A"] };
expect(roots(g1)).toEqual(roots(g2));
});
});
Copy link
Member

@alexander-akait alexander-akait Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a test case in configCases, no need to create unit tests, they are useless for such cases

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — I've replaced the unit test with a configCase at test/configCases/chunk-graph/deterministic-roots. It sets up three modules (a, b, c) with cyclic imports (a→c→a, a→b→c) and concatenateModules enabled, which exercises the findGraphRoots path. The test passes locally.

Move the findGraphRoots test from a standalone unittest to a configCase
under chunk-graph/deterministic-roots as requested by the reviewer.
The test exercises cyclic module dependencies (A -> C -> A, A -> B -> C)
to verify deterministic graph root selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@veeceey
Copy link
Contributor Author

veeceey commented Feb 16, 2026

Hey @alexander-akait, thanks for the feedback. I've pushed a new commit that replaces the unit test with a configCase under test/configCases/chunk-graph/deterministic-roots — three modules with cyclic imports and concatenateModules enabled. Still working on getting the CLA sorted out.

@veeceey
Copy link
Contributor Author

veeceey commented Feb 16, 2026

@CLAassistant check

@veeceey
Copy link
Contributor Author

veeceey commented Feb 16, 2026

Both items should be resolved now — CLA is signed and passing, and the configCase test at test/configCases/chunk-graph/deterministic-roots is in place. Let me know if anything else needs updating!

@alexander-akait
Copy link
Member

@veeceey I can't reproduce freeze on your example

@veeceey
Copy link
Contributor Author

veeceey commented Feb 16, 2026

Hey @alexander-akait, thanks for taking a look! To clarify — it's not a freeze, it's nondeterministic output. The same graph can produce different root sets depending on which order the edges happen to be iterated.

Here's the minimal repro:

const { findGraphRoots } = require("./lib/util/findGraphRoots");

// Same graph, just different edge ordering for node A
const g1 = {"A":["C","B"], "B":["C"], "C":["A"]};
const g2 = {"A":["B","C"], "B":["C"], "C":["A"]};

console.log(findGraphRoots(Object.keys(g1), m => g1[m]));
// => ['C']

console.log(findGraphRoots(Object.keys(g2), m => g2[m]));
// => ['C', 'A']  <-- different roots for the same graph

Both calls describe the exact same cycle (A→B→C→A), but because Set iteration depends on insertion order, the DFS traverses edges in a different order and produces different results. The second call incorrectly reports two roots instead of one.

In practice this means chunk names can differ between builds if module discovery order changes (e.g. parallel file system reads), which is the issue reported in #20445.

The fix makes the algorithm track cycle membership so that once any back-edge merges nodes into a cycle, all nodes in that cycle share the same marker — making the result independent of edge ordering.

Let me know if you'd like me to adjust anything!

@alexander-akait
Copy link
Member

@veeceey Do you copied this answer from AI?

@alexander-akait alexander-akait merged commit 1313375 into webpack:main Feb 16, 2026
53 checks passed
@github-actions
Copy link
Contributor

This PR is packaged and the instant preview is available (1313375).

Install it locally:

  • npm
npm i -D webpack@https://pkg.pr.new/webpack@1313375
  • yarn
yarn add -D webpack@https://pkg.pr.new/webpack@1313375
  • pnpm
pnpm add -D webpack@https://pkg.pr.new/webpack@1313375

@codspeed-hq
Copy link

codspeed-hq bot commented Feb 16, 2026

Merging this PR will not alter performance

✅ 72 untouched benchmarks


Comparing veeceey:fix/issue-20445-findGraphRoots-nondeterministic (aae7729) with main (7039d0a)

Open in CodSpeed

@hai-x hai-x mentioned this pull request Feb 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

findGraphRoots bug handling complicated cycles makes chunk names nondeterministic

2 participants