Repository: webpack-contrib/css-loader (js)

Issue:
\@ in CSS Selector
* macOS Mojave 10.14 (18A391)
* Node.js 11.13.0
* npm 6.7.0
* webpack 4.29.6
* css-loader 2.1.1

### Expected Behavior

```HTML
<div class="m_x_@"></div>
```

### Actual Behavior

```SCSS
.m_x_\@ {
  margin-left: auto !important;
  margin-right: auto !important;
}
```

```JSX
/*
 * @flow
 */

import * as React from 'react';

import design from './design.scss';

class Test extends React.Component<P: {

}> {
  render () {
    return <div className={design['m_x_@']} />;
  }
}

export default Test;
```

```HTML
<div class="m_x_----wB9u"></div>
```

### Code

```js
const path = require('path');

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const sass = require('sass');

const isProduction = process.env.NODE_ENV === 'production';

const js = {
  exclude: /node_modules/,
  test: /\.js$/,
  use: [
    {
      loader: 'babel-loader',
    },
  ],
};

module.exports = {
  devtool: isProduction ? '' : 'source-map',
  entry: {
    index: path.resolve(__dirname, 'private/index.js'),
  },
  mode: isProduction ? 'production' : 'development',
  module: {
    rules: [
      js,
      {
        exclude: /node_modules/,
        test: /\.scss$/,
        use: [
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              importLoaders: 2,
              localIdentName: isProduction ? '[hash:base64:8]' : '[local]--[hash:base64:4]',
              modules: true,
              sourceMap: !isProduction,
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              plugins: [
                require('autoprefixer'),
              ],
              sourceMap: !isProduction,
            },
          },
          {
            loader: 'sass-loader',
            options: {
              implementation: sass,
              outputStyle: isProduction ? 'compressed' : 'expanded',
              sourceMap: !isProduction,
            },
          },
        ],
      },
    ],
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'public'),
  },
  plugins: [
    new MiniCssExtractPlugin(),
  ],
};
```


Pull request:
fix: avoid all escaped characters replaced
<!--
  HOLY CRAP a Pull Request. We ❤️ those!

  If you remove or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  Please place an x (no spaces!) in all [ ] that apply
-->

This PR contains a:

- [x] **bugfix**
- [ ] new **feature**
- [ ] **code refactor**
- [x] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [ ] **metadata update**

### Motivation / Use-Case

<!--
  Please explain the motivation or use-case for your change.
  What existing problem does the PR solve?
  If this PR addresses an issue, please link to the issue.
-->
fixes #913 

### Breaking Changes

<!--
  If this PR introduces a breaking change, please describe the impact and a
  migration path for existing applications.
-->
No

### Additional Info
No


Patch:
diff --git a/README.md b/README.md
index cefaac7..eaf209c 100644
--- a/README.md
+++ b/README.md
@@ -324,7 +324,7 @@ Styles can be locally scoped to avoid globally scoping styles.
 The syntax `:local(.className)` can be used to declare `className` in the local scope. The local identifiers are exported by the module.
 
 With `:local` (without brackets) local mode can be switched on for this selector.
-The `:global(.className)` nocation can be used to declare an explicit global selector.
+The `:global(.className)` notation can be used to declare an explicit global selector.
 With `:global` (without brackets) global mode can be switched on for this selector.
 
 The loader replaces local selectors with unique identifiers. The chosen unique identifiers are exported by the module.
diff --git a/src/utils.js b/src/utils.js
index c647dc8..7614945 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -88,9 +88,7 @@ function getLocalIdent(loaderContext, localIdentName, localName, options) {
     options
   );
 
-  return hash
-    .replace(new RegExp('[^a-zA-Z0-9\\-_\u00A0-\uFFFF]', 'g'), '-')
-    .replace(/^((-?[0-9])|--)/, '_$1');
+  return normalizeIdentifier(hash);
 }
 
 function getFilter(filter, resourcePath, defaultFilter = null) {
@@ -107,6 +105,52 @@ function getFilter(filter, resourcePath, defaultFilter = null) {
   };
 }
 
