Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CSS Module locals/hashmap are being overwritten when more than one 'module' exists in same file #1518

Closed
RaphaelDDL opened this issue May 11, 2023 · 6 comments

Comments

@RaphaelDDL
Copy link

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
image

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
image

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

@alexander-akait
Copy link
Member

Yeah, a known problem with CSS-in-JS solutions without runtime, we can't fix it here, vue-loader should fix it on own side, also I am not sure, but sometimes some CSS-in-JS solutions can have such limitations, there is the same problem and how developers use it #1384.

We can't control how CSS was imported (in css-loader), we just generate CSS code and classes for modules

If you look at generated code, you will see (disable devtool for better reading):

const cssModules = {}
;
cssModules["$style"] = _app_vue_vue_type_style_index_0_id_5ef48958_lang_scss_module_true__WEBPACK_IMPORTED_MODULE_2__["default"];
if (false) {}

cssModules["$style"] = _app_vue_vue_type_style_index_1_id_5ef48958_lang_scss_module_true__WEBPACK_IMPORTED_MODULE_3__["default"]
if (false) {}

And as you can see the styles are just overwritten, it can be solved using Object.assign instead =, but again, this code generated by vue-loader (or vue template compiler), example of solution:

const cssModules = {}
;
cssModules["$style"] = {}
;
Object.assign(cssModules["$style"], _app_vue_vue_type_style_index_0_id_5ef48958_lang_scss_module_true__WEBPACK_IMPORTED_MODULE_2__["default"]);

if (false) {}

Object.assign(cssModules["$style"], _app_vue_vue_type_style_index_1_id_5ef48958_lang_scss_module_true__WEBPACK_IMPORTED_MODULE_3__["default"])

if (false) {}

Also want to note again - it can be a limitation on vue side with multiple style tags with the module attribute

Feel free to feedback

@RaphaelDDL
Copy link
Author

First, thank you very much @alexander-akait for taking your time reading and understanding my issue. I appreciate it

Like I said, I wasn't even sure if multiple modules would be a bug or just not thought in this scenario. That's why I thought in asking in css-loader instead of vue-loader at first, as you would understand better the concept of the CSS Module itself. I guess I got sidetracked because of the locals = {} in the compiled code made by the css-loader lol.

After seeing more of how is used outside Vue (React) during weekend, I came to see it could be really vue-loader, since in react is manually import style from './a-file.css' and used style in the JSX.

With your confirmation showing the un-devtool'd version (nice tip, I'll use it more) and the suggestion of possible fix, which was kinda what I had imagined in the pseudo-code, I understand now that in fact, that it is vue-loader's job to piece each of these style tags which became "separate" css files into a single applicable style, including the module exports hashmaps.

So I went into the real source on where styles are built, in special for modules and what is done in them and yeah, if I change

code += `\ncssModules["${name}"] = ${styleVar}`

to

code += `\n
    if(cssModules["${name}"]){
        Object.assign(cssModules["${name}"], ${styleVar});
    } else {
        cssModules["${name}"] = ${styleVar};
    }`

it'll parse each css-loader locals properly for all my test cases (except one, multiple module names style[module=a] and style[module=b] still break but I'll looking into it)

I'll try now push this into vue-loader and see if Evan and team will accept.

Thank you very much once again, Alex!

@RaphaelDDL
Copy link
Author

RaphaelDDL commented May 16, 2023

@alexander-akait I do have a following question if you don't mind, regarding one of the issues I pointed, and this one I'm not sure is vue-loader's fault but how the hash is made for the class name:

Circling back to this PoC:

<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>

3 modules names, moduledefault, modulecocacola, modulepepsi. All three have the same classname, modulenamedbrands. Ok, so, if everything was working as intended (vue-loader, css-loader, etc), I would expect to be able to access them through moduledefault.modulenamedbrands, modulecocacola.modulenamedbrands, modulepepsi.modulenamedbrands respectively, right?

In this scenario, as I showed in the original post, all them are getting the same name. So I checked the code with devtools off, and seems the module is given for the call, e.g. for moduledefault

"./node_modules/css-loader/dist/cjs.js??clonedRuleSet-3.use[1]!./node_modules/vue-loader/dist/stylePostLoader.js!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-3.use[2]!./node_modules/sass-loader/dist/cjs.js??clonedRuleSet-3.use[3]!./node_modules/vue-loader/dist/index.js??ruleSet[1].rules[6].use[0]!./src/components/module-named-multiple.vue?vue&type=style&index=0&id=18f2a545&lang=scss&module=moduledefault":

