webpack-contrib / css-loader

CSS Loader
MIT License
4.3k stars 603 forks source link

Output unique class names for duplicated classes across multiple chunks #1602

Closed OliverJAsh closed 4 months ago

OliverJAsh commented 4 months ago

Related issues:

Feature Proposal

When CSS Modules is used in combination with code splitting, webpack may duplicate CSS across multiple chunks.

This is problematic because CSS files may be loaded in an arbitrary order resulting in unpredictable behaviour.

I believe we could mitigate this issue by ensuring duplicated classes have unique class names for each chunk they appear within. This could be achieved by adding a new template string to localIdentName that represents the current chunk. This would be similar to [chunkhash] as supported by output.

Feature Use Case

The reduced test case outlined below can be found here: https://github.com/OliverJAsh/webpack-css-order-test/tree/code-splitting

Let's say we have a project with this structure:

src/
├─ Button.js
├─ Button.css
├─ Collections.js
├─ Collections.css
├─ Photos.js
├─ Photos.css
├─ main.js

We have a Button component:

src/Button.js:

import * as styles from './Button.css';

export const Button = () => [styles.button];

src/Button.css:

.button {
  padding: 5px;
  color: blue;
}

Both Collections.js and Photos.js import Button.js as a component and compose its styles to override color:

src/Photos.js:

import { Button } from './Button';
import * as styles from './Photos.css';

export const Photos = () => [Button, styles.button];

src/Photos.css:

.button {
  color: black;
}

src/Collections.js:

import { Button } from './Button';
import * as styles from './Collections.css';

export const Collections = () => [Button, styles.button];

src/Collections.css:

.button {
  color: orange;
}

Within main.js, we dynamically import Photos.js and Collections.js. Pretend for this test-case that these imports are only triggered on page navigation:

// Loaded when navigating to a "Photos page"
import(/* webpackChunkName: "Photos" */ './Photos');

// Loaded when navigating to a "Collections page"
import(/* webpackChunkName: "Collections" */ './Collections');

Because Button.js is only used within 2 dynamic imports, and not directly in the main.js, the contents of Button.css get duplicated and prepended to Photos.css and Collections.css.

The resulting CSS files:

dist/Photos.css:

.src-Button__button {
  padding: 5px;
  color: blue;
}

.src-Photos__button {
  color: black;
}

dist/Collections.css:

.src-Button__button {
  padding: 5px;
  color: blue;
}

.src-Collections__button {
  color: orange;
}

Having the .src-Button__button rule duplicated across chunks makes it possible to undo the override set in Photos.css if the chunks are loaded in a specific order. The load order may vary depending on which components are rendered on the page, and how the user navigates around the application.

We can illustrate the problem by having a user navigate our app as follows:

Loads Photos Page -> Collections Page -> Photos Page
|                    |                   |
|- color: black      |- color: orange    |- color: blue

The CSS chunk for Collections is loaded after Photos, causing the .src-Button__button rule to be loaded a second time later in the cascade order, undoing the override we've set in Photos.css.

Please paste the results of npx webpack-cli info here, and mention other relevant information

$ npx webpack-cli info

  System:
    OS: macOS 14.5
    CPU: (10) arm64 Apple M1 Pro
    Memory: 51.59 MB / 16.00 GB
  Binaries:
    Node: 20.12.2 - /nix/store/bzzs4kvjyvjjhs3rj08vqpvvzmfggvbv-nodejs-20.12.2/bin/node
    Yarn: 1.22.22 - /nix/store/m2fiyh0393635g5vm804ady3rq0j24l4-yarn-1.22.22/bin/yarn
    npm: 10.5.0 - /nix/store/bzzs4kvjyvjjhs3rj08vqpvvzmfggvbv-nodejs-20.12.2/bin/npm
  Browsers:
    Chrome: 125.0.6422.77
    Safari: 17.5
  Packages:
    css-loader: ^7.1.2 => 7.1.2
    webpack: ^5.91.0 => 5.91.0
    webpack-cli: ^5.1.4 => 5.1.4
alexander-akait commented 4 months ago

Sorry, we can't get chunk name/chunk hash/etc while we are building modules, anyway we have https://webpack.js.org/configuration/module/#ruleissuer, so you can setup multiple css-loader loaders with different localIdentNames, another solution is using - https://webpack.js.org/configuration/module/#rulelayer, so you can setup different layers, so you can split your application on layers.

Also CSS modules support - https://github.com/webpack-contrib/css-loader?tab=readme-ov-file#separating-interoperable-css-only-and-css-module-features, so you can create basic button component and use composes (https://github.com/webpack-contrib/css-loader/blob/master/test/fixtures/modules/composes/composes.css#L44C3-L44C11)

Also my recomendation is avoid such situations and give unique name for the each class - i.e. have button-collection/button-photo/etc

Feel free to feedback

OliverJAsh commented 4 months ago

Also CSS modules support - https://github.com/webpack-contrib/css-loader?tab=readme-ov-file#separating-interoperable-css-only-and-css-module-features, so you can create basic button component and use composes (https://github.com/webpack-contrib/css-loader/blob/master/test/fixtures/modules/composes/composes.css#L44C3-L44C11)

Also my recomendation is avoid such situations and give unique name for the each class - i.e. have button-collection/button-photo/etc

Unfortunately neither of these suggestions solve the problem.

anyway we have https://webpack.js.org/configuration/module/#ruleissuer, so you can setup multiple css-loader loaders with different localIdentNames, another solution is using - https://webpack.js.org/configuration/module/#rulelayer, so you can setup different layers, so you can split your application on layers.

I don't think it would feasible to do this for all of the places where this issue may occur in a large application, such as the one we have at Unsplash.

alexander-akait commented 4 months ago

I understand, I’m just offering solutions, we simply don’t have chunks available at the time of building the modules

Another solution - use getLocalIdent like a function and get issuer from LoaderContext (i.e. loaderContext._module), it is hacky, but will work, and you can see which things you can use to achive your behaviour.

Feel free to feedback