+function normalizeIdentifier(value) {
+  const escapedSymbols = [
+    '~',
+    '!',
+    '@',
+    '#',
+    '$',
+    '%',
+    '&',
+    '^',
+    '*',
+    '(',
+    ')',
+    '{',
+    '}',
+    '[',
+    ']',
+    '`',
+    '/',
+    '=',
+    '?',
+    '+',
+    '\\',
+    '|',
+    '-',
+    '_',
+    ':',
+    ';',
+    "'",
+    '"',
+    ',',
+    '<',
+    '.',
+    '>',
+  ];
+
+  const identifiersRegExp = new RegExp(
+    `[^a-zA-Z0-9${escapedSymbols.join('\\')}\\-_\u00A0-\uFFFF]`,
+    'g'
+  );
+
+  return value
+    .replace(identifiersRegExp, '-')
+    .replace(/^((-?[0-9])|--)/, '_$1');
+}
+
 export {
   getImportPrefix,
   getLocalIdent,


Test patch:
diff --git a/test/__snapshots__/localIdentName-option.test.js.snap b/test/__snapshots__/localIdentName-option.test.js.snap
index 23e0126..12b9821 100644
--- a/test/__snapshots__/localIdentName-option.test.js.snap
+++ b/test/__snapshots__/localIdentName-option.test.js.snap
@@ -35,6 +35,16 @@ Array [
 :local(.-a0-34a___f) {
   color: red;
 }
+
+:local(.m_x_\\\\@) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+:local(.B\\\\&W\\\\?) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
 ",
     "",
   ],
@@ -78,6 +88,16 @@ Array [
 :local(.-a0-34a___f) {
   color: red;
 }
+
+:local(.m_x_\\\\@) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+:local(.B\\\\&W\\\\?) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
 ",
     "",
   ],
@@ -121,6 +141,16 @@ Array [
 :local(.-a0-34a___f) {
   color: red;
 }
+
+:local(.m_x_\\\\@) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+:local(.B\\\\&W\\\\?) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
 ",
     "",
   ],
@@ -164,6 +194,16 @@ Array [
 :local(.-a0-34a___f) {
   color: red;
 }
+
+:local(.m_x_\\\\@) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+:local(.B\\\\&W\\\\?) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
 ",
     "",
   ],
@@ -207,6 +247,16 @@ Array [
 :local(.-a0-34a___f) {
   color: red;
 }
+
+:local(.m_x_\\\\@) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+:local(.B\\\\&W\\\\?) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
 ",
     "",
   ],
@@ -250,6 +300,16 @@ Array [
 :local(.-a0-34a___f) {
   color: red;
 }
+
+:local(.m_x_\\\\@) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+:local(.B\\\\&W\\\\?) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
 ",
     "",
   ],
@@ -293,6 +353,16 @@ Array [
 :local(.-a0-34a___f) {
   color: red;
 }
+
+:local(.m_x_\\\\@) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+:local(.B\\\\&W\\\\?) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
 ",
     "",
   ],
@@ -300,3 +370,67 @@ Array [
 `;
 
 exports[`localIdentName option should use hash prefix: warnings 1`] = `Array []`;
+
+exports[`localIdentName option should сorrectly replace escaped symbols in selector: errors 1`] = `Array []`;
+
+exports[`localIdentName option should сorrectly replace escaped symbols in selector: locals 1`] = `
+Object {
+  "-a0-34a___f": "-a0-34a___f--2nJ5",
+  "B&W?": "B&W?--1s8i",
+  "_test": "_test--23te",
+  "className": "className--1E8H",
+  "m_x_@": "m_x_@--2G3b",
+  "someId": "someId--3w7J",
+  "subClass": "subClass--3lo0",
+  "test": "test--NW9Y",
+}
+`;
+
+exports[`localIdentName option should сorrectly replace escaped symbols in selector: module (evaluated) 1`] = `
+Array [
+  Array [
+    1,
+    ".test--NW9Y {
+  background: red;
+}
+
+._test--23te {
+  background: blue;
+}
+
+.className--1E8H {
+  background: red;
+}
+
+#someId--3w7J {
+  background: green;
+}
+
+.className--1E8H .subClass--3lo0 {
+  color: green;
+}
+
+#someId--3w7J .subClass--3lo0 {
+  color: blue;
+}
+
+.-a0-34a___f--2nJ5 {
+  color: red;
+}
+
+.m_x_\\\\@--2G3b {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+.B\\\\&W\\\\?--1s8i {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+",
+    "",
+  ],
+]
+`;
+
+exports[`localIdentName option should сorrectly replace escaped symbols in selector: warnings 1`] = `Array []`;
diff --git a/test/fixtures/modules/localIdentName.css b/test/fixtures/modules/localIdentName.css
index e670d06..282de45 100644
--- a/test/fixtures/modules/localIdentName.css
+++ b/test/fixtures/modules/localIdentName.css
@@ -25,3 +25,13 @@
 :local(.-a0-34a___f) {
   color: red;
 }
+
+:local(.m_x_\@) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
+
+:local(.B\&W\?) {
+  margin-left: auto !important;
+  margin-right: auto !important;
+}
diff --git a/test/localIdentName-option.test.js b/test/localIdentName-option.test.js
index 91aac17..7ead50a 100644
--- a/test/localIdentName-option.test.js
+++ b/test/localIdentName-option.test.js
@@ -117,4 +117,26 @@ describe('localIdentName option', () => {
     expect(stats.compilation.warnings).toMatchSnapshot('warnings');
     expect(stats.compilation.errors).toMatchSnapshot('errors');
   });
+
+  it('should сorrectly replace escaped symbols in selector', async () => {
+    const config = {
+      loader: {
+        options: {
+          importLoaders: 2,
+          localIdentName: '[local]--[hash:base64:4]',
+          modules: true,
+        },
+      },
+    };
+    const testId = './modules/localIdentName.css';
+    const stats = await webpack(testId, config);
+    const { modules } = stats.toJson();
+    const module = modules.find((m) => m.id === testId);
+    const evaluatedModule = evaluated(module.source, modules);
+
+    expect(evaluatedModule).toMatchSnapshot('module (evaluated)');
+    expect(evaluatedModule.locals).toMatchSnapshot('locals');
+    expect(stats.compilation.warnings).toMatchSnapshot('warnings');
+    expect(stats.compilation.errors).toMatchSnapshot('errors');
+  });
 });