Skip to content

namespaceReexportsByName cache skips side-effect recording for subsequent importers via export * #6274

@littlegrayss

Description

@littlegrayss

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions