From f110b488792612b481dea18cdd36fb998b123170 Mon Sep 17 00:00:00 2001 From: Aleksandr Ovchinnikov Date: Thu, 12 Nov 2026 21:47:06 +0200 Subject: [PATCH 1/2] wip --- README.md | 28 ++++++++++++--- package.json | 2 +- src/diagnostics.ts | 12 +++---- src/index.ts | 9 +++-- src/typeScriptApi.ts | 35 +++++++++++-------- src/types.ts | 15 +++++++- test/execution-tests/README.md | 2 +- test/execution-tests/run-webpack.js | 21 ++++++++--- test/execution-tests/suppliedCompiler/app.ts | 1 + .../suppliedCompiler/webpack.config.mjs | 30 ++++++++++++++++ 10 files changed, 120 insertions(+), 35 deletions(-) create mode 100644 test/execution-tests/suppliedCompiler/app.ts create mode 100644 test/execution-tests/suppliedCompiler/webpack.config.mjs diff --git a/README.md b/README.md index e6f85b790..398f2a2df 100644 --- a/README.md +++ b/README.md @@ -409,11 +409,31 @@ If `true`, `ts-loader` writes nothing to the console. Errors reported through we #### compiler -| Type | Default Value | -| -------- | -------------- | -| `string` | `'typescript'` | +| Type | Default Value | +| ------------------------------------------ | -------------- | +| `string \| { API, createFileSystemLayer }` | `'typescript'` | -Use a TypeScript package other than the one installed as `typescript`, such as a fork or a prerelease installed under an alias. The package must provide TypeScript's native API at `/sync` (for example `typescript/sync`). Packages that only provide the classic compiler API, such as `ttypescript`, are not supported. +Use a TypeScript package other than the one installed as `typescript`, such as a fork or a prerelease installed under an alias. The package must provide TypeScript's native API at `/sync` and `/fs`. Packages that only provide the classic compiler API, such as `ttypescript`, are not supported. + +A package name is loaded with `require()`, which needs Node.js 20.19+ / 22.12+ because TypeScript 7 is ESM-only. On older Node.js (16.20+), import the modules in your webpack config and pass them instead. If two rules pass different `compiler` modules, give them different `instance` names. + +```js +// webpack.config.mjs +import { API } from 'typescript/sync'; +import { createFileSystemLayer } from 'typescript/fs'; + +export default { + module: { + rules: [ + { + test: /\.tsx?$/, + loader: 'ts-loader', + options: { compiler: { API, createFileSystemLayer } }, + }, + ], + }, +}; +``` #### instance diff --git a/package.json b/package.json index 9b8c0f970..eb2a0bb13 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "ts" ], "engines": { - "node": ">=22.0.0" + "node": ">=16.20.0" }, "author": "John Reilly (https://johnnyreilly.com)", "contributors": [ diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 293218017..91a35575b 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -295,8 +295,7 @@ function removeCompilationTSLoaderErrors( loaderOptions: LoaderOptions, ) { compilation.errors = compilation.errors.filter( - error => - !isTSLoaderModuleError(error as webpack.WebpackError, loaderOptions), + error => !isTSLoaderModuleError(error, loaderOptions), ); } @@ -328,11 +327,10 @@ function removeModuleTSLoaderError( } } -function isTSLoaderModuleError( - error: webpack.WebpackError, - loaderOptions: LoaderOptions, -) { - return error?.details === tsLoaderSource(loaderOptions); +function isTSLoaderModuleError(error: Error, loaderOptions: LoaderOptions) { + return ( + (error as webpack.WebpackError)?.details === tsLoaderSource(loaderOptions) + ); } function moduleHasWebpackErrors(module: webpack.Module) { diff --git a/src/index.ts b/src/index.ts index 0f7803ddc..67f548ebb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -227,7 +227,13 @@ function getTypeScriptInstance( } setTSInstanceInCache(compiler, loaderOptions.instance, instance); - log.logInfo(`Using ${loaderOptions.compiler} API with ${configFilePath}`); + log.logInfo( + `Using ${ + typeof loaderOptions.compiler === 'object' + ? 'supplied compiler' + : (loaderOptions.compiler ?? 'typescript') + } API with ${configFilePath}`, + ); return instance; } @@ -442,7 +448,6 @@ function getLoaderOptions(loaderContext: webpack.LoaderContext) { silent: false, logLevel: 'WARN' as keyof typeof LogLevel, logInfoToStdOut: false, - compiler: 'typescript', transpileOnly: hasForkTsCheckerWebpackPlugin, appendTsSuffixTo: [], appendTsxSuffixTo: [], diff --git a/src/typeScriptApi.ts b/src/typeScriptApi.ts index ff49dfad9..0c6600fb6 100644 --- a/src/typeScriptApi.ts +++ b/src/typeScriptApi.ts @@ -3,8 +3,6 @@ import * as fs from 'fs'; import * as path from 'path'; import type * as webpack from 'webpack'; import type { - API, - APIOptions, CompilerOptions, CreateSnapshotParams, Diagnostic, @@ -15,7 +13,6 @@ import type { SyntheticProjectId, } from 'typescript/sync'; import type { StringLiteralLikeNode } from 'typescript/ast'; -import { createFileSystemLayer } from 'typescript/fs'; import * as constants from './constants'; import { @@ -29,29 +26,30 @@ import type { LoaderOptions, PendingDeclarationFile, SyntheticProgram, + TypeScriptCompilerModules, TypeScriptInstance, TSInstance, ResolvedFilePathCache, } from './types'; import { isWebpack5 } from './loaderUtils'; -interface TypeScriptApiModule { - API: new (options?: APIOptions) => API; -} - export function createTypeScriptInstance( loaderOptions: LoaderOptions, configFilePath: string, useCaseSensitiveFileNames: boolean, ): TypeScriptInstance { - const typeScriptApiModule = loadTypeScriptApiModule(loaderOptions.compiler); + const compiler = + typeof loaderOptions.compiler === 'object' + ? loaderOptions.compiler + : requireCompiler(loaderOptions.compiler ?? 'typescript'); // No `fs` callbacks: the API reads disk directly, with webpack's content // layered on top per file (see layerFileContents). Case sensitivity is // passed explicitly so the API agrees with `resolvedFilePathCache`. - const api = new typeScriptApiModule.API({ useCaseSensitiveFileNames }); + const api = new compiler.API({ useCaseSensitiveFileNames }); return { api, + compiler, configFilePath: path.resolve(configFilePath), transpileConfig: undefined, orphanRootFileNames: new Map(), @@ -500,14 +498,19 @@ function computeDeclarationFilePath( return path.join(outDirLikeOption, relativeToRoot); } -function loadTypeScriptApiModule(compilerPackage: string): TypeScriptApiModule { - const specifier = `${compilerPackage}/sync`; - +/** + * Used when `compiler` is a package name (or not passed). TypeScript 7 is + * ESM-only, so this needs require(esm) (Node 20.19+ / 22.12+); on older Node, + * pass `compiler: { API, createFileSystemLayer }` instead. + */ +function requireCompiler(compilerPackage: string): TypeScriptCompilerModules { try { - return require(specifier) as TypeScriptApiModule; + const { API } = require(`${compilerPackage}/sync`); + const { createFileSystemLayer } = require(`${compilerPackage}/fs`); + return { API, createFileSystemLayer }; } catch (error) { throw new Error( - `Could not load the TypeScript API from "${specifier}": ${ + `Could not load the TypeScript API from "${compilerPackage}": ${ error instanceof Error ? error.message : String(error) }`, ); @@ -710,7 +713,9 @@ function layerFileContents( contents: string, ): Snapshot { const fileLayer: SnapshotChanges = { - fileSystem: createFileSystemLayer([[fileName, contents]]), + fileSystem: typeScriptInstance.compiler.createFileSystemLayer([ + [fileName, contents], + ]), ensurePrograms: true, }; if ( diff --git a/src/types.ts b/src/types.ts index d25a28433..043103e7f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,13 @@ import type { Colors } from './colors'; import type { API, + APIOptions, Diagnostic, ParsedCommandLine, Snapshot, SyntheticProjectId, } from 'typescript/sync'; +import type { createFileSystemLayer } from 'typescript/fs'; import type * as webpack from 'webpack'; export interface ErrorInfo { @@ -38,12 +40,22 @@ export const LogLevel = { ERROR: 3, } as const; +/** `API` from `typescript/sync` and `createFileSystemLayer` from `typescript/fs`. */ +export interface TypeScriptCompilerModules { + API: new (options?: APIOptions) => API; + createFileSystemLayer: typeof createFileSystemLayer; +} + export interface LoaderOptions { silent: boolean; logLevel: keyof typeof LogLevel; logInfoToStdOut: boolean; instance: string; - compiler: string; + /** + * A package name (loaded with `require()`, needs Node 20.19+ / 22.12+), or + * the already-imported modules. Defaults to `'typescript'`. + */ + compiler?: string | TypeScriptCompilerModules; configFile: string; transpileOnly: boolean; ignoreDiagnostics: number[]; @@ -82,6 +94,7 @@ export interface SyntheticProgram { export interface TypeScriptInstance { api: API; + compiler: TypeScriptCompilerModules; /** Real-cased, as given to the API - not a canonical key (see FilePath). */ configFilePath: string; transpileConfig?: ParsedCommandLine; diff --git a/test/execution-tests/README.md b/test/execution-tests/README.md index cc99a5e39..534191219 100644 --- a/test/execution-tests/README.md +++ b/test/execution-tests/README.md @@ -6,7 +6,7 @@ These tests are executed more widely that the comparison tests; we aim to run th ## Structure -The execution test pack can be found under `/test/execution-tests`. Like the comparison test pack, the execution test pack uses certain conventions. All tests have their own directory under `/test/execution-tests`, eg `/test/execution-tests/someFeature`, named after the feature being tested. Each test has a `webpack.config.js` file and usually a `main.js` file that loads its specs: +The execution test pack can be found under `/test/execution-tests`. Like the comparison test pack, the execution test pack uses certain conventions. All tests have their own directory under `/test/execution-tests`, eg `/test/execution-tests/someFeature`, named after the feature being tested. Each test has a `webpack.config.js` file; build-only tests may use `webpack.config.mjs` for an ESM config. Tests with specs usually also have a `main.js` file that loads them: ```js const testsContext = require.context('./', true, /\.tests\.ts(x?)$/); diff --git a/test/execution-tests/run-webpack.js b/test/execution-tests/run-webpack.js index 7f9fc9cbc..e4049d075 100644 --- a/test/execution-tests/run-webpack.js +++ b/test/execution-tests/run-webpack.js @@ -1,20 +1,33 @@ 'use strict'; -// Builds the webpack.config.js in the current directory once, like `webpack --bail`, +// Builds the webpack config in the current directory once, like `webpack --bail`, // so the execution tests don't need webpack-cli. Exits with 1 if the build has errors. // Like webpack-cli, the config can export an object, a function or a Promise, or an -// array of them. +// array of them. ESM configs can use webpack.config.mjs. +const fs = require('fs'); const path = require('path'); +const { pathToFileURL } = require('url'); const webpack = require('webpack'); -const exported = require(path.resolve('webpack.config.js')); +async function loadWebpackConfig() { + const jsConfigPath = path.resolve('webpack.config.js'); + if (fs.existsSync(jsConfigPath)) { + return require(jsConfigPath); + } + + const { default: config } = await import( + pathToFileURL(path.resolve('webpack.config.mjs')).href + ); + return config; +} + const env = { WEBPACK_BUNDLE: true, WEBPACK_BUILD: true }; // What webpack-cli passes a config function for `webpack --bail` const argv = { env, bail: true }; // Starting from Promise.resolve() means a config function that throws is caught below Promise.resolve() - .then(() => resolveConfig(exported)) + .then(() => resolveConfig(loadWebpackConfig())) .then((config) => (Array.isArray(config) ? Promise.all(config.map(resolveConfig)) : config)) .then((config) => { (Array.isArray(config) ? config : [config]).forEach((c) => { c.bail = true; }); diff --git a/test/execution-tests/suppliedCompiler/app.ts b/test/execution-tests/suppliedCompiler/app.ts new file mode 100644 index 000000000..116f22d41 --- /dev/null +++ b/test/execution-tests/suppliedCompiler/app.ts @@ -0,0 +1 @@ +export const compiledWithSuppliedCompiler = true; diff --git a/test/execution-tests/suppliedCompiler/webpack.config.mjs b/test/execution-tests/suppliedCompiler/webpack.config.mjs new file mode 100644 index 000000000..463f3282c --- /dev/null +++ b/test/execution-tests/suppliedCompiler/webpack.config.mjs @@ -0,0 +1,30 @@ +import { fileURLToPath } from "node:url"; +import { API } from "typescript/sync"; +import { createFileSystemLayer } from "typescript/fs"; + +export default { + mode: "development", + entry: "./app.ts", + output: { + filename: "bundle.js", + }, + resolve: { + extensions: [".ts", ".js"], + }, + module: { + rules: [ + { + test: /\.ts$/, + loader: "ts-loader", + options: { + compiler: { API, createFileSystemLayer }, + }, + }, + ], + }, + resolveLoader: { + alias: { + "ts-loader": fileURLToPath(new URL("../../../index.js", import.meta.url)), + }, + }, +}; From 34a5923f37a926252850c065c08410cb6b6445cb Mon Sep 17 00:00:00 2001 From: johnnyreilly Date: Fri, 13 Nov 2026 15:37:18 +0000 Subject: [PATCH 2/2] ci: run the suppliedCompiler execution test on Node 16 Install and build on Node 26 (Jasmine 7 and the dev tooling need it), then switch to Node 16 for the build-only suppliedCompiler test, which never loads Jasmine. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/execution-tests.yml | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/execution-tests.yml b/.github/workflows/execution-tests.yml index 088a3c876..f36cfbcb9 100644 --- a/.github/workflows/execution-tests.yml +++ b/.github/workflows/execution-tests.yml @@ -33,6 +33,40 @@ jobs: - name: test run: npm run execution-tests + # Node 16 can only load TypeScript 7 through a `compiler` passed from an ESM + # webpack config. Install and build on Node 26 (Jasmine 7 and the dev tooling + # need it), then switch to Node 16 for suppliedCompiler: a build-only test, + # so it never loads Jasmine. + execution_test_node16: + name: ubuntu node 16 + strategy: + matrix: + ts: [7.1.0-beta, next] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: install node + uses: actions/setup-node@v7 + with: + node-version: 26 + cache: npm + + - uses: ./.github/actions/build-ts-loader + + - name: install typescript + run: npm install -D typescript@${{ matrix.ts }} + + - name: switch to node 16 + uses: actions/setup-node@v7 + with: + node-version: 16 + + - name: test + run: | + node --version + node test/execution-tests/run-tests.js --single-test suppliedCompiler + execution_test_macos: name: macOS strategy: