-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
namespaceReexportsByName cache skips side-effect recording for subsequent importers via export * #6274
Description
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.mjsThen 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.