The resulting hashed class always generates the same for all 3 different modules (the push shows the correct css, the locals all have same name):

/******* MODULEDEFAULT ****/

           // Module
            ___CSS_LOADER_EXPORT___.push([module.id, ".module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh {\n  color: green;\n}", ""]);
            // Exports
            ___CSS_LOADER_EXPORT___.locals = {
                "modulenamedbrands": "module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh"
            };

/******* MODULECOCACOLA ****/
            // Module
            ___CSS_LOADER_EXPORT___.push([module.id, ".module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh {\n  color: red;\n}", ""]);
            // Exports
            ___CSS_LOADER_EXPORT___.locals = {
                "modulenamedbrands": "module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh"
            };

/******* MODULEPEPSI ****/
          // Module
          ___CSS_LOADER_EXPORT___.push([module.id, ".module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh {\n  color: black;\n}", ""]);
          // Exports
          ___CSS_LOADER_EXPORT___.locals = {
	          "modulenamedbrands": "module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh"
          };

image

image

So, the file name and the class name are the same. Doesn't seem it's taking module name &module=modulecocacola into consideration when creating the hash, so the hash became the same in all.

I was thinking that I should be the one to tell css-loader that since I am the one that configured localIdentName to be '[name]_[local]_[hash:base64]', but checking https://webpack.js.org/loaders/css-loader/#localidentname in "Supported template strings" it doesn't have anything I can use differently regarding module, and the [hash] is always the same.

I did test having the 3 modules with 3 different classes (each having it's own module name as a class) and worked ok:

/******* MODULEDEFAULT ****/

// Module
___CSS_LOADER_EXPORT___.push([module.id, ".module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh {\n  color: green;\n}\n.module-named-multiple_moduledefault_zVQfNcu7rcnFIp5VJ8lC {\n  color: green;\n}", ""]);
// Exports
___CSS_LOADER_EXPORT___.locals = {
	"modulenamedbrands": "module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh",
	"moduledefault": "module-named-multiple_moduledefault_zVQfNcu7rcnFIp5VJ8lC"
};

/******* MODULECOCACOLA ****/

___CSS_LOADER_EXPORT___.push([module.id, ".module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh {\n  color: red;\n}\n.module-named-multiple_modulecocacola_EYVlTdkVAbpx1GMCG7VH {\n  color: red;\n}", ""]);
// Exports
___CSS_LOADER_EXPORT___.locals = {
	"modulenamedbrands": "module-named-multiple_modulenamedbrands_GeT1iohGlC280xnEEYWh",
	"modulecocacola": "module-named-multiple_modulecocacola_EYVlTdkVAbpx1GMCG7VH"
};

image
image

Is this "different module name same class generating same hashmap" a css-loader issue?

PS.: I was looking into hashFunction for localIdentHashFunction but I don't see how I could change [hash:base64] implementation to take modulename into account.

@alexander-akait
Copy link
Member

alexander-akait commented May 16, 2023

We have this https://github.com/webpack-contrib/css-loader/blob/master/src/utils.js#L340, but because vue-loader doesn't use specific syntax for this (i.e. !=!, you can found it in our test cases), you have a problem, as I said above we don't know how CSS was imported, so a loader above should think about collisions and solve it and we have !=! syntax in import, we have hashSalt, we allow to use custom function to generate, these solutions to solve such problems

@alexander-akait
Copy link
Member

"./node_modules/css-loader/dist/cjs.js??clonedRuleSet-3.use[1]!./node_modules/vue-loader/dist/stylePostLoader.js!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-3.use[2]!./node_modules/sass-loader/dist/cjs.js??clonedRuleSet-3.use[3]!./node_modules/vue-loader/dist/index.js??ruleSet[1].rules[6].use[0]!./src/components/module-named-multiple.vue?vue&type=style&index=0&id=18f2a545&lang=scss&module=moduledefault":

as you can see moduledefault is a part of vue-loader request

@RaphaelDDL
Copy link
Author

RaphaelDDL commented May 18, 2023

Thank you very much again Alex, I couldn't find exactly how vue-loader would pass this to css-loader but I'll make a separated issue then for them :-)

edit: I've created the PR with my changes, though they don't fix all issues, specifically 2 in the extends and the one where module name is not forwarded for creating the hash vuejs/vue-loader#2045

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants