Repository: web-infra-dev/rsbuild (TypeScript)

Issue:
[Bug]: build fails when trying to import CSS with `tools.cssLoader.exportType = 'string'`
### Version

```sh
System:
  OS: Linux 6.12 Manjaro Linux
  CPU: (20) x64 Intel(R) Core(TM) i5-14600K
  Memory: 18.60 GB / 31.12 GB
  Container: Yes
  Shell: 5.9 - /usr/bin/zsh
Browsers:
  Chromium: 133.0.6943.141
npmPackages:
  @rsbuild/core: ^1.2.11 => 1.2.14
```

### Details

I'm trying to use `tools.cssLoader.exportType = 'string'` option to import CSS as a plain string but my build fails with an error `TypeError: Cannot use 'in' operator to search for 'locals' in body { color: #fff; ...`

Full error log when running build command:

```
> rsbuild-css-export-string-bug@1.0.0 build /home/username/TMP/rsbuild-css-export-string-bug
> rsbuild build

  Rsbuild v1.2.14

error   Compile error: 
Failed to compile, check the errors for troubleshooting.
File: /home/username/TMP/rsbuild-css-export-string-bug/src/index.css:1:1
  × Module build failed:
  ╰─▶   × TypeError: Cannot use 'in' operator to search for 'locals' in body {
        │   color: #fff;
        │   background-image: linear-gradient(#020917, #101725);
        │   margin: 0;
        │   font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
        │ }
        │
        │ .content {
        │   text-align: center;
        │   flex-direction: column;
        │   justify-content: center;
        │   min-height: 100vh;
        │   line-height: 1.1;
        │   display: flex;
        │ }
        │
        │ .content h1 {
        │   font-size: 3.6rem;
        │   font-weight: 700;
        │ }
        │
        │ .content p {
        │   opacity: .5;
        │   font-size: 1.2rem;
        │   font-weight: 400;
        │ }
        │
        │     at handleExports (/home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rspack+core@1.2.5_@swc+helpers@0.5.15/node_modules/@rspack/core/dist/cssExtractLoader.js:172:77)
        │     at /home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rspack+core@1.2.5_@swc+helpers@0.5.15/node_modules/@rspack/core/dist/cssExtractLoader.js:264:7
        │     at /home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rspack+core@1.2.5_@swc+helpers@0.5.15/node_modules/@rspack/core/dist/index.js:6061:18
        │     at /home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rspack+core@1.2.5_@swc+helpers@0.5.15/node_modules/@rspack/core/dist/index.js:6034:13
      
 @ ./src/index.js

error   Failed to build.
error   Rspack build failed!
    at file:///home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rsbuild+core@1.2.14/node_modules/@rsbuild/core/dist/index.js:5697:87
    at finalCallback (/home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rspack+core@1.2.5_@swc+helpers@0.5.15/node_modules/@rspack/core/dist/index.js:15075:9)
    at /home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rspack+core@1.2.5_@swc+helpers@0.5.15/node_modules/@rspack/core/dist/index.js:15107:16
    at done (/home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rspack+lite-tapable@1.0.1/node_modules/@rspack/lite-tapable/dist/index.js:473:13)
    at promise.then.index (/home/username/TMP/rsbuild-css-export-string-bug/node_modules/.pnpm/@rspack+lite-tapable@1.0.1/node_modules/@rspack/lite-tapable/dist/index.js:493:25)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
 ELIFECYCLE  Command failed with exit code 1.
```

### Reproduce link

https://github.com/pajecawav/rsbuild-css-export-string-bug

### Reproduce Steps

1. `git clone https://github.com/pajecawav/rsbuild-css-export-string-bug`
2. `cd rsbuild-css-export-string-bug`
3. `pnpm install`
4. `pnpm build`
5. Build fails with an error `TypeError: Cannot use 'in' operator to search for 'locals' in body { ...`


Discussion: This will be supported in the next release



Fix (patch):
diff --git a/packages/core/src/plugins/css.ts b/packages/core/src/plugins/css.ts
index 28ae9ff53b..24bf726363 100644
--- a/packages/core/src/plugins/css.ts
+++ b/packages/core/src/plugins/css.ts
@@ -1,9 +1,9 @@
-import path from 'node:path';
+import path, { posix } from 'node:path';
 import deepmerge from 'deepmerge';
 import type { AcceptedPlugin, PluginCreator } from 'postcss';
 import { reduceConfigs, reduceConfigsWithContext } from 'reduce-configs';
 import { CSS_REGEX, LOADER_PATH } from '../constants';
-import { castArray } from '../helpers';
+import { castArray, getFilename } from '../helpers';
 import { getCompiledPath } from '../helpers/path';
 import { getCssExtractPlugin } from '../pluginHelper';
 import type {
@@ -356,12 +356,50 @@ export const pluginCss = (): RsbuildPlugin => ({
         });
         rule.use(CHAIN_ID.USE.CSS).options(cssLoaderOptions);
 
+        const isStringExport = cssLoaderOptions.exportType === 'string';
+        if (isStringExport && rule.uses.has(CHAIN_ID.USE.MINI_CSS_EXTRACT)) {
+          rule.uses.delete(CHAIN_ID.USE.MINI_CSS_EXTRACT);
+        }
+
         // CSS imports should always be treated as sideEffects
         rule.merge({ sideEffects: true });
 
         // Enable preferRelative by default, which is consistent with the default behavior of css-loader
         // see: https://github.com/webpack-contrib/css-loader/blob/579fc13/src/plugins/postcss-import-parser.js#L234
         rule.resolve.preferRelative(true);
+
+        // Apply CSS extract plugin if not using style-loader and emitCss is true
+        if (emitCss && !config.output.injectStyles && !isStringExport) {
+          const extractPluginOptions = config.tools.cssExtract.pluginOptions;
+
+          const cssPath = config.output.distPath.css;
+          const cssFilename = getFilename(config, 'css', isProd);
+          const isCssFilenameFn = typeof cssFilename === 'function';
+
+          const cssAsyncPath =
+            config.output.distPath.cssAsync ??
+            (cssPath ? `${cssPath}/async` : 'async');
+
+          chain
+            .plugin(CHAIN_ID.PLUGIN.MINI_CSS_EXTRACT)
+            .use(getCssExtractPlugin(), [
+              {
+                filename: isCssFilenameFn
+                  ? (...args) => {
+                      const name = cssFilename(...args);
+                      return posix.join(cssPath, name);
+                    }
+                  : posix.join(cssPath, cssFilename),
+                chunkFilename: isCssFilenameFn
+                  ? (...args) => {
+                      const name = cssFilename(...args);
+                      return posix.join(cssAsyncPath, name);
+                    }
+                  : posix.join(cssAsyncPath, cssFilename),
+                ...extractPluginOptions,
+              },
+            ]);
+        }
       },
     });
   },
diff --git a/packages/core/src/plugins/output.ts b/packages/core/src/plugins/output.ts
index ee02f79d74..6301338d29 100644
--- a/packages/core/src/plugins/output.ts
+++ b/packages/core/src/plugins/output.ts
@@ -6,7 +6,6 @@ import {
   DEFAULT_PORT,
 } from '../constants';
 import { formatPublicPath, getFilename, urlJoin } from '../helpers';
-import { getCssExtractPlugin } from '../pluginHelper';
 import { replacePortPlaceholder } from '../server/open';
 import type {
   NormalizedEnvironmentConfig,
@@ -77,7 +76,7 @@ export const pluginOutput = (): RsbuildPlugin => ({
 
   setup(api) {
     api.modifyBundlerChain(
-      async (chain, { CHAIN_ID, target, isProd, isServer, environment }) => {
+      async (chain, { CHAIN_ID, isProd, isServer, environment }) => {
         const { distPath, config } = environment;
 
         const publicPath = getPublicPath({
@@ -138,40 +137,6 @@ export const pluginOutput = (): RsbuildPlugin => ({
             .plugin(CHAIN_ID.PLUGIN.COPY)
             .use(rspack.CopyRspackPlugin, [options]);
         }
-
-        // CSS output
-        const emitCss = config.output.emitCss ?? target === 'web';
-        if (!config.output.injectStyles && emitCss) {
-          const extractPluginOptions = config.tools.cssExtract.pluginOptions;
-
-          const cssPath = config.output.distPath.css;
-          const cssFilename = getFilename(config, 'css', isProd);
-          const isCssFilenameFn = typeof cssFilename === 'function';
-
-          const cssAsyncPath =
-            config.output.distPath.cssAsync ??
-            (cssPath ? `${cssPath}/async` : 'async');
-
-          chain
-            .plugin(CHAIN_ID.PLUGIN.MINI_CSS_EXTRACT)
-            .use(getCssExtractPlugin(), [
-              {
-                filename: isCssFilenameFn
-                  ? (...args) => {
-                      const name = cssFilename(...args);
-                      return posix.join(cssPath, name);
-                    }
-                  : posix.join(cssPath, cssFilename),
-                chunkFilename: isCssFilenameFn
-                  ? (...args) => {
-                      const name = cssFilename(...args);
-                      return posix.join(cssAsyncPath, name);
-                    }
-                  : posix.join(cssAsyncPath, cssFilename),
-                ...extractPluginOptions,
-              },
-            ]);
-        }
       },
     );
   },