Skip to content

CJS namespace import snapshots exports before later side-effect mutation #9512

Description

@Caltsic

Reproduction link or steps

This came from investigating vitejs/vite#22435, but the behavior reproduces with Rolldown directly and without Backbone.

A minimal fixture added under crates/rolldown/tests/rolldown/function/experimental/strict_execution_order/cjs_namespace_late_mutation:

// main.js
import * as target from './target.cjs';
import './augment.cjs';

export const result = {
  existing: target.existing,
  addedType: typeof target.added,
  defaultAddedType: typeof target.default?.added,
};
// target.cjs
module.exports = {
  existing: 'existing',
};
// augment.cjs
const target = require('./target.cjs');

target.added = function added() {
  return 'added';
};
// _test.mjs
import assert from 'node:assert';

const { result } = await import('./dist/main.js');

assert.deepStrictEqual(result, {
  existing: 'existing',
  addedType: 'function',
  defaultAddedType: 'function',
});
// _config.json
{
  config: {
    input: [
      {
        name: main,
        import: ./main.js
      }
    ]
  },
  expectExecuted: false,
  snapshot: false
}

Run:

NEEDS_EXTENDED=false cargo run-fixture crates/rolldown/tests/rolldown/function/experimental/strict_execution_order/cjs_namespace_late_mutation/_config.json

What is expected?

The namespace object should observe target.added after the side-effect CommonJS module has executed, matching ESM static import execution semantics and the behavior of Rollup + @rollup/plugin-commonjs for the same fixture.

In other words, result should be:

{
  existing: 'existing',
  addedType: 'function',
  defaultAddedType: 'function',
}

What is actually happening?

The test fails with:

AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected

  {
+   addedType: 'undefined',
-   addedType: 'function',
    defaultAddedType: 'function',
    existing: 'existing'
  }

The generated output initializes the CJS namespace wrapper before the later side-effect require mutates the same CommonJS export object:

var import_target = __toESM(require_target(), 1);
require_augment();

__toESM snapshots/copies the current own properties from module.exports, so target.added is missing on the namespace object. target.default.added works because default still points to the mutable original CommonJS export object.

A named import variant works because the output keeps a direct reference to the CJS export object and reads the property after the side-effect module runs:

var import_target = require_target();
require_augment();
typeof import_target.added;

System Info

OS: Windows 11
Node: v24.14.0
pnpm: 11.1.2
rustc: 1.95.0 (59807616e 2026-04-14)
cargo: 1.95.0 (f2d3ce0bd 2026-03-21)
Rolldown repo commit tested: bf468a4dc

Any additional comments?

This affects Vite 8 production builds for legacy CommonJS packages that augment another CommonJS package via side effects. One concrete downstream case is backbone-relational augmenting backbone: import * as Backbone from backbone; import backbone-relational works in Vite dev and Vite 7 build, but fails in Vite 8 build because the namespace wrapper is created before the augmentation runs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions