diff --git a/packages/core/src/check.ts b/packages/core/src/check.ts new file mode 100644 index 000000000..f748107ef --- /dev/null +++ b/packages/core/src/check.ts @@ -0,0 +1,61 @@ +import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core'; +import type { TsconfigCompilerOptions } from './types'; +import { color } from './utils/helper'; +import { logger } from './utils/logger'; + +type PluginReactOptions = { + tsconfigCompilerOptions?: TsconfigCompilerOptions; +}; + +const mapTsconfigJsxToSwcJsx = (jsx: string | undefined): string | null => { + if (jsx === undefined) { + // 'preserve' is the default value of tsconfig.compilerOptions.jsx + return null; + } + + // Calculate a corresponding SWC JSX config if tsconfig.compilerOptions.jsx is set to React related option. + // Return `null` stands for no need to check. + switch (jsx) { + case 'react-jsx': + case 'react-jsxdev': + return 'automatic'; + case 'react': + return 'classic'; + case 'preserve': + case 'react-native': + // SWC JSX does not support `preserve` as of now. + return null; + default: + return null; + } +}; + +const checkJsx = ({ + tsconfigCompilerOptions, +}: PluginReactOptions): RsbuildPlugin => ({ + name: 'rsbuild:lib-check', + setup(api) { + api.onBeforeEnvironmentCompile(({ environment }) => { + const config = api.getNormalizedConfig({ + environment: environment.name, + }); + const swc = config.tools.swc; + const tsconfigJsx = tsconfigCompilerOptions?.jsx; + if (swc && !Array.isArray(swc) && typeof swc !== 'function') { + const swcReactRuntime = swc?.jsc?.transform?.react?.runtime || null; + const mapped = mapTsconfigJsxToSwcJsx(tsconfigJsx); + if (mapped !== swcReactRuntime) { + logger.warn( + `JSX runtime is set to ${color.green(`${JSON.stringify(swcReactRuntime)}`)} in SWC, but got ${color.green(`${JSON.stringify(tsconfigJsx)}`)} in tsconfig.json. This may cause unexpected behavior, considering aligning them.`, + ); + } + } + }); + }, +}); + +export const composeCheckConfig = ( + compilerOptions: TsconfigCompilerOptions, +): RsbuildConfig => { + return { plugins: [checkJsx({ tsconfigCompilerOptions: compilerOptions })] }; +}; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b7a990eb9..92c8a8a28 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -14,6 +14,7 @@ import { } from '@rsbuild/core'; import { glob } from 'tinyglobby'; import { composeAssetConfig } from './asset/assetConfig'; +import { composeCheckConfig } from './check'; import { DEFAULT_CONFIG_EXTENSIONS, DEFAULT_CONFIG_NAME, @@ -57,6 +58,7 @@ import type { RspackResolver, Shims, Syntax, + TsconfigCompilerOptions, } from './types'; import { getDefaultExtension } from './utils/extension'; import { @@ -434,7 +436,7 @@ export function composeBannerFooterConfig( } export function composeDecoratorsConfig( - compilerOptions?: Record, + compilerOptions?: TsconfigCompilerOptions, version?: NonNullable< NonNullable['decorators'] >['version'], @@ -1327,6 +1329,8 @@ async function composeLibRsbuildConfig( rootPath, config.source?.tsconfigPath, ); + + const checkConfig = composeCheckConfig({ compilerOptions }); const cssModulesAuto = config.output?.cssModules?.auto ?? true; const { @@ -1438,6 +1442,7 @@ async function composeLibRsbuildConfig( dtsConfig, bannerFooterConfig, decoratorsConfig, + checkConfig, ); } diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 96e14f4d6..09af4d4eb 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -305,3 +305,7 @@ export type RslibConfigExport = | RslibConfig | RslibConfigSyncFn | RslibConfigAsyncFn; + +export type TsconfigCompilerOptions = Record & { + jsx?: 'react-jsx' | 'react-jsxdev' | 'react'; +}; diff --git a/packages/core/src/utils/helper.ts b/packages/core/src/utils/helper.ts index 302ddbc93..4e0bb39b7 100644 --- a/packages/core/src/utils/helper.ts +++ b/packages/core/src/utils/helper.ts @@ -167,21 +167,23 @@ export function omit( ); } -export function isPluginIncluded( +function findPlugin(pluginName: string, plugins?: RsbuildPlugins) { + return plugins?.find((plugin) => { + if (Array.isArray(plugin)) { + return isPluginIncluded(pluginName, plugin); + } + if (typeof plugin === 'object' && plugin !== null && 'name' in plugin) { + return plugin.name === pluginName; + } + return false; + }); +} + +function isPluginIncluded( pluginName: string, plugins?: RsbuildPlugins, ): boolean { - return Boolean( - plugins?.some((plugin) => { - if (Array.isArray(plugin)) { - return isPluginIncluded(pluginName, plugin); - } - if (typeof plugin === 'object' && plugin !== null && 'name' in plugin) { - return plugin.name === pluginName; - } - return false; - }), - ); + return Boolean(findPlugin(pluginName, plugins)); } export function checkMFPlugin( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d4ff6761..9cfa38ea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -575,6 +575,18 @@ importers: specifier: ^1.0.6 version: 1.0.6(@rsbuild/core@1.2.0-beta.0)(typescript@5.7.3) + tests/integration/check/jsx: + devDependencies: + '@rsbuild/plugin-react': + specifier: ^1.1.0 + version: 1.1.0(@rsbuild/core@1.2.0-beta.0) + '@types/react': + specifier: ^19.0.6 + version: 19.0.6 + react: + specifier: ^19.0.0 + version: 19.0.0 + tests/integration/cli/build: {} tests/integration/cli/build-watch: {} diff --git a/tests/integration/check/index.test.ts b/tests/integration/check/index.test.ts new file mode 100644 index 000000000..4f07dc90d --- /dev/null +++ b/tests/integration/check/index.test.ts @@ -0,0 +1,22 @@ +import { join } from 'node:path'; +import stripAnsi from 'strip-ansi'; +import { buildAndGetResults, proxyConsole } from 'test-helper'; +import { expect, test } from 'vitest'; + +test('should receive JSX mismatch warning of SWC with tsconfig', async () => { + const { logs, restore } = proxyConsole(); + const fixturePath = join(__dirname, 'jsx'); + await buildAndGetResults({ fixturePath }); + const logStrings = logs + .map((log) => stripAnsi(log)) + .filter((log) => log.startsWith('warn')) + .sort() + .join('\n'); + + expect(logStrings).toMatchInlineSnapshot(` + "warn JSX runtime is set to "automatic" in SWC, but got undefined in tsconfig.json. This may cause unexpected behavior, considering aligning them. + warn JSX runtime is set to "automatic" in SWC, but got undefined in tsconfig.json. This may cause unexpected behavior, considering aligning them." + `); + + restore(); +}); diff --git a/tests/integration/check/jsx/package.json b/tests/integration/check/jsx/package.json new file mode 100644 index 000000000..2a18b21ba --- /dev/null +++ b/tests/integration/check/jsx/package.json @@ -0,0 +1,11 @@ +{ + "name": "check-jsx-test", + "version": "1.0.0", + "private": true, + "type": "module", + "devDependencies": { + "@rsbuild/plugin-react": "^1.1.0", + "@types/react": "^19.0.6", + "react": "^19.0.0" + } +} diff --git a/tests/integration/check/jsx/rslib.config.ts b/tests/integration/check/jsx/rslib.config.ts new file mode 100644 index 000000000..6789d23a2 --- /dev/null +++ b/tests/integration/check/jsx/rslib.config.ts @@ -0,0 +1,8 @@ +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rslib/core'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig(), generateBundleCjsConfig()], + plugins: [pluginReact()], +}); diff --git a/tests/integration/check/jsx/src/index.tsx b/tests/integration/check/jsx/src/index.tsx new file mode 100644 index 000000000..ee8c17647 --- /dev/null +++ b/tests/integration/check/jsx/src/index.tsx @@ -0,0 +1,3 @@ +import React from 'react'; + +export const Foo =
foo
; diff --git a/tests/integration/check/jsx/tsconfig.json b/tests/integration/check/jsx/tsconfig.json new file mode 100644 index 000000000..df2cd79f5 --- /dev/null +++ b/tests/integration/check/jsx/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@rslib/tsconfig/base", + "compilerOptions": { + "baseUrl": "./", + "jsx": "react" + }, + "include": ["src"] +}