Rollup Version
4.57.1
Operating System (or Browser)
macOS 14.4 (also reproducible on Linux)
Node Version (if applicable)
v20.x
Link To Reproduction
https://stackblitz.com/edit/rollup-repro-3bwuswb5?file=rollup.config.mjs&view=editor
Expected Behaviour
Reproduction Setup
Directory Structure:
repro/
├── rollup.config.mjs
├── package.json
└── src/
├── entry1.js (dynamic entry - first to resolve Foo)
├── entry2.js (dynamic entry - second to resolve Foo, loses CSS)
├── entry3.js (dynamic entry - uses direct import, always has CSS)
├── main.js (static entry with dynamic imports)
└── lib/
├── index.js (barrel with export *)
├── foo/
│ ├── index.js (sub-barrel: re-exports Foo + imports CSS side-effect)
│ ├── Foo.js (component implementation, no CSS)
│ └── style.css (CSS styles)
└── bar/
├── index.js
└── Bar.js
package.json:
{
"name": "rollup-namespace-reexport-side-effect-bug",
"private": true,
"type": "module",
"sideEffects": [
"src/lib/foo/index.js",
"src/lib/foo/style.css",
"src/lib/bar/index.js"
]
}
Note: src/lib/index.js (the main barrel) is intentionally NOT listed in sideEffects, which is a common pattern — barrel files themselves have no side effects, but the sub-modules they re-export from do.
src/lib/foo/Foo.js — Component implementation (no CSS import):
export const Foo = 'Foo component';
src/lib/foo/style.css — Side-effect CSS:
.foo { color: red; font-size: 24px; }
src/lib/foo/index.js — Sub-barrel with side-effect import:
export { Foo } from './Foo.js';
import './style.css'; // <-- side-effect import (CSS)
src/lib/bar/Bar.js:
export const Bar = 'Bar component';
src/lib/bar/index.js:
export { Bar } from './Bar.js';
src/lib/index.js — Main barrel using export *:
export * from './foo/index.js';
export * from './bar/index.js';
src/entry1.js — First dynamic entry (imports Foo from barrel):
import { Foo } from './lib/index.js';
console.log('entry1:', Foo);
src/entry2.js — Second dynamic entry (also imports Foo from barrel):
import { Foo, Bar } from './lib/index.js';
console.log('entry2:', Foo, Bar);
src/entry3.js — Third dynamic entry (imports via direct import of sub-barrel):
import { Foo } from './lib/foo/index.js'; // direct import, not via export *
console.log('entry3:', Foo);
src/main.js:
// Dynamic imports to create multiple entry points
const load = async (name) => import(`./${name}.js`);
load('entry1');
load('entry2');
load('entry3');
rollup.config.mjs:
import { nodeResolve } from '@rollup/plugin-node-resolve';
import css from 'rollup-plugin-css-only'; // or any CSS plugin
export default {
input: 'src/main.js',
output: {
dir: 'dist',
format: 'es',
},
plugins: [
nodeResolve({ moduleSideEffects: (id) => {
// Simulate package.json sideEffects behavior
if (id.includes('lib/index.js')) return false; // barrel: no side effects
if (id.includes('foo/index.js')) return true; // sub-barrel: has side effects
if (id.includes('style.css')) return true; // CSS: has side effects
return false;
}}),
css({ output: 'styles.css' }),
],
};
Steps to Reproduce
npm install
npx rollup -c rollup.config.mjs
Then inspect which chunks contain style.css (or its content).
All three entries (entry1, entry2, entry3) import Foo which is re-exported from lib/foo/index.js. Since lib/foo/index.js has a side-effect import (import './style.css'), and it is listed in sideEffects, all three entries should have lib/foo/index.js (and transitively style.css) in their dependency graph.
Specifically, entry2's getDependenciesToBeIncluded() should include lib/foo/index.js as a side-effect dependency of the Foo variable, just like entry1.
Actual Behaviour
Only the first entry to resolve Foo through the barrel's export * gets the side-effect dependency recorded. The second and subsequent entries miss it entirely.
This causes style.css to be assigned to a chunk that only the first entry loads. Other entries that import the same Foo variable through the same barrel get the JavaScript but not the CSS.
Root Cause Analysis
The bug is in Module.getVariableForExportName() in src/Module.ts, specifically the namespaceReexportsByName caching logic:
// src/Module.ts — getVariableForExportName()
if (name !== 'default') {
const foundNamespaceReexport =
this.namespaceReexportsByName.get(name) ?? // cache lookup
this.getVariableFromNamespaceReexports( // only called on cache MISS
name,
importerForSideEffects, // side-effect recording happens inside
searchedNamesAndModules,
[...importChain, this.id]
);
this.namespaceReexportsByName.set(name, foundNamespaceReexport); // cache the result
if (foundNamespaceReexport[0]) {
return foundNamespaceReexport; // cache HIT returns here, skipping everything
}
}
The issue: getVariableFromNamespaceReexports() internally calls getVariableForExportName() on the sub-barrel (lib/foo/index.js), which records the side-effect dependency via:
// Inside lib/foo/index.js's getVariableForExportName(), in the reexportDescriptions branch:
if (importerForSideEffects) {
if (this.info.moduleSideEffects) {
getOrCreate(
importerForSideEffects.sideEffectDependenciesByVariable,
variable,
getNewSet<Module>
).add(this); // records lib/foo/index.js as a side-effect dep of importerForSideEffects
}
}
But this code only executes when getVariableFromNamespaceReexports() is called — which only happens on cache MISS. On cache HIT, the cached [variable] is returned directly, and the side-effect recording for the new importerForSideEffects is completely skipped.
Comparison with reexportDescriptions branch
The reexportDescriptions branch (handling export { Foo } from './Foo.js') does NOT have this problem because it always executes the side-effect recording code — there is no caching:
const reexportDeclaration = this.reexportDescriptions.get(name);
if (reexportDeclaration) {
const [variable] = getVariableForExportNameRecursive(/* ... importerForSideEffects ... */);
if (importerForSideEffects) {
if (this.info.moduleSideEffects) {
// This ALWAYS runs for every caller — no cache
getOrCreate(importerForSideEffects.sideEffectDependenciesByVariable, variable, getNewSet).add(this);
}
}
return [variable];
}
Only the namespaceReexportsByName path (for export *) has this caching issue.
Downstream impact on chunk assignment
In analyzeModuleGraph(), each entry's static dependencies are walked via getDependenciesToBeIncluded(). This method checks sideEffectDependenciesByVariable to build alwaysCheckedDependencies. Since the second entry has no side-effect recording for Foo, its alwaysCheckedDependencies is empty. Combined with the barrel having moduleSideEffects: false, addRelevantSideEffectDependencies() skips the barrel entirely and never recurses into its sub-dependencies. The CSS module is therefore never added to the second entry's staticDependencies.
Rollup Version
4.57.1
Operating System (or Browser)
macOS 14.4 (also reproducible on Linux)
Node Version (if applicable)
v20.x
Link To Reproduction
https://stackblitz.com/edit/rollup-repro-3bwuswb5?file=rollup.config.mjs&view=editor
Expected Behaviour
Reproduction Setup
Directory Structure:
package.json:
{ "name": "rollup-namespace-reexport-side-effect-bug", "private": true, "type": "module", "sideEffects": [ "src/lib/foo/index.js", "src/lib/foo/style.css", "src/lib/bar/index.js" ] }Note:
src/lib/index.js(the main barrel) is intentionally NOT listed insideEffects, which is a common pattern — barrel files themselves have no side effects, but the sub-modules they re-export from do.src/lib/foo/Foo.js — Component implementation (no CSS import):
src/lib/foo/style.css — Side-effect CSS:
src/lib/foo/index.js — Sub-barrel with side-effect import:
src/lib/bar/Bar.js:
src/lib/bar/index.js:
src/lib/index.js — Main barrel using
export *:src/entry1.js — First dynamic entry (imports
Foofrom barrel):src/entry2.js — Second dynamic entry (also imports
Foofrom barrel):src/entry3.js — Third dynamic entry (imports via direct
importof sub-barrel):src/main.js:
rollup.config.mjs:
Steps to Reproduce
Then inspect which chunks contain
style.css(or its content).All three entries (
entry1,entry2,entry3) importFoowhich is re-exported fromlib/foo/index.js. Sincelib/foo/index.jshas a side-effect import (import './style.css'), and it is listed insideEffects, all three entries should havelib/foo/index.js(and transitivelystyle.css) in their dependency graph.Specifically,
entry2'sgetDependenciesToBeIncluded()should includelib/foo/index.jsas a side-effect dependency of theFoovariable, just likeentry1.Actual Behaviour
Only the first entry to resolve
Foothrough the barrel'sexport *gets the side-effect dependency recorded. The second and subsequent entries miss it entirely.This causes
style.cssto be assigned to a chunk that only the first entry loads. Other entries that import the sameFoovariable through the same barrel get the JavaScript but not the CSS.Root Cause Analysis
The bug is in
Module.getVariableForExportName()insrc/Module.ts, specifically thenamespaceReexportsByNamecaching logic:The issue:
getVariableFromNamespaceReexports()internally callsgetVariableForExportName()on the sub-barrel (lib/foo/index.js), which records the side-effect dependency via:But this code only executes when
getVariableFromNamespaceReexports()is called — which only happens on cache MISS. On cache HIT, the cached[variable]is returned directly, and the side-effect recording for the newimporterForSideEffectsis completely skipped.Comparison with
reexportDescriptionsbranchThe
reexportDescriptionsbranch (handlingexport { Foo } from './Foo.js') does NOT have this problem because it always executes the side-effect recording code — there is no caching:Only the
namespaceReexportsByNamepath (forexport *) has this caching issue.Downstream impact on chunk assignment
In
analyzeModuleGraph(), each entry's static dependencies are walked viagetDependenciesToBeIncluded(). This method checkssideEffectDependenciesByVariableto buildalwaysCheckedDependencies. Since the second entry has no side-effect recording forFoo, itsalwaysCheckedDependenciesis empty. Combined with the barrel havingmoduleSideEffects: false,addRelevantSideEffectDependencies()skips the barrel entirely and never recurses into its sub-dependencies. The CSS module is therefore never added to the second entry'sstaticDependencies.