Skip to content

[Bug]: Non-deterministic content hash: export key order in ConcatenatedModule #13182

@T9-Forever

Description

@T9-Forever

System Info

rspack version: 1.6.x
rsbuild version: 1.6.6
rspack_resolver version: 0.6.4
OS: macOS (also affects Linux)
Node.js: 20.x

Details

Summary

Chunk content hashes differ between builds (e.g. local vs CI) even with the same source and commit. The only difference in the built JS is the order of keys in the same export object (e.g. __webpack_require__.d(exports, { "PA8": ..., "HRB": ... }) vs { "HRB": ..., "PA8": ... }). Same code, different serialization order → different content hash.

Reproduction

  1. Build the project locally and note a chunk filename, e.g. 3916.35d2a9f7.js.
  2. Build the same commit on CI (or another machine) and get the same chunk with a different hash, e.g. 3916.41ba190a.js.
  3. Diff the two files: the only difference is the order of two (or more) export keys in one object (e.g. PA8 vs HRB), ~42 bytes.

No code or config change; the difference is purely the iteration order of the object keys when emitting the chunk.

Root cause (from source inspection)

In crates/rspack_core/src/concatenated_module.rs:

  • Line ~1240: exports_map is a HashMap<Atom, String> (e.g. FxHashMap).
  • Lines ~1368–1376: The code builds the __webpack_require__.d(exports, { ... }) object by iterating exports_map.iter() and joining definitions. HashMap iteration order is not guaranteed, so the same exports can be serialized in different order across builds → different content → different content hash.

By contrast:

  • crates/rspack_core/src/init_fragment.rs (ESMExportInitFragment, ~line 369): sorts export_map with sort_by(|a, b| a.0.cmp(&b.0)) before rendering, so that path is deterministic.
  • crates/rspack_plugin_javascript/src/runtime.rs (line 69): sorts modules by module_identifier before emitting the chunk module map, so module order in the chunk is deterministic.

So the only place that emits an export object without sorting keys is the ConcatenatedModule (scope hoisting) path in concatenated_module.rs.

Expected behavior

Builds with the same source and lockfile should produce the same content hashes for the same chunks (deterministic builds). Export key order in ConcatenatedModule output should be deterministic (e.g. sorted by key).

Suggested fix

Before building the definitions string from exports_map, sort entries by key (same approach as ESMExportInitFragment), e.g.:

// Define exports
if !exports_map.is_empty() {
  let mut definitions: Vec<_> = exports_map
    .iter()
    .map(|(key, value)| {
      (
        key,
        format!(
          "\n  {}: {}",
          property_name(key).expect("should convert to property_name"),
          returning_function(&compilation.options.output.environment, value, "")
        ),
      )
    })
    .collect();
  definitions.sort_by(|a, b| a.0.cmp(b.0));
  let definitions: Vec<_> = definitions.into_iter().map(|(_, s)| s).collect();
  // ... rest unchanged, use definitions.join(",")
}

This makes the export object key order deterministic and stabilizes content hashes for chunks that include ConcatenatedModule output (e.g. scope-hoisted bundles like iconResources.ts + 404 modules).

Additional context

  • Observed with Rsbuild (rspack-based); chunk naming uses [name].[contenthash:8].js.
  • The chunk that showed the diff contained scope hoisting output (ConcatenatedModule); the same non-determinism would affect any chunk that includes a ConcatenatedModule’s export object.

Reproduce link

No response

Reproduce Steps

Reproduce Steps

Metadata

Metadata

Assignees

No one assigned

    Labels

    pending triageThe issue/PR is currently untouched.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions