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

Issue:
Style-loader duplicates insert function
<!--
  Issues are so 🔥

  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.

  👉🏽 Need support, advice, or help? Don't open an issue!
  Head to StackOverflow or https://gitter.im/webpack/webpack.
-->

- Operating System: linux mint 20
- Node Version: v14.16.0
- NPM Version: 6.14.11
- webpack Version: 5.28.0
- css-loader Version: 3.0.0

### Expected Behavior

I expect that the style-loader will add the insert function to the bundle exactly 1 time.

<!-- Remove this section if not reporting a bug or modification request. -->

### Actual Behavior

<!-- Remove this section if not reporting a bug or modification request. -->

But in reality, this function is created for each css file and inserted as an expression, webpack cannot optimize it, because it thinks that these are different functions

![image](https://user-images.githubusercontent.com/32592443/124741224-25260f00-df24-11eb-8ca1-32d27ba92b0b.png)


### Code

```js
//css part of module.rules productuion config
{
    test: /\.css$/,
    exclude: /\.page\.css$/,
    use: [
      {
        loader: 'style-loader',
        options: {
          injectType: 'singletonStyleTag',
          insert: [Function: _insertCssIntoIframeImportedByStyleLoader]
        }
      },
      'css-modules-typescript-loader'
    ]
  },
  {
    test: /\.page\.css$/,
    use: [ 'style-loader', 'css-modules-typescript-loader' ]
  },
  {
    test: /\.css$/,
    use: [
      {
        loader: 'css-loader',
        options: {
          importLoaders: 1,
          modules: { localIdentName: '[hash:base64:5]' }
        }
      },
      'postcss-loader'
    ]
  }
```


Pull request:
Feat: insert option processed path to module
<!--
  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:

- [ ] **bugfix**
- [x] new **feature**
- [ ] **code refactor**
- [x] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [x] **metadata update**

### Motivation / Use-Case

Fixes #518 

### Breaking Changes

No

### Additional Info

No


Patch:
diff --git a/README.md b/README.md
index 4bf21e3..55fa1b3 100644
--- a/README.md
+++ b/README.md
@@ -433,6 +433,8 @@ If you target an [iframe](https://developer.mozilla.org/en-US/docs/Web/API/HTMLI
 
 #### `String`
 
+##### `Selector`
+
 Allows to setup custom [query selector](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector) where styles inject into the DOM.
 
 **webpack.config.js**
@@ -458,6 +460,36 @@ module.exports = {
 };
 ```
 
+##### `Absolute path to function`
+
+Allows to setup absolute path to custom function that allows to override default behavior and insert styles at any position.
+
+> ⚠ Do not forget that this code will be used in the browser and not all browsers support latest ECMA features like `let`, `const`, `arrow function expression` and etc. We recommend using [`babel-loader`](https://webpack.js.org/loaders/babel-loader/) for support latest ECMA features.
+> ⚠ Do not forget that some DOM methods may not be available in older browsers, we recommended use only [DOM core level 2 properties](https://caniuse.com/#search=DOM%20Core), but it is depends what browsers you want to support
+
+**webpack.config.js**
+
+```js
+module.exports = {
+  module: {
+    rules: [
+      {
+        test: /\.css$/i,
+        use: [
+          {
+            loader: "style-loader",
+            options: {
+              insert: require.resolve("modulePath"),
+            },
+          },
+          "css-loader",
+        ],
+      },
+    ],
+  },
+};
+```
+
 A new `<style>`/`<link>` elements will be inserted into at bottom of `body` tag.
 
 #### `Function`
diff --git a/src/index.js b/src/index.js
index 373986b..c6e60fd 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,8 @@
+import path from "path";
+
 import {
   getImportInsertStyleElementCode,
-  getImportGetTargetCode,
+  getImportInsertBySelectorCode,
   getImportStyleContentCode,
   getImportStyleDomAPICode,
   getImportStyleAPICode,
@@ -14,6 +16,7 @@ import {
   getExportStyleCode,
   getExportLazyStyleCode,
   getSetAttributesCode,
+  getInsertOptionCode,
 } from "./utils";
 
 import schema from "./options.json";
@@ -22,11 +25,6 @@ const loaderAPI = () => {};
 
 loaderAPI.pitch = function loader(request) {
   const options = this.getOptions(schema);
-  const insert =
-    typeof options.insert === "string"
-      ? JSON.stringify(options.insert)
-      : '"head"';
-  const insertIsFunction = typeof options.insert === "function";
   const injectType = options.injectType || "styleTag";
   const { styleTagTransform } = options;
   const esModule =
@@ -41,19 +39,12 @@ loaderAPI.pitch = function loader(request) {
     runtimeOptions.base = options.base;
   }
 
-  const insertFn = insertIsFunction
-    ? options.insert.toString()
-    : `function(style){
-    var target = getTarget(${insert});
-
-    if (!target) {
-      throw new Error(
-        "Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid."
-      );
-    }
-
-    target.appendChild(style);
-  }`;
+  const insertType =
+    typeof options.insert === "function"
+      ? "function"
+      : options.insert && path.isAbsolute(options.insert)
+      ? "module-path"
+      : "selector";
 
   const styleTagTransformFn =
     typeof styleTagTransform === "function"
@@ -76,7 +67,7 @@ loaderAPI.pitch = function loader(request) {
 
       return `
       ${getImportLinkAPICode(esModule, this)}
-      ${getImportGetTargetCode(esModule, this, insertIsFunction)}
+      ${getImportInsertBySelectorCode(esModule, this, insertType, options)}
       ${getImportLinkContentCode(esModule, this, request)}
       ${
         esModule
@@ -86,7 +77,7 @@ loaderAPI.pitch = function loader(request) {
 
 var options = ${JSON.stringify(runtimeOptions)};
 
-options.insert = ${insertFn};
+${getInsertOptionCode(insertType, options)}
 
 var update = API(content, options);
 
@@ -109,7 +100,7 @@ ${esModule ? "export default {}" : ""}`;
 
       ${getImportStyleAPICode(esModule, this)}
       ${getImportStyleDomAPICode(esModule, this, isSingleton, isAuto)}
-      ${getImportGetTargetCode(esModule, this, insertIsFunction)}
+      ${getImportInsertBySelectorCode(esModule, this, insertType, options)}
       ${getSetAttributesCode(esModule, this, options)}
       ${getImportInsertStyleElementCode(esModule, this)}
       ${getImportStyleContentCode(esModule, this, request)}
@@ -131,7 +122,7 @@ var options = ${JSON.stringify(runtimeOptions)};
 
 ${getStyleTagTransformFn(styleTagTransformFn, isSingleton)};
 options.setAttributes = setAttributes;
-options.insert = ${insertFn};
+${getInsertOptionCode(insertType, options)}
 options.domAPI = ${getdomAPI(isAuto)};
 options.insertStyleElement = insertStyleElement;
 
@@ -168,7 +159,7 @@ ${getExportLazyStyleCode(esModule, this, request)}
       return `
       ${getImportStyleAPICode(esModule, this)}
       ${getImportStyleDomAPICode(esModule, this, isSingleton, isAuto)}
-      ${getImportGetTargetCode(esModule, this, insertIsFunction)}
+      ${getImportInsertBySelectorCode(esModule, this, insertType, options)}
       ${getSetAttributesCode(esModule, this, options)}
       ${getImportInsertStyleElementCode(esModule, this)}
       ${getImportStyleContentCode(esModule, this, request)}
@@ -183,7 +174,7 @@ var options = ${JSON.stringify(runtimeOptions)};
 
 ${getStyleTagTransformFn(styleTagTransformFn, isSingleton)};
 options.setAttributes = setAttributes;
-options.insert = ${insertFn};
+${getInsertOptionCode(insertType, options)}
 options.domAPI = ${getdomAPI(isAuto)};
 options.insertStyleElement = insertStyleElement;
 
diff --git a/src/runtime/getTarget.js b/src/runtime/insertBySelector.js
similarity index 67%
rename from src/runtime/getTarget.js
rename to src/runtime/insertBySelector.js
index 042a465..5fff84c 100644
--- a/src/runtime/getTarget.js
+++ b/src/runtime/insertBySelector.js
@@ -26,4 +26,17 @@ function getTarget(target) {
   return memo[target];
 }
 
-module.exports = getTarget;
+/* istanbul ignore next  */
+function insertBySelector(insert, style) {
+  const target = getTarget(insert);
+
+  if (!target) {
+    throw new Error(
+      "Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid."
+    );
+  }
+
+  target.appendChild(style);
+}
+
+module.exports = insertBySelector;
diff --git a/src/utils.js b/src/utils.js
index 6267edb..a456c66 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -112,15 +112,49 @@ function getImportStyleContentCode(esModule, loaderContext, request) {
     : `var content = require(${modulePath});`;
 }
 
-function getImportGetTargetCode(esModule, loaderContext, insertIsFunction) {
-  const modulePath = stringifyRequest(
-    loaderContext,
-    `!${path.join(__dirname, "runtime/getTarget.js")}`
-  );
+function getImportInsertBySelectorCode(
+  esModule,
+  loaderContext,
+  insertType,
+  options
+) {
+  if (insertType === "selector") {
+    const modulePath = stringifyRequest(
+      loaderContext,
+      `!${path.join(__dirname, "runtime/insertBySelector.js")}`
+    );
 
-  return esModule
-    ? `${!insertIsFunction ? `import getTarget from ${modulePath};` : ""}`
-    : `${!insertIsFunction ? `var getTarget = require(${modulePath});` : ""}`;
+    return esModule
+      ? `import insertFn from ${modulePath};`
+      : `var insertFn = require(${modulePath});`;
+  }
+
+  if (insertType === "module-path") {
+    const modulePath = stringifyRequest(loaderContext, `${options.insert}`);
+
+    return esModule
+      ? `import insertFn from ${modulePath};`
+      : `var insertFn = require(${modulePath});`;
+  }
+
+  return "";
+}
+
+function getInsertOptionCode(insertType, options) {
+  if (insertType === "selector") {
+    const insert = options.insert ? JSON.stringify(options.insert) : '"head"';
+
+    return `
+      options.insert = insertFn.bind(null, ${insert});
+    `;
+  }
+
+  if (insertType === "module-path") {
+    return `options.insert = insertFn;`;
+  }
+
+  // Todo remove "function" type for insert option in next major release, because code duplication occurs. Leave require.resolve()
+  return `options.insert = ${options.insert.toString()};`;
 }
 
 function getImportInsertStyleElementCode(esModule, loaderContext) {
@@ -307,7 +341,7 @@ function getSetAttributesCode(esModule, loaderContext, options) {
 export {
   stringifyRequest,
   getImportInsertStyleElementCode,
-  getImportGetTargetCode,
+  getImportInsertBySelectorCode,
   getImportStyleContentCode,
   getImportStyleDomAPICode,
   getImportStyleAPICode,
@@ -321,4 +355,5 @@ export {
   getExportStyleCode,
   getExportLazyStyleCode,
   getSetAttributesCode,
+  getInsertOptionCode,
 };


Test patch:
diff --git a/test/__snapshots__/insert-option.test.js.snap b/test/__snapshots__/insert-option.test.js.snap
index 12c42e4..b2ce4a3 100644
--- a/test/__snapshots__/insert-option.test.js.snap
+++ b/test/__snapshots__/insert-option.test.js.snap
@@ -342,6 +342,30 @@ exports[`"insert" option should insert styles into "head" bottom when not specif
 
 exports[`"insert" option should insert styles into "head" bottom when not specified and when the "injectType" option is "styleTag": warnings 1`] = `Array []`;
 
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazySingletonStyleTag" and insert is object: DOM 1`] = `
+"<!DOCTYPE html><html><head><style>body {
+  color: red;
+}
+h1 {
+  color: blue;
+}
+</style>
+  <title>style-loader test</title>
+  <style id=\\"existing-style\\">.existing { color: yellow }</style>
+</head>
+<body>
+  <h1>Body</h1>
+  <div class=\\"target\\"></div>
+  <iframe class=\\"iframeTarget\\"></iframe>
+
+
+</body></html>"
+`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazySingletonStyleTag" and insert is object: errors 1`] = `Array []`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazySingletonStyleTag" and insert is object: warnings 1`] = `Array []`;
+
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazySingletonStyleTag": DOM 1`] = `
 "<!DOCTYPE html><html><head><style>body {
   color: red;
@@ -366,6 +390,30 @@ exports[`"insert" option should insert styles into "head" top when the "injectTy
 
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazySingletonStyleTag": warnings 1`] = `Array []`;
 
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazyStyleTag" and insert is object: DOM 1`] = `
+"<!DOCTYPE html><html><head><style>body {
+  color: red;
+}
+</style><style>h1 {
+  color: blue;
+}
+</style>
+  <title>style-loader test</title>
+  <style id=\\"existing-style\\">.existing { color: yellow }</style>
+</head>
+<body>
+  <h1>Body</h1>
+  <div class=\\"target\\"></div>
+  <iframe class=\\"iframeTarget\\"></iframe>
+
+
+</body></html>"
+`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazyStyleTag" and insert is object: errors 1`] = `Array []`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazyStyleTag" and insert is object: warnings 1`] = `Array []`;
+
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazyStyleTag": DOM 1`] = `
 "<!DOCTYPE html><html><head><style>body {
   color: red;
@@ -390,6 +438,24 @@ exports[`"insert" option should insert styles into "head" top when the "injectTy
 
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "lazyStyleTag": warnings 1`] = `Array []`;
 
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "linkTag" and insert is object: DOM 1`] = `
+"<!DOCTYPE html><html><head><link rel=\\"stylesheet\\" href=\\"style.css\\"><link rel=\\"stylesheet\\" href=\\"style-other.css\\">
+  <title>style-loader test</title>
+  <style id=\\"existing-style\\">.existing { color: yellow }</style>
+</head>
+<body>
+  <h1>Body</h1>
+  <div class=\\"target\\"></div>
+  <iframe class=\\"iframeTarget\\"></iframe>
+
+
+</body></html>"
+`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "linkTag" and insert is object: errors 1`] = `Array []`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "linkTag" and insert is object: warnings 1`] = `Array []`;
+
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "linkTag": DOM 1`] = `
 "<!DOCTYPE html><html><head><link rel=\\"stylesheet\\" href=\\"style.css\\"><link rel=\\"stylesheet\\" href=\\"style-other.css\\">
   <title>style-loader test</title>
@@ -408,6 +474,30 @@ exports[`"insert" option should insert styles into "head" top when the "injectTy
 
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "linkTag": warnings 1`] = `Array []`;
 
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "singletonStyleTag" and insert is object: DOM 1`] = `
+"<!DOCTYPE html><html><head><style>body {
+  color: red;
+}
+h1 {
+  color: blue;
+}
+</style>
+  <title>style-loader test</title>
+  <style id=\\"existing-style\\">.existing { color: yellow }</style>
+</head>
+<body>
+  <h1>Body</h1>
+  <div class=\\"target\\"></div>
+  <iframe class=\\"iframeTarget\\"></iframe>
+
+
+</body></html>"
+`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "singletonStyleTag" and insert is object: errors 1`] = `Array []`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "singletonStyleTag" and insert is object: warnings 1`] = `Array []`;
+
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "singletonStyleTag": DOM 1`] = `
 "<!DOCTYPE html><html><head><style>body {
   color: red;
@@ -432,6 +522,30 @@ exports[`"insert" option should insert styles into "head" top when the "injectTy
 
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "singletonStyleTag": warnings 1`] = `Array []`;
 
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "styleTag" and insert is object: DOM 1`] = `
+"<!DOCTYPE html><html><head><style>body {
+  color: red;
+}
+</style><style>h1 {
+  color: blue;
+}
+</style>
+  <title>style-loader test</title>
+  <style id=\\"existing-style\\">.existing { color: yellow }</style>
+</head>
+<body>
+  <h1>Body</h1>
+  <div class=\\"target\\"></div>
+  <iframe class=\\"iframeTarget\\"></iframe>
+
+
+</body></html>"
+`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "styleTag" and insert is object: errors 1`] = `Array []`;
+
+exports[`"insert" option should insert styles into "head" top when the "injectType" option is "styleTag" and insert is object: warnings 1`] = `Array []`;
+
 exports[`"insert" option should insert styles into "head" top when the "injectType" option is "styleTag": DOM 1`] = `
 "<!DOCTYPE html><html><head><style>body {
   color: red;
diff --git a/test/fixtures/insertFn.js b/test/fixtures/insertFn.js
new file mode 100644
index 0000000..1b5e7db
--- /dev/null
+++ b/test/fixtures/insertFn.js
@@ -0,0 +1,19 @@
+function insert (element) {
+  const parent = document.querySelector("head");
+  const lastInsertedElement =
+    // eslint-disable-next-line no-underscore-dangle
+    window._lastElementInsertedByStyleLoader;
+
+  if (!lastInsertedElement) {
+    parent.insertBefore(element, parent.firstChild);
+  } else if (lastInsertedElement.nextSibling) {
+    parent.insertBefore(element, lastInsertedElement.nextSibling);
+  } else {
+    parent.appendChild(element);
+  }
+
+  // eslint-disable-next-line no-underscore-dangle
+  window._lastElementInsertedByStyleLoader = element;
+};
+
+module.exports = insert;
diff --git a/test/insert-option.test.js b/test/insert-option.test.js
index d92d4e0..3024782 100644
--- a/test/insert-option.test.js
+++ b/test/insert-option.test.js
@@ -136,6 +136,24 @@ describe('"insert" option', () => {
       expect(getErrors(stats)).toMatchSnapshot("errors");
     });
 
+    it(`should insert styles into "head" top when the "injectType" option is "${injectType}" and insert is object`, async () => {
+      expect.assertions(3);
+
+      const entry = getEntryByInjectType("simple.js", injectType);
+      const compiler = getCompiler(entry, {
+        injectType,
+        insert: require.resolve("./fixtures/insertFn.js"),
+      });
+      const stats = await compile(compiler);
+
+      runInJsDom("main.bundle.js", compiler, stats, (dom) => {
+        expect(dom.serialize()).toMatchSnapshot("DOM");
+      });
+
+      expect(getWarnings(stats)).toMatchSnapshot("warnings");
+      expect(getErrors(stats)).toMatchSnapshot("errors");
+    });
+
     it(`should insert styles into before "#existing-style" id when the "injectType" option is "${injectType}"`, async () => {
       expect.assertions(3);
 
diff --git a/test/runtime/injectStylesIntoLinkTag.test.js b/test/runtime/injectStylesIntoLinkTag.test.js
index 57b6b6f..871e0f6 100644
--- a/test/runtime/injectStylesIntoLinkTag.test.js
+++ b/test/runtime/injectStylesIntoLinkTag.test.js
@@ -6,20 +6,9 @@
 
 import injectStylesIntoLinkTag from "../../src/runtime/injectStylesIntoLinkTag";
 
-import getTarget from "../../src/runtime/getTarget";
+import insertBySelector from "../../src/runtime/insertBySelector";
 
-const getInsertFn = (place) =>
-  function insertFn(style) {
-    const target = getTarget(place);
-
-    if (!target) {
-      throw new Error(
-        "Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid."
-      );
-    }
-
-    target.appendChild(style);
-  };
+const getInsertFn = (place) => insertBySelector.bind(null, place);
 
 function insertAtTop(element) {
   const parent = document.querySelector("head");
diff --git a/test/runtime/injectStylesIntoStyleTag.test.js b/test/runtime/injectStylesIntoStyleTag.test.js
index 3208fd7..f16ea70 100644
--- a/test/runtime/injectStylesIntoStyleTag.test.js
+++ b/test/runtime/injectStylesIntoStyleTag.test.js
@@ -9,20 +9,9 @@ import injectStylesIntoStyleTag from "../../src/runtime/injectStylesIntoStyleTag
 import domAPI from "../../src/runtime/styleDomAPI";
 import singletonApi from "../../src/runtime/singletonStyleDomAPI";
 import insertStyleElement from "../../src/runtime/insertStyleElement";
-import getTarget from "../../src/runtime/getTarget";
+import insertBySelector from "../../src/runtime/insertBySelector";
 
-const getInsertFn = (place) =>
-  function insertFn(style) {
-    const target = getTarget(place);
-
-    if (!target) {
-      throw new Error(
-        "Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid."
-      );
-    }
-
-    target.appendChild(style);
-  };
+const getInsertFn = (place) => insertBySelector.bind(null, place);
 
 function styleTagTransform(css, style) {
   if (style.styleSheet) {