Skip to content

ConcatenatedModule re-export ordering still depends on graph insertion order when sourceOrder and rangeStart tie #22694

Closed

Description

@Anonym-tsk

Have you used AI?

Yes. AI-assisted investigation and report; the standalone reproducer below was executed against an unmodified npm installation of webpack 5.111.1.

Which part of webpack is affected?

optimization (tree-shaking, concatenation)

Bug Description

ConcatenatedModule can emit different code and contenthashes for identical source files when outgoing module-graph connections have a different insertion order.

This is a remaining tie case related to #15866 / #15868. getConcatenatedImports sorts by sourceOrder and rangeStart. For named re-exports through a side-effect-free barrel, multiple dependencies can share sourceOrder and have no rangeStart. The comparator returns zero, so the graph connection order determines the concatenation order.

In a larger application this was observed between cold and warm filesystem-cache builds. The reproducer below isolates the ordering problem deterministically by reversing outgoing connections only while _createConcatenationList consumes them. It deliberately uses cache: false: it demonstrates sensitivity to graph insertion order, not a standalone reproduction of cache restoration.

Minimal Reproduction

In an empty directory:

npm init -y
npm install --save-exact webpack@5.111.1
# Save the following script as repro.cjs
node repro.cjs
Self-contained repro.cjs (creates and removes its own fixture)
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const webpack = require('webpack');
const ConcatenatedModule = require('webpack/lib/optimize/ConcatenatedModule');

(async () => {
  const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'webpack-reexports-'));
  const original = ConcatenatedModule.prototype._createConcatenationList;
  let reverse = false;
  let reordered = 0;
  // Force the two possible connection orders at the point where they are consumed.
  // This models the observed cold/warm-cache difference; it does not test cache IO.
  ConcatenatedModule.prototype._createConcatenationList = function (root, modules, runtime, graph) {
    const get = graph.getOutgoingConnections;
    graph.getOutgoingConnections = function (module) {
      const connections = [...get.call(this, module)];
      if (reverse && connections.length > 1) {
        reordered++;
        connections.reverse();
      }
      return connections;
    };
    try { return original.call(this, root, modules, runtime, graph); }
    finally { graph.getOutgoingConnections = get; }
  };
  async function build(name) {
    const compiler = webpack({
      mode: 'production', context: directory, entry: './index.js', cache: false,
      output: { path: path.join(directory, name), filename: '[contenthash].js', library: { type: 'commonjs2' } },
      optimization: { minimize: false, concatenateModules: true, sideEffects: true }
    });
    await new Promise((resolve, reject) => compiler.run((error, stats) => compiler.close((closeError) => {
      if (error || closeError) reject(error || closeError);
      else if (!stats || stats.hasErrors()) reject(new Error(stats?.toString()));
      else resolve();
    })));
    const files = await fs.readdir(path.join(directory, name));
    assert.equal(files.length, 1);
    return { name: files[0], source: await fs.readFile(path.join(directory, name, files[0]), 'utf8') };
  }
  try {
    for (const [name, source] of Object.entries({
      'package.json': '{"sideEffects":false}',
      'index.js': 'export { a, b } from "./barrel.js";',
      'barrel.js': 'export { a } from "./a.js"; export { b } from "./b.js";',
      'a.js': 'export const a = () => globalThis.x;',
      'b.js': 'export const b = () => globalThis.y;'
    })) await fs.writeFile(path.join(directory, name), source);
    const first = await build('first');
    reverse = true;
    const second = await build('second');
    assert.ok(reordered > 0);
    console.log({ webpack: webpack.version, first: first.name, second: second.name });
    assert.deepEqual(second, first);
  } finally {
    ConcatenatedModule.prototype._createConcatenationList = original;
    await fs.rm(directory, { recursive: true, force: true });
  }
})().catch(error => { console.error(error); process.exitCode = 1; });

Expected Behavior

Both builds produce identical bytes and the same contenthash filename. Graph insertion order should not change the output for these identical, side-effect-free modules.

Actual Behavior

The final equality assertion fails: the declarations for a.js and b.js exchange positions and the contenthash changes. With minimization enabled, declaration order and minified variable names also change. Exact hashes depend on the temporary path.

Environment

  • webpack: 5.111.1 (clean npm installation); also reproduced with 5.111.0
  • Node.js: 26.9.0
  • OS: macOS, arm64
  • Loaders/plugins: none, apart from the instrumentation shown in the reproducer

Additional Context

A local workaround adds a third sorting criterion: the dependency's position in its owning module's dependencies array. This array is serialized/restored in order. It keeps the existing sourceOrder/rangeStart priorities and resolves the remaining tie. The regression check passes with that change, both with and without minimization.

Disabling optimization.concatenateModules or optimization.sideEffects also makes the two outputs match, at the cost of disabling useful optimizations. This is not fixed by the rangeStart comparison introduced in #15868.

No last-known-good version has been established.

Activity

  1. added
    area: cachePersistent and memory caching, serialization (lib/cache, lib/serialization)
    area: optimizationTree-shaking, splitChunks, concatenation, ids (lib/optimize, lib/ids)
    on Oct 15, 2026
  2. added theissue type on Oct 15, 2026
  3. added a commit that references this issue on Oct 15, 2026
    3c95c6f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: cachePersistent and memory caching, serialization (lib/cache, lib/serialization)area: optimizationTree-shaking, splitChunks, concatenation, ids (lib/optimize, lib/ids)

    Type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions