} fragmentContent={<>
+ Nested
+ Fragment
+ >}/>
+
+ {[
+
+
+
+
+ {'item-one'.toUpperCase()}
+ {}
+ ,
+ ,
+
+
+
+
+ {(()=>)()}
+ {}
+
+ ]}
+ {...spreadChildren}
+ bold'
+ }}/>
+
+
+ >;
+ }
+})();
+exports["default"] = __webpack_exports__["default"];
+for(var __webpack_i__ in __webpack_exports__)if (-1 === [
+ "default"
+].indexOf(__webpack_i__)) exports[__webpack_i__] = __webpack_exports__[__webpack_i__];
+Object.defineProperty(exports, '__esModule', {
+ value: true
+});
+"
+`;
+
+exports[`JSX syntax should be preserved 2`] = `
+"import { App, App1A, App1B, App1C, app1cProps } from "./App1.js";
+import { App2, app2Props } from "./App2.js";
+const DynamicComponent = ()=>{
+ const Component = Math.random() > 0.5 ? App1A : App1C;
+ return import("./App1.js").then((mod)=>{
+ const Dynamic = mod[Component === App1A ? 'App1A' : 'App1C'];
+ return ;
+ });
+};
+const SectionWithSpread = (props)=>;
+const spreadChildren = [
+ First,
+ Second
+];
+function Root() {
+ return <>
+
+ <>
+
+ Loading...}>
+
+
+ >
+
+
+ {x ?
+
+
+
+
+
+ : }
+
+
+
+
+ } fragmentContent={<>
+ Nested
+ Fragment
+ >}/>
+
+ {[
+
+
+
+
+ {'item-one'.toUpperCase()}
+ {}
+ ,
+
,
+
+
+
+
+ {(()=>)()}
+ {}
+
+ ]}
+
{...spreadChildren}
+
bold'
+ }}/>
+
+
+ >;
+}
+export { Root as default };
+"
+`;
+
+exports[`JSX syntax should be preserved 3`] = `
+"import { App, App1A, App1B, App1C, app1cProps } from "./App1.jsx";
+import { App2, app2Props } from "./App2.jsx";
+const DynamicComponent = ()=>{
+ const Component = Math.random() > 0.5 ? App1A : App1C;
+ return import("./App1.jsx").then((mod)=>{
+ const Dynamic = mod[Component === App1A ? 'App1A' : 'App1C'];
+ return ;
+ });
+};
+const SectionWithSpread = (props)=>;
+const spreadChildren = [
+ First,
+ Second
+];
+function Root() {
+ return <>
+
+ <>
+
+ Loading...}>
+
+
+ >
+
+
+ {x ?
+
+
+
+
+
+ : }
+
+
+
+
+ } fragmentContent={<>
+ Nested
+ Fragment
+ >}/>
+
+ {[
+
+
+
+
+ {'item-one'.toUpperCase()}
+ {}
+ ,
+
,
+
+
+
+
+ {(()=>)()}
+ {}
+
+ ]}
+
{...spreadChildren}
+
bold'
+ }}/>
+
+
+ >;
+}
+export { Root as default };
+"
+`;
diff --git a/tests/integration/preserve-jsx/default/package.json b/tests/integration/preserve-jsx/default/package.json
new file mode 100644
index 000000000..e5a07d912
--- /dev/null
+++ b/tests/integration/preserve-jsx/default/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "preserve-jsx-default-test",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "react": "^19.1.1"
+ },
+ "devDependencies": {
+ "@rsbuild/plugin-react": "^1.4.1"
+ }
+}
diff --git a/tests/integration/preserve-jsx/default/rslib.config.ts b/tests/integration/preserve-jsx/default/rslib.config.ts
new file mode 100644
index 000000000..bbc1c5857
--- /dev/null
+++ b/tests/integration/preserve-jsx/default/rslib.config.ts
@@ -0,0 +1,42 @@
+import { pluginReact } from '@rsbuild/plugin-react';
+import { defineConfig } from '@rslib/core';
+import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper';
+
+export default defineConfig({
+ lib: [
+ generateBundleCjsConfig({
+ bundle: false,
+ }),
+ generateBundleEsmConfig({
+ bundle: false,
+ output: {
+ distPath: {
+ root: 'dist/esm0',
+ },
+ },
+ }),
+ generateBundleEsmConfig({
+ bundle: false,
+ output: {
+ distPath: {
+ root: 'dist/esm1',
+ },
+ filename: {
+ js: '[name].jsx',
+ },
+ },
+ }),
+ ],
+ source: {
+ entry: {
+ index: './src/**',
+ },
+ },
+ plugins: [
+ pluginReact({
+ swcReactOptions: {
+ runtime: 'preserve',
+ },
+ }),
+ ],
+});
diff --git a/tests/integration/preserve-jsx/default/src/Component1.jsx b/tests/integration/preserve-jsx/default/src/Component1.jsx
new file mode 100644
index 000000000..4ece10e68
--- /dev/null
+++ b/tests/integration/preserve-jsx/default/src/Component1.jsx
@@ -0,0 +1,105 @@
+import * as NamespaceImportApp1 from './App1';
+import { App1A, App1C, App1C as C } from './App1';
+import { App2, app2Props } from './App2';
+
+const DynamicComponent = () => {
+ const Component = Math.random() > 0.5 ? App1A : App1C;
+ return import('./App1').then((mod) => {
+ const Dynamic = mod[Component === App1A ? 'App1A' : 'App1C'];
+ return ;
+ });
+};
+
+const NamespaceComponents = {
+ Button: ({ label, ...rest }) => (
+
+ ),
+};
+
+const SectionWithSpread = (props) => ;
+
+const spreadChildren = [
+ First,
+ Second,
+];
+
+export default function Root() {
+ return (
+ <>
+
+ <>
+
+ Loading...}>
+
+
+ >
+
+
+ {x ? (
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ }
+ fragmentContent={
+ <>
+ Nested
+ Fragment
+ >
+ }
+ />
+
+ {[
+
+
+
+
+ {'item-one'.toUpperCase()}
+ {/* JSXEmptyExpr in action */}
+ ,
+
+
+ app2
+
+
+
+
+ {/* JSXEmptyExpr in action */}
+ ,
+
+
+
+
+ {(() => (
+
+ ))()}
+ {/* JSXEmptyExpr in action */}
+ ,
+ ]}
+
{...spreadChildren}
+
bold' }}
+ />
+
+
+ >
+ );
+}
diff --git a/tests/integration/preserve-jsx/default/src/Component2.tsx b/tests/integration/preserve-jsx/default/src/Component2.tsx
new file mode 100644
index 000000000..eb3770c72
--- /dev/null
+++ b/tests/integration/preserve-jsx/default/src/Component2.tsx
@@ -0,0 +1,115 @@
+import type { JSX, ReactNode } from 'react';
+import type { App1ButtonProps, App1CProps, App1Namespace } from './App1';
+import * as NamespaceImportApp1 from './App1';
+import { App1A, App1C, App1C as C } from './App1';
+import type { AsyncComponentLoader } from './App2';
+import { App2, app2Props } from './App2';
+
+interface DynamicComponentState {
+ Component: typeof App1A | typeof App1C;
+}
+
+const DynamicComponent: AsyncComponentLoader = () => {
+ const Component: DynamicComponentState['Component'] =
+ Math.random() > 0.5 ? App1A : App1C;
+ return import('./App1').then((mod) => {
+ const Dynamic = mod[Component === App1A ? 'App1A' : 'App1C'];
+ return ;
+ });
+};
+
+const NamespaceComponents: App1Namespace = {
+ Button: ({ label, ...rest }: App1ButtonProps) => (
+
+ ),
+};
+
+const SectionWithSpread = (props: Record) => (
+
+);
+
+const spreadChildren: ReactNode[] = [
+ First,
+ Second,
+];
+
+export default function Root(): JSX.Element {
+ return (
+ <>
+
+ <>
+
+ Loading...}>
+
+
+ >
+
+
+ {x ? (
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ }
+ fragmentContent={
+ <>
+ Nested
+ Fragment
+ >
+ }
+ />
+
+ {[
+
+
+
+
+ {'item-one'.toUpperCase()}
+ {/* JSXEmptyExpr in action */}
+ ,
+
+
+ app2
+
+
+
+
+ {/* JSXEmptyExpr in action */}
+ ,
+
+
+
+
+ {(() => (
+
+ ))()}
+ {/* JSXEmptyExpr in action */}
+ ,
+ ]}
+
{...spreadChildren}
+
bold' }}
+ />
+
+
+ >
+ );
+}
diff --git a/tests/integration/preserve-jsx/default/tsconfig.json b/tests/integration/preserve-jsx/default/tsconfig.json
new file mode 100644
index 000000000..0ddeb9b58
--- /dev/null
+++ b/tests/integration/preserve-jsx/default/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "baseUrl": ".",
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "lib": ["DOM", "ESNext"],
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "rootDir": "src",
+ "skipLibCheck": true,
+ "strict": true
+ },
+ "exclude": ["**/node_modules"],
+ "include": ["src"]
+}
diff --git a/tests/integration/preserve-jsx/forbid-bundle/package.json b/tests/integration/preserve-jsx/forbid-bundle/package.json
new file mode 100644
index 000000000..6360b151a
--- /dev/null
+++ b/tests/integration/preserve-jsx/forbid-bundle/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "preserve-jsx-forbid-bundle-test",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/tests/integration/preserve-jsx/forbid-bundle/rslib.config.ts b/tests/integration/preserve-jsx/forbid-bundle/rslib.config.ts
new file mode 100644
index 000000000..448d9c2a8
--- /dev/null
+++ b/tests/integration/preserve-jsx/forbid-bundle/rslib.config.ts
@@ -0,0 +1,19 @@
+import { pluginReact } from '@rsbuild/plugin-react';
+import { defineConfig } from '@rslib/core';
+import { generateBundleEsmConfig } from 'test-helper';
+
+export default defineConfig({
+ lib: [generateBundleEsmConfig({})],
+ source: {
+ entry: {
+ index: './src/index.tsx',
+ },
+ },
+ plugins: [
+ pluginReact({
+ swcReactOptions: {
+ runtime: 'preserve',
+ },
+ }),
+ ],
+});
diff --git a/tests/integration/preserve-jsx/forbid-bundle/src/index.tsx b/tests/integration/preserve-jsx/forbid-bundle/src/index.tsx
new file mode 100644
index 000000000..64a32fd29
--- /dev/null
+++ b/tests/integration/preserve-jsx/forbid-bundle/src/index.tsx
@@ -0,0 +1 @@
+export const answer = 42;
diff --git a/tests/integration/preserve-jsx/forbid-bundle/tsconfig.json b/tests/integration/preserve-jsx/forbid-bundle/tsconfig.json
new file mode 100644
index 000000000..d7462bc92
--- /dev/null
+++ b/tests/integration/preserve-jsx/forbid-bundle/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ESNext"
+ },
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
diff --git a/tests/integration/preserve-jsx/index.test.ts b/tests/integration/preserve-jsx/index.test.ts
new file mode 100644
index 000000000..8077f024e
--- /dev/null
+++ b/tests/integration/preserve-jsx/index.test.ts
@@ -0,0 +1,59 @@
+import { join } from 'node:path';
+import { expect, test } from '@rstest/core';
+import { buildAndGetResults, proxyConsole, queryContent } from 'test-helper';
+
+test('JSX syntax should be preserved', async () => {
+ const fixturePath = join(__dirname, 'default');
+ const { contents } = await buildAndGetResults({ fixturePath });
+ const { content: cjsContent } = queryContent(contents.cjs, 'Component1.cjs', {
+ basename: true,
+ });
+ await expect(cjsContent).toMatchSnapshot();
+
+ const { content: esmContent } = queryContent(
+ contents.esm0!,
+ 'Component1.js',
+ {
+ basename: true,
+ },
+ );
+ const { content: esmTsxContent } = queryContent(
+ contents.esm0!,
+ 'Component2.js',
+ {
+ basename: true,
+ },
+ );
+
+ // apart from the TS types, this tsx file is completely identical to a jsx file.
+ // expect them to be the same after stripping the types.
+ expect(esmContent).toBe(esmTsxContent);
+ await expect(esmContent).toMatchSnapshot();
+
+ const { content: esmJsxContent } = queryContent(
+ contents.esm1!,
+ 'Component1.jsx',
+ {
+ basename: true,
+ },
+ );
+ await expect(esmJsxContent).toMatchSnapshot();
+ await expect(esmContent.replace(/\.js"/g, '.jsx"')).toBe(esmJsxContent);
+});
+
+test('throw error when preserve JSX with bundle mode', async () => {
+ const fixturePath = join(__dirname, 'forbid-bundle');
+ const { logs, restore } = proxyConsole();
+
+ try {
+ await buildAndGetResults({ fixturePath });
+ } catch {
+ expect(logs).toMatchInlineSnapshot(`
+ [
+ "error Bundle mode does not support preserving JSX syntax. Set "bundle" to "false" or change the JSX runtime to \`automatic\` or \`classic\`. Check out https://rslib.rs/guide/solution/react#jsx-transform for more details.",
+ ]
+ `);
+ } finally {
+ restore();
+ }
+});
diff --git a/tests/package.json b/tests/package.json
index faa25e86d..eb22d8fad 100644
--- a/tests/package.json
+++ b/tests/package.json
@@ -18,7 +18,7 @@
"@playwright/test": "1.55.0",
"@rsbuild/core": "~1.5.11",
"@rsbuild/plugin-less": "^1.5.0",
- "@rsbuild/plugin-react": "^1.4.0",
+ "@rsbuild/plugin-react": "^1.4.1",
"@rsbuild/plugin-sass": "^1.4.0",
"@rsbuild/plugin-toml": "^1.1.1",
"@rsbuild/plugin-typed-css-modules": "^1.1.0",
diff --git a/tests/scripts/shared.ts b/tests/scripts/shared.ts
index e2b95576c..c6e453636 100644
--- a/tests/scripts/shared.ts
+++ b/tests/scripts/shared.ts
@@ -171,7 +171,7 @@ export async function getResults(
? /\.d.(ts|cts|mts)(\.map)?$/
: type === 'css'
? /\.css(\.map)?$/
- : /\.(js|cjs|mjs)(\.map)?$/;
+ : /\.(js|cjs|mjs|jsx)(\.map)?$/;
const content: Record = await globContentJSON(globFolder, {
absolute: true,
diff --git a/website/docs/en/guide/solution/react.mdx b/website/docs/en/guide/solution/react.mdx
index 7c8ae522f..1b337146a 100644
--- a/website/docs/en/guide/solution/react.mdx
+++ b/website/docs/en/guide/solution/react.mdx
@@ -45,14 +45,16 @@ export default defineConfig({
## JSX transform
-- **Type**: `'automatic' | 'classic'`
-- **Default**: `'automatic'`
+- **Type:** `'automatic' | 'classic' | 'preserve'`
+- **Default:** `'automatic'`
React introduced a [new JSX transform](https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) in version 17. This new transform removes the need to import `React` when using JSX.
-By default, Rsbuild uses the new JSX transform, which is `runtime: 'automatic'`. It requires at least React `16.14.0` or higher. The `peerDependencies` should be declared as `"react": ">=16.14.0"`.
+By default, Rslib uses the new JSX transform, which is `runtime: 'automatic'`. It requires at least React `16.14.0` or higher and the `peerDependencies` should be specified as `"react": ">=16.14.0"`.
-To change the JSX transform, you can pass the [swcReactOptions](https://rsbuild.rs/plugins/list/plugin-react#swcreactoptionsruntime) option to the React plugin. For example, to use the classic runtime:
+To change the JSX transform, you can set the [swcReactOptions](https://rsbuild.rs/plugins/list/plugin-react#swcreactoptionsruntime) option in `@rsbuild/plugin-react`.
+
+For example, to use the classic runtime:
```ts title="rslib.config.ts" twoslash
import { pluginReact } from '@rsbuild/plugin-react';
@@ -76,6 +78,43 @@ export default defineConfig({
});
```
+When you need to keep native JSX in the build output, you can set the runtime to `'preserve'` to leave JSX syntax unchanged without transforming it, which is useful for subsequent processing by other bundlers.
+
+::: warning
+
+When using `runtime: 'preserve'`, you must set `bundle: false` to enable [bundleless mode](/guide/basic/output-structure#bundle--bundleless) to keep files unbundled.
+
+:::
+
+To emit `.jsx` files, you can configure the JS filename template through [output.filename](/config/rsbuild/output#outputfilename) option:
+
+```ts title="rslib.config.ts" twoslash
+import { pluginReact } from '@rsbuild/plugin-react';
+import { defineConfig } from '@rslib/core';
+
+export default defineConfig({
+ lib: [
+ {
+ bundle: false,
+ format: 'esm',
+ // [!code highlight:5]
+ output: {
+ filename: {
+ js: '[name].jsx',
+ },
+ },
+ },
+ ],
+ plugins: [
+ pluginReact({
+ swcReactOptions: {
+ runtime: 'preserve',
+ },
+ }),
+ ],
+});
+```
+
## JSX import source
- **Type**: `string`
diff --git a/website/docs/zh/guide/solution/react.mdx b/website/docs/zh/guide/solution/react.mdx
index 4450daf50..9b075472c 100644
--- a/website/docs/zh/guide/solution/react.mdx
+++ b/website/docs/zh/guide/solution/react.mdx
@@ -45,14 +45,16 @@ export default defineConfig({
## JSX transform
-- **类型**: `'automatic' | 'classic'`
-- **默认值**: `'automatic'`
+- **类型:** `'automatic' | 'classic' | 'preserve'`
+- **默认值:** `'automatic'`
React 引入了一个 [新的 JSX transform](https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) 在版本 17 中。这个新的 transform 在使用 JSX 时无需导入 `React`。
-默认情况下,Rsbuild 使用新的 JSX 转换,即 `runtime: 'automatic'`。需要 React `16.14.0` 或更高版本。 `peerDependencies` 中应声明 `"react": ">=16.14.0"`。
+默认情况下,Rslib 使用新的 JSX 转换,即 `runtime: 'automatic'`。这需要 React `16.14.0` 或更高版本,且 `peerDependencies` 中应声明 `"react": ">=16.14.0"`。
-要更改 JSX transform,可以传递 [swcReactOptions](https://rsbuild.rs/zh/plugins/list/plugin-react#swcreactoptionsruntime) 给 React plugin. 比如要使用 classic runtime 时:
+要更改 JSX transform,可以在 `@rsbuild/plugin-react` 中设置 [swcReactOptions](https://rsbuild.rs/zh/plugins/list/plugin-react#swcreactoptionsruntime) 选项。
+
+比如要使用 classic runtime 时:
```ts title="rslib.config.ts" twoslash
import { pluginReact } from '@rsbuild/plugin-react';
@@ -76,6 +78,43 @@ export default defineConfig({
});
```
+当你希望在构建产物中保留原始 JSX 时,可以将 runtime 设置为 `'preserve'`。该模式可以保持 JSX 语法原样,不做任何转换,方便后续由其他打包工具处理。
+
+::: warning
+
+使用 `runtime: 'preserve'` 时,必须设置 `bundle: false` 启用 [bundleless 模式](/guide/basic/output-structure#bundle--bundleless) 使文件保持非打包状态。
+
+:::
+
+若要输出 `.jsx` 后缀的文件,可通过 [output.filename](/config/rsbuild/output#outputfilename) 配置 JS 文件名模版:
+
+```ts title="rslib.config.ts" twoslash
+import { pluginReact } from '@rsbuild/plugin-react';
+import { defineConfig } from '@rslib/core';
+
+export default defineConfig({
+ lib: [
+ {
+ bundle: false,
+ format: 'esm',
+ // [!code highlight:5]
+ output: {
+ filename: {
+ js: '[name].jsx',
+ },
+ },
+ },
+ ],
+ plugins: [
+ pluginReact({
+ swcReactOptions: {
+ runtime: 'preserve',
+ },
+ }),
+ ],
+});
+```
+
## JSX import source
- **类型**: `string`
diff --git a/website/package.json b/website/package.json
index d7ab4f4da..d0fb1d39d 100644
--- a/website/package.json
+++ b/website/package.json
@@ -11,7 +11,7 @@
"devDependencies": {
"@module-federation/rsbuild-plugin": "^0.19.1",
"@rsbuild/core": "~1.5.11",
- "@rsbuild/plugin-react": "^1.4.0",
+ "@rsbuild/plugin-react": "^1.4.1",
"@rsbuild/plugin-sass": "^1.4.0",
"@rslib/core": "workspace:*",
"@rslib/tsconfig": "workspace:*",