Description
Bug report
I'm using CSS modules with Vue3/Webpack5. All dependencies in their latest version as of this post (sass-loader, postcss-loader, css-loader, vue-loader, vue-style-loader, vue, webpack, etc). I've created a PoC with the issue https://github.com/RaphaelDDL/vue3-css-module
The css-loader options for module is as following:
options: {
modules: {
mode: 'local',
localIdentName: '[name]_[local]_[hash:base64]',
},
},
CSS Modules create a locals object ___CSS_LOADER_EXPORT___.locals
which has the object/map of class->hashed class, this happens for each css/style, as vue-loader parses and provides for later loaders each style as a separated css source (which can be captured with test: /\.(css|scss)$/
for e.g.)
The issue: When using more than one style
tag with a module
attr, only the last style
tag retains it's module locals. Each style will have it's locals parsed, but only the last one in each .vue file retains the hashmap. (Reasoning for why have two modules later). Since https://github.com/css-modules/css-modules seems completely abandoned, I have no idea if this is really a bug or never thought.
Actual Behavior
I've described in the PoC readme, but adding here as well. Taking the simplest test-case PoC Code module-nameless.vue::
<template>
<h2>
multiple module=> <span :class="$style.multi1">orange</span> | <span :class="$style.multi2">purple</span>
</h2>
</template>
<style lang="scss" module>
.multi1 { color: orange; }
</style>
<style lang="scss" module>
.multi2 { color: purple; }
</style>
When inspecting the loaders output, I can see the hashmaps being created, example:
// Module
___CSS_LOADER_EXPORT___.push([module.id, ".module-multiple_multi1_[HASH]" /* sourcemap, source, etc*/]);
// Exports
___CSS_LOADER_EXPORT___.locals = {
"multi1": ".module-multiple_multi1_[HASH]"
};
and
// Module
___CSS_LOADER_EXPORT___.push([module.id, ".module-multiple_multi2_[HASH]" /* sourcemap, source, etc*/]);
// Exports
___CSS_LOADER_EXPORT___.locals = {
"multi2": ".module-multiple_multi2_[HASH]"
};
You can see both were created and exist in the files served to browser
The issue is that the end-result is that only the file's last style's locals
hashmap exists for Vue, therefore the resulting HTML is a empty class as multi1
is not in locals
<h2>
multiple module=> <span class="">orange</span> | <span class="module-multiple_multi2_[HASH]">purple</span>
</h2>
This issue also happens with two styles with named modules, e.g. PoC Code module-multiple.vue:
<style lang="scss" module="classes">
.multi1 { color: orange; }
</style>
<style lang="scss" module="classes">
.multi2 { color: purple; }
</style>
The behavior is even weirder when you try multiple modules with different named modules, e.g PoC Code module-named-multiple.vue::
<template>
<h2>
multiple named module=>
<span :class="moduledefault.modulenamedbrands">green</span> |
<span :class="modulecocacola.modulenamedbrands">red</span> |
<span :class="modulepepsi.modulenamedbrands">black</span>
</h2>
</template>
<style lang="scss" module="moduledefault">
.modulenamedbrands {
color: green;
}
</style>
<style lang="scss" module="modulecocacola">
.modulenamedbrands {
color: red;
}
</style>
<style lang="scss" module="modulepepsi">
.modulenamedbrands {
color: black;
}
</style>
In this case, module names will be ignored as being separated name/scopes, all styles will create their CSS and all them will have the same name and hash instead, making the last style's class overwrite all others
Expected Behavior
The expectation was that multiple modules would work as if shallow copy/merging, where:
- nameless modules all merge their locals under same nameless scope (for vue, it creates
$style
, idk others) - modules that contain same name all merge their locals under same name/scope
- multiple modules with different names each merge their locals under each of their name/scope
This way, the locals hashmap would work like CSS where same rule/selector that comes later is applied on top of a earlier. Example:
First style generates:
___CSS_LOADER_EXPORT___.locals = {
"multi1": ".module-multiple_multi1_[HASH]"
};
Second style, also being a module, takes into account whatever locals already exists and merge, example:
___CSS_LOADER_EXPORT___.locals = {
"multi1": ".module-multiple_multi1_[HASH]",
"multi2": ".module-multiple_multi2_[HASH]"
};
Basically, a shallow merge (I assume the place would be in utils.js) for locals, e.g. pseudo-code
___CSS_LOADER_EXPORT___.locals = {
...___CSS_LOADER_EXPORT___.locals,
other locals
}
And same for named module(s), under each name/scope.
How Do We Reproduce?
As mentioned, I created a PoC in https://github.com/RaphaelDDL/vue3-css-module
It contains various views under src/components/
where I explore all different combinations with module
, scoped
, styles in style
and also in external files (style
with src
). The webpack config is the simplest I could: .vue is parsed by vue-loader
, then scss/css
is parsed by (in this order): sass-loader, postcss-loader, css-loader, vue-style-loader.
Now, after all this, the question that would come to mind is "Why have more than one module?":
To support two or more websites within each self-contained component, via an extra attribute in each style tag, called brand
. In the PoC, I created two brands: cocacola
and pepsi
. During run and build, I pass which brand I want, Example: npm run start:pepsi
will instruct to run in brand pepsi
, and in the webpack.config regarding the css, I use the CSS query for the other brand(s) and with nullLoader, to delete the other brand's styles.
<template>
<h2 :class="$style.heading2">my cool multi-brand component; has to be (red || black)</h2>
</template>
<style lang="scss" module brand="cocacola">
.heading2 { color: red; }
</style>
<style lang="scss" module brand="pepsi">
.heading2 { color: black; }
</style>
This is fine in all scenarios using styles, scoped, etc, except when modules is involved.
Please paste the results of npx webpack-cli info
here, and mention other relevant information
System:
OS: macOS 12.3.1
CPU: (8) x64 Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz
Memory: 58.40 MB / 32.00 GB
Binaries:
Node: 14.21.3 - ~/.nvm/versions/node/v14.21.3/bin/node
npm: 6.14.18 - ~/.nvm/versions/node/v14.21.3/bin/npm
Browsers:
Chrome: 113.0.5672.63
Safari: 15.4
Packages:
babel-loader: ^9.1.2 => 9.1.2
css-loader: ^6.7.3 => 6.7.3
html-webpack-plugin: ^5.5.1 => 5.5.1
null-loader: ^4.0.1 => 4.0.1
postcss-loader: ^7.3.0 => 7.3.0
sass-loader: ^13.2.2 => 13.2.2
simple-functional-loader: ^1.2.1 => 1.2.1
style-loader: ^3.3.2 => 3.3.2
vue-loader: ^17.1.0 => 17.1.0
vue-style-loader: ^4.1.3 => 4.1.3
webpack: ^5.82.0 => 5.82.0
webpack-cli: ^5.1.1 => 5.1.1
webpack-dev-server: ^4.15.0 => 4.15.0
webpack-merge: ^5.8.0 => 5.8.0
Conclusion
What I came to learn when debugging is that nullLoader doesn't "delete" the tag, it empties it's contents as it is what arrives as source when querying for .scss/.css. As I've shown before, only the LAST style with a module in each file keeps it's locals hashmap. Which means, if the last module in the file is the one being emptied, the empty style tag will make the entire locals empty (locals = {}
).
With this, I understood that CSS module doesn't support multiple style tags w/ module attribute, doesn't support multiple module attributes with same name, nor multiple different module names, etc.
As a "hacky" way to circumvent this issue (which doesn't cover everything, only when specifically has 2 styles with module, and both are nameless or named the same), I wrote a loader that runs before vue-loader, where I use RegExp to "delete" the other brand's style tag as a whole (commented, https://github.com/RaphaelDDL/vue3-css-module/blob/main/webpack.config.js#L108-L122 ) but of course, this approach is hard to read for those not used to regexp and VERY error-prone (e.g. the regexp atm doesn't cover yet self-closing styles, normally used when used with src
for external files, nor covers when there are more than 2 brands).
With the little documentation CSS Modules has in https://github.com/css-modules/css-modules (seems abandoned, last commit 6 years ago, multiple issues opened without discussion or conclusion), I'm not sure if having multiple modules not working is a real bug or this was never thought as an use-case in the concept of this feature.
I've looked at code regarding module's locals on css-loader utils L1182+, style-loader (index.js L108+, utils L198+) and vue-style-loader (index.js L43), seems they always expect a module or a named module, only?
Thank you for taking your time reading this, I appreciate any hint regarding this.
Best