Skip to content

Unexpected CSS Module Ordering in Dev/Prod When Using Tree-Shaking #72846

@jantimon

Description

@jantimon

Link to the code that reproduces this issue

https://github.com/jantimon/reproduction-webpack-css-order

To Reproduce

Clone the repository and checkout the turbo branch

pnpm install

pnpm run dev

see that the button is blue (but should be orange)

Current vs. Expected behavior

While analyzing a CSS ordering problem in our monorepo, I traced it down to an interesting combination of module graph building and tree-shaking. The core of the issue appears to be in how the module graph handles CSS imports when sideEffects: false is set (or sideEffects: ["*.css"]

Looking at webpack's buildChunkGraph.js (https://github.com/webpack/webpack/blob/5e21745e98eb90a029e1f5374d4e4ac338fbe7c7/lib/buildChunkGraph.js#L683-L708), I found that the module traversal order changes once webpack is able to remove a barrel file.

That’s quite a bad DX for most developers because it means that the CSS order changes can be caused by JavaScript refactoring that seems completely unrelated to styles

Here's a concrete example from the reproduction - changing from:

import { CarouselButton } from '@segments/carousel';

to:

import { CarouselButton } from '@segments/carousel/buttons';

can unexpectedly reorder CSS across the entire application. This means that code cleanup like splitting up barrel files or moving components between packages can silently break styles in seemingly unrelated components.

I've done some testing across different bundlers to understand how they handle this scenario:

Bundler Consistent CSS Order CSS Treeshaking CSS Output
webpack ❌ Order depends on barrel files & sideEffects ✅ Excludes unused.module.css main.css
vite ✅ Button → Teaser → TeaserButton ✅ Excludes unused.module.css index.css
parcel ✅ Button → Teaser → TeaserButton ✅ Excludes unused.module.css index.5ff2b6c6.css
turbopack ❌ Order depends on barrel files & sideEffects N/A (no production build tested) N/A

What's interesting is that both Vite and Parcel manage to maintain consistent CSS ordering while still being able to tree-shake. So we might be able to find a middle ground that keeps the benefits of tree-shaking and allows a consistent CSS order

To better understand the issue, I've created a minimal reproduction: https://github.com/jantimon/reproduction-webpack-css-order

The tricky part is that this only manifests when several conditions align:

// @libraries/teaser/src/teaser.ts
import { CarouselButton } from '@segments/carousel'; // via barrel file
import styles from './teaser.module.css';

When building with sideEffects: false, the CSS order becomes unpredictable. Here's the output:

.hDE5PT5V3QGAPX9o9iZl { ... }
.yqrxTjAG22vkATE1VjR9 { background-color: orange; }  /* Should be last */
.R_y25aX9lTSLQtlxA1c9 { ... }

Here are the module graphs for the 3 scenarios.

The postOrder is the index which is used for the css order:

sideEffects: true example

graph TD
    subgraph "sideEffects: true ✅"
        A2["@applications/base/src/index.ts preOrder: 0, postOrder: 8"]
        B2["@libraries/teaser/src/index.ts preOrder: 1, postOrder: 7"]
        C2["@libraries/teaser/src/teaser.ts preOrder: 2, postOrder: 6"]
        D2["@segments/carousel/src/index.ts preOrder: 3, postOrder: 3"]
        E2["@segments/carousel/src/buttons.ts preOrder: 4, postOrder: 2"]
        F2["@segments/carousel/src/button.module.css preOrder: 5, postOrder: 1"]
        G2["button.module.css|0|||}} preOrder: 6, postOrder: 0"]
        H2["@libraries/teaser/src/teaser.module.css preOrder: 7, postOrder: 5"]
        I2["teaser.module.css|0|||}} preOrder: 8, postOrder: 4"]
        
        A2 --> B2
        B2 --> C2
        C2 --> D2
        D2 --> E2
        E2 --> F2
        F2 --> G2
        C2 --> H2
        H2 --> I2

        style A2 fill:#0a0a4a,stroke:#333
        style F2 fill:#294b51,stroke:#333
        style G2 fill:#294b51,stroke:#333
        style H2 fill:#294b51,stroke:#333
        style I2 fill:#294b51,stroke:#333
    end
Loading

no barrel example

graph TD
    subgraph "No Barrel ✅"
        A3["@applications/base/src/index.ts preOrder: 0, postOrder: 6"]
        B3["@libraries/teaser/src/teaser.ts preOrder: 1, postOrder: 5"]
        E3["@segments/carousel/src/buttons.ts preOrder: 2, postOrder: 2"]
        F3["@segments/carousel/src/button.module.css preOrder: 3, postOrder: 1"]
        G3["button.module.css|0|||}} preOrder: 4, postOrder: 0"]
        H3["@libraries/teaser/src/teaser.module.css preOrder: 5, postOrder: 4"]
        I3["teaser.module.css|0|||}} preOrder: 6, postOrder: 3"]
        
        A3 --> B3
        B3 --> E3
        E3 --> F3
        F3 --> G3
        B3 --> H3
        H3 --> I3
        style A3 fill:#0a0a4a,stroke:#333
        style F3 fill:#294b51,stroke:#333
        style G3 fill:#294b51,stroke:#333
        style H3 fill:#294b51,stroke:#333
        style I3 fill:#294b51,stroke:#333
    end
Loading

sideEffects:false example

graph TD
    subgraph "sideEffects: false ❌"
        A1["@applications/base/src/index.ts preOrder: 0, postOrder: 6"]
        B1["@libraries/teaser/src/teaser.ts preOrder: 1, postOrder: 5"]
        C1["@libraries/teaser/src/teaser.module.css preOrder: 2, postOrder: 1"]
        D1["teaser.module.css|0|||}} preOrder: 3, postOrder: 0"]
        E1["@segments/carousel/src/buttons.ts preOrder: 4, postOrder: 4"]
        F1["@segments/carousel/src/button.module.css preOrder: 5, postOrder: 3"]
        G1["button.module.css|0|||}} preOrder: 6, postOrder: 2"]
        
        A1 --> B1
        B1 --> C1
        C1 --> D1
        B1 --> E1
        E1 --> F1
        F1 --> G1

        style A1 fill:#0a0a4a,stroke:#333
        style C1 fill:#294b51,stroke:#333
        style D1 fill:#294b51,stroke:#333
        style F1 fill:#294b51,stroke:#333
        style G1 fill:#294b51,stroke:#333
    end
Loading

For me common suggestions like "just use Tailwind" or "increase specificity" miss the point - vanilla CSS with simple, understandable selectors should be an option. The unpredictable ordering creates harder to read code where developers need to constantly guard against CSS specificity bugs using &&& or !important.

The reproduction repo includes branches for different scenarios and bundlers, making it easy to verify the behavior:

Provide environment information

any

Which area(s) are affected? (Select all that apply)

Turbopack, Webpack

Which stage(s) are affected? (Select all that apply)

next dev (local), next build (local), next start (local)

Additional context

Related webpack issue:
webpack/webpack#18961

Metadata

Metadata

Assignees

No one assigned

    Labels

    CSSRelated to CSS.linear: nextConfirmed issue that is tracked by the Next.js team.linear: turbopackConfirmed issue that is tracked by the Turbopack team.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions