diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ab5820..974110be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,14 @@ ### pack -* Fixed `enact pack` to skip TypeScript type checking, with a warning, when the installed `typescript` package does not expose `createProgram` (TypeScript 7). Apps on TypeScript 5.x and 6.x still run `fork-ts-checker-webpack-plugin`. +* Fixed `enact pack` and `enact serve` to type check TypeScript 7 apps by running the `tsc` binary, since TypeScript 7 does not expose the compiler API required by `fork-ts-checker-webpack-plugin`. Apps on TypeScript 5.x and 6.x still use `fork-ts-checker-webpack-plugin`. * Fixed `enact pack` to exit with code 1 when the build fails, including when `chalk` has not loaded yet. * Updated the optional `typescript` peer dependency to `^5.4.0 || ^6.0.0 || ^7.0.2`. +### serve + +* Fixed `enact serve` printing compilation errors twice. + ## 7.3.4 (August 21, 2026) ### bootstrap diff --git a/commands/serve.js b/commands/serve.js index 84619dd7..e9747a86 100755 --- a/commands/serve.js +++ b/commands/serve.js @@ -17,6 +17,7 @@ const path = require('path'); const minimist = require('minimist'); const clearConsole = require('react-dev-utils/clearConsole'); const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware'); +const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath'); const openBrowser = require('react-dev-utils/openBrowser'); const redirectServedPathMiddleware = require('react-dev-utils/redirectServedPathMiddleware'); @@ -26,6 +27,7 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin' const webpack = require('webpack'); const WebpackDevServer = require('webpack-dev-server'); const {optionParser: app} = require('@enact/dev-utils'); +const TypeScriptCheckPlugin = require('../config/TypeScriptCheckPlugin'); let chalk; @@ -210,7 +212,9 @@ function devServerConfig (host, port, protocol, publicPath, proxy, allowedHost) // we specified in the webpack config. When homepage is '.', default to serving // from the root. // remove last slash so user can land on `/test` instead of `/test/` - publicPath: publicPath.slice(0, -1) + publicPath: publicPath.slice(0, -1), + // Errors and warnings are already printed by react-dev-utils' createCompiler + stats: 'none' }, historyApiFallback: { // ensure JSON file requests correctly 404 error when not found. @@ -284,6 +288,16 @@ function serve (config, host, port, open) { }); callback(); }); + // createCompiler only prints the first error of a compilation. With TypeScript 7 the type errors are + // part of the compilation, so print them as well when another error (e.g. ESLint) was shown instead. + compiler.hooks.done.tap('EnactCLI', stats => { + const typeScriptError = stats.compilation.errors.find(TypeScriptCheckPlugin.isTypeScriptError); + if (!typeScriptError) return; + + const [shown] = formatWebpackMessages(stats.toJson({all: false, warnings: true, errors: true})).errors; + const [typeScriptMessage] = formatWebpackMessages({errors: [typeScriptError.message], warnings: []}).errors; + if (shown !== typeScriptMessage) console.log(typeScriptMessage + '\n'); + }); // Load proxy config const proxySetting = app.proxy; const proxyConfig = prepareProxy(proxySetting, './public', publicPath); diff --git a/config/TypeScriptCheckPlugin.js b/config/TypeScriptCheckPlugin.js new file mode 100644 index 00000000..15be9f9c --- /dev/null +++ b/config/TypeScriptCheckPlugin.js @@ -0,0 +1,147 @@ +'use strict'; +const {spawn} = require('child_process'); +const path = require('path'); +const {WebpackError} = require('webpack'); + +const pluginName = 'EnactTypeScriptCheckPlugin'; + +// Errors reported by this plugin, so `enact serve` can tell them apart from other compilation errors +const reportedErrors = new WeakSet(); + +// Diagnostics are not reported for test and setup files, matching the fork-ts-checker issue filters. +const excludeFile = /(^|[\\/])__tests__[\\/]|\.(spec|test)\.[^\\/]+$|(^|[\\/])src[\\/]setup(Proxy|Tests)\.[^\\/]+$/; + +// Parses `tsc --pretty false` output. Each diagnostic starts with `file(line,col): error TSxxxx: message` +// (or `error TSxxxx: message` for global ones) and any following indented lines elaborate on it. +function parseDiagnostics (output) { + const diagnostics = []; + output.split(/\r?\n/).forEach(line => { + const located = /^(.+)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/.exec(line); + const unlocated = !located && /^(error|warning) (TS\d+): (.*)$/.exec(line); + if (located) { + const [, file, row, column, category, code, text] = located; + diagnostics.push({file, row, column, category, code, text}); + } else if (unlocated) { + const [, category, code, text] = unlocated; + diagnostics.push({category, code, text}); + } else if (line.trim() && diagnostics.length) { + diagnostics[diagnostics.length - 1].text += '\n' + line; + } + }); + return diagnostics; +} + +function formatDiagnostic ({file, row, column, code, text}) { + return (file ? `${file}(${row},${column})\n` : '') + `${code}: ${text}`; +} + +// Type checks the app with the `tsc` binary for TypeScript versions that do not expose the compiler API +// required by fork-ts-checker (TypeScript 7+). `tsc` runs alongside each webpack compilation and its +// diagnostics are added to the compilation once it finishes. +class TypeScriptCheckPlugin { + constructor ({tscPath, context, compileOnError = false}) { + this.tscPath = tscPath; + this.context = context; + this.compileOnError = compileOnError; + this.child = null; + this.check = null; + } + + runTsc () { + this.cancel(); + const args = [ + this.tscPath, + '--project', + path.join(this.context, 'tsconfig.json'), + '--noEmit', + '--skipLibCheck', + '--incremental', + '--tsBuildInfoFile', + path.join(this.context, 'node_modules', '.cache', 'tsconfig.tsbuildinfo'), + '--pretty', + 'false' + ]; + + return new Promise(resolve => { + let output = ''; + const child = spawn(process.execPath, args, {cwd: this.context, windowsHide: true}); + this.child = child; + child.stdout.on('data', data => (output += data)); + child.stderr.on('data', data => (output += data)); + child.on('error', err => { + resolve({failure: err.message}); + }); + child.on('close', (exitCode, signal) => { + if (this.child === child) this.child = null; + if (signal) { + resolve({cancelled: true}); + } else if (exitCode === 0) { + resolve({diagnostics: []}); + } else { + const diagnostics = parseDiagnostics(output); + // A non-zero exit without any parsable diagnostics means tsc itself failed + resolve( + diagnostics.length ? + {diagnostics} : + {failure: output.trim() || `tsc exited with code ${exitCode}`} + ); + } + }); + }); + } + + cancel () { + if (this.child) { + this.child.kill(); + this.child = null; + } + } + + apply (compiler) { + const start = () => { + this.check = this.runTsc(); + }; + compiler.hooks.run.tap(pluginName, start); + compiler.hooks.watchRun.tap(pluginName, start); + compiler.hooks.watchClose.tap(pluginName, () => this.cancel()); + compiler.hooks.shutdown.tap(pluginName, () => this.cancel()); + + compiler.hooks.afterCompile.tapPromise(pluginName, compilation => { + // Child compilers inherit this hook; only report on the main compilation + if (compilation.compiler !== compiler || !this.check) return Promise.resolve(); + + return this.check.then(({cancelled, failure, diagnostics}) => { + if (cancelled) return; + if (failure) { + compilation.warnings.push(new WebpackError(`TypeScript type checking could not run:\n${failure}`)); + return; + } + const errors = []; + const warnings = []; + diagnostics + .filter(({file}) => !file || !excludeFile.test(file)) + .forEach(diagnostic => { + const isError = diagnostic.category === 'error' && !this.compileOnError; + (isError ? errors : warnings).push(formatDiagnostic(diagnostic)); + }); + + // All diagnostics are reported as a single issue, since react-dev-utils only + // prints the first error of a compilation in `enact serve` + const report = (list, issues) => { + if (!list.length) return; + const issue = new WebpackError(list.join('\n\n')); + issue.hideStack = true; + issues.push(issue); + return issue; + }; + const error = report(errors, compilation.errors); + if (error) reportedErrors.add(error); + report(warnings, compilation.warnings); + }); + }); + } +} + +TypeScriptCheckPlugin.isTypeScriptError = error => reportedErrors.has(error); + +module.exports = TypeScriptCheckPlugin; diff --git a/config/webpack.config.js b/config/webpack.config.js index e651ff71..0886313b 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -37,11 +37,12 @@ const { WebOSMetaPlugin } = require('@enact/dev-utils'); const createEnvironmentHash = require('./createEnvironmentHash'); +const TypeScriptCheckPlugin = require('./TypeScriptCheckPlugin'); // fork-ts-checker needs the classic TypeScript compiler API. TypeScript 7's -// package entry only exports a version, so resolution via `main` fails and the -// checker cannot run. Return null to skip it instead of failing the build. -function resolveLegacyTypeScript (context) { +// package entry only exports a version, so for it the `tsc` binary is resolved +// instead and run by TypeScriptCheckPlugin. Return null when neither is available. +function resolveTypeScript (context) { let typescriptPath; try { typescriptPath = require.resolve('typescript', {paths: [context]}); @@ -51,7 +52,16 @@ function resolveLegacyTypeScript (context) { try { const typescript = require(typescriptPath); - if (typeof typescript.createProgram === 'function') return typescriptPath; + if (typeof typescript.createProgram === 'function') return {typescriptPath}; + } catch (e) { + // Fall back to the tsc binary below + } + + try { + const packagePath = require.resolve('typescript/package.json', {paths: [context]}); + const {bin} = require(packagePath); + const tscPath = typeof bin === 'string' ? bin : bin && bin.tsc; + if (tscPath) return {tscPath: path.join(path.dirname(packagePath), tscPath)}; } catch (e) { return null; } @@ -80,10 +90,10 @@ module.exports = function ( // Check if TypeScript is setup const useTypeScript = fs.existsSync('tsconfig.json'); - const typeScriptPath = useTypeScript ? resolveLegacyTypeScript(app.context) : null; - if (useTypeScript && !typeScriptPath) { + const {typescriptPath, tscPath} = (useTypeScript && resolveTypeScript(app.context)) || {}; + if (useTypeScript && !typescriptPath && !tscPath) { console.warn( - 'Skipping TypeScript type checking during the build. The installed "typescript" package does not provide the compiler API required by fork-ts-checker.' + 'Skipping TypeScript type checking during the build. Unable to resolve the "typescript" package or its tsc binary.' ); } @@ -652,11 +662,11 @@ module.exports = function ( // and parses any to copy over any webOS meta assets at build time. new WebOSMetaPlugin({htmlPlugin: HtmlWebpackPlugin}), // TypeScript type checking - typeScriptPath && + typescriptPath && new ForkTsCheckerWebpackPlugin({ async: !isEnvProduction, typescript: { - typescriptPath: typeScriptPath, + typescriptPath, configOverwrite: { compilerOptions: { sourceMap: shouldUseSourceMap, @@ -692,6 +702,13 @@ module.exports = function ( infrastructure: 'silent' } }), + // TypeScript 7+ type checking via the tsc binary + tscPath && + new TypeScriptCheckPlugin({ + tscPath, + context: app.context, + compileOnError: process.env.TSC_COMPILE_ON_ERROR === 'true' + }), !noLinting && new ESLintPlugin({ // Plugin options