From 516ea6ec86e7affe32785d8dce8b4e09db63971b Mon Sep 17 00:00:00 2001 From: chiddekel <11840160+chiddekel@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:30:36 +0200 Subject: [PATCH] feat: support tsx for TypeScript config loading --- .gitignore | 1 + packages/webpack-cli/src/webpack-cli.ts | 53 ++++++++++++++++--- .../config-format/typescript-tsx/main.ts | 1 + .../node_modules/@babel/register/index.js | 1 + .../node_modules/@swc/register/index.js | 1 + .../node_modules/sucrase/register/ts.js | 1 + .../node_modules/ts-node/register.js | 1 + .../typescript-tsx/node_modules/tsx/cjs.js | 9 ++++ .../config-format/typescript-tsx/package.json | 6 +++ .../typescript-tsx/typescript.test.mjs | 24 +++++++++ .../typescript-tsx/webpack.config.ts | 13 +++++ 11 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 test/build/config-format/typescript-tsx/main.ts create mode 100644 test/build/config-format/typescript-tsx/node_modules/@babel/register/index.js create mode 100644 test/build/config-format/typescript-tsx/node_modules/@swc/register/index.js create mode 100644 test/build/config-format/typescript-tsx/node_modules/sucrase/register/ts.js create mode 100644 test/build/config-format/typescript-tsx/node_modules/ts-node/register.js create mode 100644 test/build/config-format/typescript-tsx/node_modules/tsx/cjs.js create mode 100644 test/build/config-format/typescript-tsx/package.json create mode 100644 test/build/config-format/typescript-tsx/typescript.test.mjs create mode 100644 test/build/config-format/typescript-tsx/webpack.config.ts diff --git a/.gitignore b/.gitignore index db588310de6..b9c48d89c48 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ node_modules test/**/node_modules !test/external-command/node_modules +!test/build/config-format/typescript-tsx/node_modules # Lock files yarn.lock diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 9ecdbea15ac..03d273f6ffc 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -14,6 +14,7 @@ import { program, } from "commander"; import { type Config as EnvinfoConfig, type Options as EnvinfoOptions } from "envinfo"; +import { type Extensions as InterpretExtensions } from "interpret"; import { type prepare } from "rechoir"; import { type Argument as WebpackArgument, @@ -60,6 +61,8 @@ const DATA_FORMAT_LOADERS: Record; @@ -580,6 +583,45 @@ class WebpackCLI { return distance(first, second); } + static #getInterpretExtensionsWithTsx(extensions: InterpretExtensions): InterpretExtensions { + const enhancedExtensions = { ...extensions }; + + // `interpret` owns the default loader table; add tsx locally until it ships + // there so projects with only `tsx` installed can still load TS configs. + for (const extension of TSX_COMMONJS_EXTENSIONS) { + if (!Object.prototype.hasOwnProperty.call(enhancedExtensions, extension)) { + enhancedExtensions[extension] = TSX_COMMONJS_LOADER; + continue; + } + + const extensionLoaders = enhancedExtensions[extension]; + + if (extensionLoaders === null) { + enhancedExtensions[extension] = TSX_COMMONJS_LOADER; + continue; + } + + const loaders = Array.isArray(extensionLoaders) ? extensionLoaders : [extensionLoaders]; + const hasTsxLoader = loaders.some((loader) => { + if (typeof loader === "string") { + return loader === TSX_COMMONJS_LOADER; + } + + return ( + loader !== null && + typeof loader === "object" && + loader.module === TSX_COMMONJS_LOADER + ); + }); + + if (!hasTsxLoader) { + enhancedExtensions[extension] = [...loaders, TSX_COMMONJS_LOADER]; + } + } + + return enhancedExtensions; + } + getLogger(): Logger { // Status icons brand every webpack-cli message (`✔`/`✖`/`⚠`/`ℹ`). They sit // between the `[webpack-cli]` prefix and the unchanged message text, so @@ -2889,18 +2931,17 @@ class WebpackCLI { if (loadingError) { const configFilePath = isFileURL ? fileURLToPath(configPath) : path.resolve(configPath); const { jsVariants, extensions } = await import("interpret"); + const interpretExtensions = WebpackCLI.#getInterpretExtensionsWithTsx(extensions); - let interpreted = Object.keys(jsVariants).find((variant) => variant === ext); - - if (!interpreted && ext.endsWith(".cts")) { - interpreted = jsVariants[".ts"] as string; - } + const interpreted = + Object.prototype.hasOwnProperty.call(jsVariants, ext) || + Object.prototype.hasOwnProperty.call(interpretExtensions, ext); if (interpreted && !disableInterpret) { const rechoir: Rechoir = (await import("rechoir")).default; try { - rechoir.prepare(extensions, configPath); + rechoir.prepare(interpretExtensions, configPath); } catch (error) { if ((error as RechoirError)?.failures) { this.logger.error(`Unable load '${configPath}'`); diff --git a/test/build/config-format/typescript-tsx/main.ts b/test/build/config-format/typescript-tsx/main.ts new file mode 100644 index 00000000000..dc6a7ea6788 --- /dev/null +++ b/test/build/config-format/typescript-tsx/main.ts @@ -0,0 +1 @@ +console.log("Rimuru Tempest"); diff --git a/test/build/config-format/typescript-tsx/node_modules/@babel/register/index.js b/test/build/config-format/typescript-tsx/node_modules/@babel/register/index.js new file mode 100644 index 00000000000..14c486c54af --- /dev/null +++ b/test/build/config-format/typescript-tsx/node_modules/@babel/register/index.js @@ -0,0 +1 @@ +throw new Error("fixture skips @babel/register"); diff --git a/test/build/config-format/typescript-tsx/node_modules/@swc/register/index.js b/test/build/config-format/typescript-tsx/node_modules/@swc/register/index.js new file mode 100644 index 00000000000..a240119a316 --- /dev/null +++ b/test/build/config-format/typescript-tsx/node_modules/@swc/register/index.js @@ -0,0 +1 @@ +throw new Error("fixture skips @swc/register"); diff --git a/test/build/config-format/typescript-tsx/node_modules/sucrase/register/ts.js b/test/build/config-format/typescript-tsx/node_modules/sucrase/register/ts.js new file mode 100644 index 00000000000..57ffd2f04a0 --- /dev/null +++ b/test/build/config-format/typescript-tsx/node_modules/sucrase/register/ts.js @@ -0,0 +1 @@ +throw new Error("fixture skips sucrase/register/ts"); diff --git a/test/build/config-format/typescript-tsx/node_modules/ts-node/register.js b/test/build/config-format/typescript-tsx/node_modules/ts-node/register.js new file mode 100644 index 00000000000..6b74c7af6a3 --- /dev/null +++ b/test/build/config-format/typescript-tsx/node_modules/ts-node/register.js @@ -0,0 +1 @@ +throw new Error("fixture skips ts-node/register"); diff --git a/test/build/config-format/typescript-tsx/node_modules/tsx/cjs.js b/test/build/config-format/typescript-tsx/node_modules/tsx/cjs.js new file mode 100644 index 00000000000..af8cf01eb10 --- /dev/null +++ b/test/build/config-format/typescript-tsx/node_modules/tsx/cjs.js @@ -0,0 +1,9 @@ +const fs = require("node:fs"); +const Module = require("node:module"); + +const transform = (source) => + source.replace(/\b(const|let|var) ([A-Za-z_$][\w$]*): string\b/g, "$1 $2"); + +Module._extensions[".ts"] = (mod, filename) => { + mod._compile(transform(fs.readFileSync(filename, "utf8")), filename); +}; diff --git a/test/build/config-format/typescript-tsx/package.json b/test/build/config-format/typescript-tsx/package.json new file mode 100644 index 00000000000..449278eb4a4 --- /dev/null +++ b/test/build/config-format/typescript-tsx/package.json @@ -0,0 +1,6 @@ +{ + "type": "commonjs", + "engines": { + "node": ">=18.12.0" + } +} diff --git a/test/build/config-format/typescript-tsx/typescript.test.mjs b/test/build/config-format/typescript-tsx/typescript.test.mjs new file mode 100644 index 00000000000..b829132d4a1 --- /dev/null +++ b/test/build/config-format/typescript-tsx/typescript.test.mjs @@ -0,0 +1,24 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { run } from "../../../utils/test-utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe("typescript configuration with tsx", () => { + it("should support `webpack --mode=production` with only `tsx` installed", async () => { + const [major] = process.versions.node.split(".").map(Number); + const { exitCode, stderr, stdout } = await run(__dirname, ["--mode=production"], { + nodeOptions: [ + // Disable TypeScript strip types so this test exercises the interpret fallback. + ...(major >= 22 ? ["--no-experimental-strip-types"] : []), + ], + }); + + expect(stderr).toBeFalsy(); + expect(stdout).toBeTruthy(); + expect(exitCode).toBe(0); + expect(existsSync(resolve(__dirname, "dist/foo.bundle.js"))).toBeTruthy(); + }); +}); diff --git a/test/build/config-format/typescript-tsx/webpack.config.ts b/test/build/config-format/typescript-tsx/webpack.config.ts new file mode 100644 index 00000000000..2473ef2f218 --- /dev/null +++ b/test/build/config-format/typescript-tsx/webpack.config.ts @@ -0,0 +1,13 @@ +const path = require("node:path"); + +const filename: string = "foo.bundle.js"; + +const config = { + entry: "./main.ts", + output: { + path: path.resolve("dist"), + filename, + }, +}; + +module.exports = config;