Skip to content

[Bug]: strictExecutionOrder + sideEffects: false drops init calls for named re-exports, leaving variables undefined #8777

@roysandrew

Description

@roysandrew

Reproduction link or steps

https://stackblitz.com/edit/rolldown-rolldown-starter-stackblitz-aeizewzc?file=dist%2Findex.mjs

What is expected?

Foo and Bar should be defined objects. The init_Foo wrapper (for Foo/index.js) should call init_Foo$1() (for Foo.js) so that the Foo variable gets assigned:

  var init_Foo = __esmMin((() => {                                                                                                                                                                   
      init_Foo$1();        // ← initialize Foo.js (assigns the Foo variable)                                                                                                                         
      init_fooClasses();                                                                                                                                                                             
      init_fooClasses();                                                                                                                                                                             
  })); 

What is actually happening?

Foo and Bar are undefined. The Foo.js module wrapper becomes anonymous and is never called. The Foo/index.js wrapper only initializes fooClasses, skipping Foo.js:

  // Foo.js — anonymous wrapper, never called! Foo stays undefined.
  var Foo;
  __esmMin((() => {
      init_fooClasses();
      Foo = { name: "Foo", root: fooClasses.root };
  }));
  // Foo/index.js — missing init_Foo$1() call
  var init_Foo = __esmMin((() => {
      init_fooClasses();   // ← only classes — no Foo.js init!
      init_fooClasses();
  }));

System Info

rolldown v1.0.0-rc.9
Node.js v22.21.1
Linux x86_64

Any additional comments?

A lot of iterating found that we needed the following to be true to hit this:

  1. strictExecutionOrder: true
  2. "sideEffects": false in a parent package.json
  3. Nested barrel re-export pattern (barrel → sub-barrel → component)

This is true seemingly in our usage of the mui material-ui package which is how we first encountered it.

A very very rough and novice look suggests that this is because in

WrapKind::Esm => {
// Turn `import ... from 'bar_esm'` into `init_bar_esm()`
stmt_info.side_effect =
(is_reexport_all || importee.side_effects.has_side_effects()).into();
// Reference to `init_foo`
stmt_info
.referenced_symbols
.push(importee_linking_info.wrapper_ref.unwrap().into());

For a named re-export like export { default } from './Foo.js':

  • is_reexport_all is false (it's not export *)
  • importee.side_effects is UserDefined(false) (from package.json)

So stmt_info.side_effect is set to false

if ctx.tree_shaking && !forced_no_treeshake {
module.stmt_infos.iter_enumerated_without_namespace_stmt().for_each(
|(stmt_info_id, stmt_info)| {
// No need to handle the namespace statement specially, because it doesn't have side effects and will only be included if it is used.
let bail_eval = module.meta.has_eval()
&& !stmt_info.declared_symbols.is_empty()
&& stmt_info_id.index() != 0;
let has_side_effects = if module.meta.contains(EcmaViewMeta::SafelyTreeshakeCommonjs)
&& ctx.options.treeshake.commonjs()
{
stmt_info.side_effect.contains(SideEffectDetail::Unknown)
} else {
stmt_info.side_effect.has_side_effect()
};
if has_side_effects || bail_eval {
include_statement(ctx, module, stmt_info_id);
}
},
);

The export { default } from './Foo.js' re-export in Foo/index.js is excluded from stmt_info_included. When module_finalizers/mod.rs:remove_unused_top_level_stmt() later processes Foo/index.js, it never sees this re-export statement, never calls transform_or_remove_import_export_stmt for it, and never
generates the init_Foo$1() call that would initialise the Foo variable.

Then, UserDefined(false) from package.json short-circuits

DeterminedSideEffects::Analyzed(true)
| DeterminedSideEffects::UserDefined(_)
| DeterminedSideEffects::NoTreeshake => module_side_effects,

while normally determine_side_effects would detect the wrapped ESM importee via the export * from / WrapKind::Esm check and return Analyzed(true).

The semantics here seem kinda deliberate so I wanted to discuss but I can raise a change that marks re-export statements as always having side_effect: true when strictExecutionOrder is enabled, importee has WrapKind:Esm? But I'm open to any solutions.

Apologies again if this overview is totally in the wrong direction, first forays into rolldown and the bundler internals so feel free to course correct

Metadata

Metadata

Labels

No labels
No labels

Type

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions