From 8a6922217181d00303673aca3594fb80c054037f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 9 Jul 2026 16:12:39 +0200 Subject: [PATCH 1/3] Add experimental TypeScript CLI backend --- .../01-next-config-js/useTypeScriptCli.mdx | 62 +++++ .../05-config/02-typescript.mdx | 56 +++- .../01-next-config-js/useTypeScriptCli.mdx | 8 + packages/next/errors.json | 5 +- packages/next/src/build/load-jsconfig.ts | 65 +++-- .../build/next-config-ts/transpile-config.ts | 91 +------ packages/next/src/build/type-check.ts | 26 +- packages/next/src/cli/next-test.ts | 1 + packages/next/src/cli/next-typegen.ts | 1 + .../src/lib/has-necessary-dependencies.ts | 2 + packages/next/src/lib/install-dependencies.ts | 2 +- .../next/src/lib/typescript/loadTsConfig.ts | 190 +++++++++++++ .../lib/typescript/missingDependencyError.ts | 2 +- .../next/src/lib/typescript/runTypeCheck.ts | 4 +- .../src/lib/typescript/runTypeCheckCli.ts | 58 ++++ .../lib/typescript/runTypeScriptCli.test.ts | 140 ++++++++++ .../src/lib/typescript/runTypeScriptCli.ts | 250 ++++++++++++++++++ .../next/src/lib/verify-typescript-setup.ts | 148 +++++++---- packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 7 + .../lib/router-utils/setup-dev-bundler.ts | 1 + packages/next/src/telemetry/events/build.ts | 1 + test/production/app-dir/typescript-cli/.npmrc | 2 + .../app-dir/typescript-cli/app/layout.tsx | 9 + .../app-dir/typescript-cli/app/page.tsx | 5 + .../typescript-cli/config/tsconfig.paths.json | 18 ++ .../app-dir/typescript-cli/next.config.js | 13 + .../app-dir/typescript-cli/src/message.ts | 1 + .../typescript-cli/tsconfig.build.json | 20 ++ .../typescript-cli/typescript-cli.test.ts | 170 ++++++++++++ .../app-dir/typescript-cli/unused.ts | 1 + .../ci-missing-typescript-deps/index.test.ts | 40 +++ .../fixture/config-dir/base.json | 5 + .../fixture/config-dir/project/tsconfig.json | 3 + .../fixture/default-project/tsconfig.json | 3 + .../default-tsconfig-package/nested/main.json | 7 + .../default-tsconfig-package/package.json | 5 + .../default-tsconfig-package/tsconfig.json | 7 + .../fixture/project/tsconfig.json | 3 + .../tsconfig-package/configs/base.json | 7 + .../fixture/tsconfig-package/index.js | 1 + .../fixture/tsconfig-package/package.json | 6 + .../fixture/typescript-esm-package/bin/tsc | 2 + .../fixture/typescript-esm-package/lib/tsc.js | 1 + .../typescript-esm-package/package.json | 8 + .../index.test.ts | 88 ++++++ 46 files changed, 1379 insertions(+), 167 deletions(-) create mode 100644 docs/01-app/03-api-reference/05-config/01-next-config-js/useTypeScriptCli.mdx create mode 100644 docs/02-pages/04-api-reference/04-config/01-next-config-js/useTypeScriptCli.mdx create mode 100644 packages/next/src/lib/typescript/loadTsConfig.ts create mode 100644 packages/next/src/lib/typescript/runTypeCheckCli.ts create mode 100644 packages/next/src/lib/typescript/runTypeScriptCli.test.ts create mode 100644 packages/next/src/lib/typescript/runTypeScriptCli.ts create mode 100644 test/production/app-dir/typescript-cli/.npmrc create mode 100644 test/production/app-dir/typescript-cli/app/layout.tsx create mode 100644 test/production/app-dir/typescript-cli/app/page.tsx create mode 100644 test/production/app-dir/typescript-cli/config/tsconfig.paths.json create mode 100644 test/production/app-dir/typescript-cli/next.config.js create mode 100644 test/production/app-dir/typescript-cli/src/message.ts create mode 100644 test/production/app-dir/typescript-cli/tsconfig.build.json create mode 100644 test/production/app-dir/typescript-cli/typescript-cli.test.ts create mode 100644 test/production/app-dir/typescript-cli/unused.ts create mode 100644 test/unit/typescript-cli-config-origin/fixture/config-dir/base.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/config-dir/project/tsconfig.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/default-project/tsconfig.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/nested/main.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/package.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/tsconfig.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/project/tsconfig.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/tsconfig-package/configs/base.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/tsconfig-package/index.js create mode 100644 test/unit/typescript-cli-config-origin/fixture/tsconfig-package/package.json create mode 100644 test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/bin/tsc create mode 100644 test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/lib/tsc.js create mode 100644 test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/package.json create mode 100644 test/unit/typescript-cli-config-origin/index.test.ts diff --git a/docs/01-app/03-api-reference/05-config/01-next-config-js/useTypeScriptCli.mdx b/docs/01-app/03-api-reference/05-config/01-next-config-js/useTypeScriptCli.mdx new file mode 100644 index 000000000000..94d86eb9154c --- /dev/null +++ b/docs/01-app/03-api-reference/05-config/01-next-config-js/useTypeScriptCli.mdx @@ -0,0 +1,62 @@ +--- +title: useTypeScriptCli +description: Run the project-local TypeScript CLI for type checking during production builds. +version: experimental +--- + +The `experimental.useTypeScriptCli` option makes `next build` run the project-local `tsc` command instead of loading the TypeScript JavaScript compiler API. You can use this option with TypeScript 6, and it enables [TypeScript 7](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/) support while its JavaScript API is unavailable. + +Install TypeScript 7 in your project: + +```bash package="pnpm" +pnpm add -D typescript@^7 +``` + +```bash package="npm" +npm install -D typescript@^7 +``` + +```bash package="yarn" +yarn add -D typescript@^7 +``` + +```bash package="bun" +bun add -D typescript@^7 +``` + +Then, explicitly enable the CLI checker: + +```ts filename="next.config.ts" switcher +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + useTypeScriptCli: true, + }, +} + +export default nextConfig +``` + +```js filename="next.config.js" switcher +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + useTypeScriptCli: true, + }, +} + +module.exports = nextConfig +``` + +Next.js does not select the CLI checker automatically. If TypeScript 7 is installed without this option, `next build` exits with instructions to enable it or install a TypeScript version supported by the default checker. + +## Behavior + +- Next.js continues to generate `next-env.d.ts` and route types and to apply its recommended `tsconfig` settings before running the checker. +- TypeScript diagnostics are printed directly from `tsc`. Next.js-specific code frames and error rewriting are not applied. +- The complete project selected by the configured `tsconfig` file is checked, including test files and `.next/dev/types` when included. The [`--debug-build-paths`](/docs/app/api-reference/cli/next#next-build-options) option does not limit this set and produces a warning when combined with the CLI checker. +- [`typescript.tsconfigPath`](/docs/app/api-reference/config/typescript#custom-tsconfig-path) selects the project passed to `tsc`. +- [`typescript.ignoreBuildErrors`](/docs/app/api-reference/config/typescript#disabling-typescript-errors-in-production) skips the type-checking step, including the CLI checker. + +Learn more about [using TypeScript 7 with Next.js](/docs/app/api-reference/config/typescript#using-typescript-7). diff --git a/docs/01-app/03-api-reference/05-config/02-typescript.mdx b/docs/01-app/03-api-reference/05-config/02-typescript.mdx index 79f89686508d..d5a39d9e31f4 100644 --- a/docs/01-app/03-api-reference/05-config/02-typescript.mdx +++ b/docs/01-app/03-api-reference/05-config/02-typescript.mdx @@ -11,6 +11,60 @@ To add TypeScript to an existing project, rename a file to `.ts` / `.tsx`. Run ` > **Good to know**: If you already have a `jsconfig.json` file, copy the `paths` compiler option from the old `jsconfig.json` into the new `tsconfig.json` file, and delete the old `jsconfig.json` file. +## Using TypeScript 7 + +[TypeScript 7](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/) does not currently provide the JavaScript compiler API that Next.js uses for type checking by default. To use TypeScript 7 during `next build`, install it in your project: + +```bash package="pnpm" +pnpm add -D typescript@^7 +``` + +```bash package="npm" +npm install -D typescript@^7 +``` + +```bash package="yarn" +yarn add -D typescript@^7 +``` + +```bash package="bun" +bun add -D typescript@^7 +``` + +Then, opt in to running the project-local `tsc` CLI instead of the JavaScript API with [`experimental.useTypeScriptCli`](/docs/app/api-reference/config/next-config-js/useTypeScriptCli): + +```ts filename="next.config.ts" switcher +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + useTypeScriptCli: true, + }, +} + +export default nextConfig +``` + +```js filename="next.config.js" switcher +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + useTypeScriptCli: true, + }, +} + +module.exports = nextConfig +``` + +Next.js does not enable this option automatically. If you install TypeScript 7 without enabling `experimental.useTypeScriptCli`, `next build` exits with instructions to enable the option or install a TypeScript version supported by the default checker. + +> **Good to know**: +> +> - CLI type checking prints the native `tsc` diagnostics. It does not apply Next.js-specific code frames or rewrite errors for routes, pages, layouts, or route handlers. +> - The CLI checks the complete project selected by your `tsconfig` file. This includes test files and `.next/dev/types` when they are included by that configuration. [`next build --debug-build-paths`](/docs/app/api-reference/cli/next#next-build-options) does not narrow the files that are type checked and produces a warning when used with this option. +> - [`typescript.tsconfigPath`](#custom-tsconfig-path) continues to select the configuration passed to `tsc`. [`typescript.ignoreBuildErrors`](#disabling-typescript-errors-in-production) skips the type-checking step, including the CLI checker. +> - `experimental.useTypeScriptCli` is experimental and its behavior may change. + ## IDE Plugin @@ -31,7 +85,7 @@ You can enable the plugin in VS Code by: height="637" /> -Now, when editing files, the custom plugin will be enabled. When running `next build`, the custom type checker will be used. +Now, when editing files, the custom plugin will be enabled. By default, the custom type checker is used when running `next build`. When [`experimental.useTypeScriptCli`](#using-typescript-7) is enabled, the project-local `tsc` CLI is used instead. The TypeScript plugin can help with: diff --git a/docs/02-pages/04-api-reference/04-config/01-next-config-js/useTypeScriptCli.mdx b/docs/02-pages/04-api-reference/04-config/01-next-config-js/useTypeScriptCli.mdx new file mode 100644 index 000000000000..5cfc374df1fd --- /dev/null +++ b/docs/02-pages/04-api-reference/04-config/01-next-config-js/useTypeScriptCli.mdx @@ -0,0 +1,8 @@ +--- +title: useTypeScriptCli +description: Run the project-local TypeScript CLI for type checking during production builds. +source: app/api-reference/config/next-config-js/useTypeScriptCli +version: experimental +--- + +{/* DO NOT EDIT. The content of this doc is generated from the source above. To edit the content of this page, navigate to the source page in your editor. You can use the `Content` component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */} diff --git a/packages/next/errors.json b/packages/next/errors.json index f1e8298f4b45..f6f62550e59d 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1414,5 +1414,8 @@ "1413": "Invalid \\`cacheLife()\\` option \"stale\" provided, expected a finite number of seconds or Infinity, received %s.", "1414": "The %s option must be a number of seconds.", "1415": "Unknown cacheLife option %s", - "1416": "Invalid \"%s\" provided, expected a finite number of seconds or Infinity, received %s" + "1416": "Invalid \"%s\" provided, expected a finite number of seconds or Infinity, received %s", + "1417": "Could not parse output from TypeScript's --showConfig.", + "1418": "TypeScript %s does not provide the compiler API required by Next.js. Enable %s in your Next.js config to use the TypeScript CLI, or install TypeScript 6 instead.", + "1419": "TypeScript CLI interrupted by %s" } diff --git a/packages/next/src/build/load-jsconfig.ts b/packages/next/src/build/load-jsconfig.ts index 55f875bec4ff..deb2037befcb 100644 --- a/packages/next/src/build/load-jsconfig.ts +++ b/packages/next/src/build/load-jsconfig.ts @@ -5,8 +5,14 @@ import * as Log from './output/log' import { getTypeScriptConfiguration } from '../lib/typescript/getTypeScriptConfiguration' import { readFileSync } from 'fs' import isError from '../lib/is-error' -import { hasNecessaryDependencies } from '../lib/has-necessary-dependencies' import { codeFrameColumns } from '../shared/lib/errors/code-frame' +import { + getTypeScriptApiMissingError, + getTypeScriptConfigurationCli, + getTypeScriptPackageInfo, + hasNativeTypeScriptPreview, +} from '../lib/typescript/runTypeScriptCli' +import { loadTsConfigOptions } from '../lib/typescript/loadTsConfig' let TSCONFIG_WARNED = false @@ -57,19 +63,24 @@ export default async function loadJsConfig( jsConfigPath?: string resolvedBaseUrl: ResolvedBaseUrl }> { - let typeScriptPath: string | undefined - try { - const deps = hasNecessaryDependencies(dir, [ - { - pkg: 'typescript', - file: 'typescript/lib/typescript.js', - exportsRestrict: true, - }, - ]) - typeScriptPath = deps.resolved.get('typescript') - } catch {} + const useTypeScriptCli = Boolean(config.experimental.useTypeScriptCli) + const typeScriptPackage = getTypeScriptPackageInfo(dir) + const typeScriptPath = useTypeScriptCli + ? typeScriptPackage?.tscPath + : typeScriptPackage?.apiPath const tsConfigFileName = config.typescript.tsconfigPath || 'tsconfig.json' const tsConfigPath = path.join(dir, tsConfigFileName) + + if ( + !useTypeScriptCli && + typeScriptPackage && + !typeScriptPackage.apiPath && + !hasNativeTypeScriptPreview(dir) && + fs.existsSync(tsConfigPath) + ) { + throw getTypeScriptApiMissingError(typeScriptPackage.version) + } + const useTypeScript = Boolean(typeScriptPath && fs.existsSync(tsConfigPath)) let implicitBaseurl @@ -81,11 +92,26 @@ export default async function loadJsConfig( Log.info(`Using tsconfig file: ${tsConfigFileName}`) } - const ts = (await Promise.resolve( - require(typeScriptPath!) - )) as typeof import('typescript') - const tsConfig = await getTypeScriptConfiguration(ts, tsConfigPath, true) - jsConfig = { compilerOptions: tsConfig.options } + if (useTypeScriptCli) { + const tsConfig = await getTypeScriptConfigurationCli({ + baseDir: dir, + tsConfigPath, + tscPath: typeScriptPath!, + }) + const configOrigins = loadTsConfigOptions(tsConfigPath) + jsConfig = { + compilerOptions: { + ...tsConfig.compilerOptions, + pathsBasePath: configOrigins.pathsBasePath, + }, + } + } else { + const ts = (await Promise.resolve( + require(typeScriptPath!) + )) as typeof import('typescript') + const tsConfig = await getTypeScriptConfiguration(ts, tsConfigPath, true) + jsConfig = { compilerOptions: tsConfig.options } + } implicitBaseurl = path.dirname(tsConfigPath) } @@ -98,7 +124,10 @@ export default async function loadJsConfig( let resolvedBaseUrl: ResolvedBaseUrl if (jsConfig?.compilerOptions?.baseUrl) { resolvedBaseUrl = { - baseUrl: path.resolve(dir, jsConfig.compilerOptions.baseUrl), + baseUrl: path.resolve( + implicitBaseurl ?? dir, + jsConfig.compilerOptions.baseUrl + ), isImplicit: false, } } else { diff --git a/packages/next/src/build/next-config-ts/transpile-config.ts b/packages/next/src/build/next-config-ts/transpile-config.ts index 6ff3a2b831a7..a20987615c44 100644 --- a/packages/next/src/build/next-config-ts/transpile-config.ts +++ b/packages/next/src/build/next-config-ts/transpile-config.ts @@ -1,15 +1,15 @@ import type { Options as SWCOptions } from '@swc/core' -import type { CompilerOptions } from 'typescript' import path from 'node:path' import { readFileSync, existsSync } from 'node:fs' import { pathToFileURL } from 'node:url' -import * as CommentJson from 'next/dist/compiled/comment-json' import { deregisterHook, registerHook, requireFromString } from './require-hook' import { warn, warnOnce } from '../output/log' import { getNodeOptionsArgs } from '../../server/lib/utils' - -type RelevantCompilerOptions = Pick +import { + loadTsConfigOptions, + type RelevantCompilerOptions, +} from '../../lib/typescript/loadTsConfig' function resolveSWCOptions( cwd: string, @@ -26,7 +26,7 @@ function resolveSWCOptions( { baseUrl: path.resolve(cwd, compilerOptions.baseUrl) } : compilerOptions.paths ? // If paths is given, baseUrl is required. - { baseUrl: cwd } + { baseUrl: compilerOptions.pathsBasePath ?? cwd } : {}), }, module: { @@ -42,85 +42,6 @@ function resolveSWCOptions( } satisfies SWCOptions } -function resolveExtends(extendsPath: string, currentConfigDir: string): string { - // Relative paths are resolved relative to the current config's directory - if ( - extendsPath.startsWith('./') || - extendsPath.startsWith('../') || - path.isAbsolute(extendsPath) - ) { - const resolved = path.resolve(currentConfigDir, extendsPath) - // TypeScript allows omitting .json extension - if (existsSync(resolved)) { - return resolved - } - if (!resolved.endsWith('.json') && existsSync(resolved + '.json')) { - return resolved + '.json' - } - return resolved - } - - // Package paths - use require.resolve to find the package - try { - // Try resolving as a direct path within the package - return require.resolve(extendsPath, { paths: [currentConfigDir] }) - } catch { - // If that fails, try appending tsconfig.json for package names like "@tsconfig/node18" - try { - return require.resolve(extendsPath + '/tsconfig.json', { - paths: [currentConfigDir], - }) - } catch { - // Return the original path and let it fail later with a clear error - return path.resolve(currentConfigDir, extendsPath) - } - } -} - -function loadTsConfigFile( - configPath: string, - visited: Set -): RelevantCompilerOptions { - const resolvedPath = path.resolve(configPath) - - if (visited.has(resolvedPath)) { - return {} - } - visited.add(resolvedPath) - - if (!existsSync(resolvedPath)) { - return {} - } - - const configContent = readFileSync(resolvedPath, 'utf8') - const config = CommentJson.parse(configContent) - const configDir = path.dirname(resolvedPath) - - let mergedOptions: RelevantCompilerOptions = {} - - // Note that config options from `extends` should get overwritten, not merged - if (config.extends) { - const extendsList = Array.isArray(config.extends) - ? config.extends - : [config.extends] - - for (const extendsPath of extendsList) { - const parentConfigPath = resolveExtends(extendsPath, configDir) - const parentOptions = loadTsConfigFile(parentConfigPath, visited) - mergedOptions = { ...mergedOptions, ...parentOptions } - } - } - - const currentOptions = config.compilerOptions ?? {} - mergedOptions = { - ...mergedOptions, - paths: currentOptions.paths ?? mergedOptions.paths, - baseUrl: currentOptions.baseUrl ?? mergedOptions.baseUrl, - } - - return mergedOptions -} - async function loadTsConfig(dir: string): Promise { // NOTE: This doesn't fully cover the edge case for setting // "typescript.tsconfigPath" in next config which is currently @@ -133,7 +54,7 @@ async function loadTsConfig(dir: string): Promise { return {} } - return loadTsConfigFile(resolvedTsConfigPath, new Set()) + return loadTsConfigOptions(resolvedTsConfigPath) } export async function transpileConfig({ diff --git a/packages/next/src/build/type-check.ts b/packages/next/src/build/type-check.ts index 861f31d3b36e..1f91c2b9caa0 100644 --- a/packages/next/src/build/type-check.ts +++ b/packages/next/src/build/type-check.ts @@ -10,8 +10,8 @@ import isError from '../lib/is-error' import { hrtimeDurationToString } from './duration-to-string' /** - * typescript will be loaded in "next/lib/verify-typescript-setup" and - * then passed to "next/lib/typescript/runTypeCheck" as a parameter. + * TypeScript setup and type checking run in a worker so the compiler's memory + * can be released before the rest of the build continues. * * Since it is impossible to pass a function from main thread to a worker, * instead of running "next/lib/typescript/runTypeCheck" in a worker, @@ -31,7 +31,8 @@ function verifyAndRunTypeScript( hasPagesDir: boolean, appDir: string | undefined, pagesDir: string | undefined, - debugBuildPaths: { app: string[]; pages: string[] } | undefined + debugBuildPaths: { app: string[]; pages: string[] } | undefined, + useTypeScriptCli: boolean ) { let impl: typeof import('../lib/verify-typescript-setup').verifyAndRunTypeScript let typeCheckWorker: @@ -47,7 +48,9 @@ function verifyAndRunTypeScript( debuggerPortOffset: -1, isolatedMemory: false, numWorkers: 1, - enableWorkerThreads, + // CLI mode must use a child-process worker so terminating the worker + // produces a process lifecycle event that can be forwarded to `tsc`. + enableWorkerThreads: useTypeScriptCli ? false : enableWorkerThreads, maxRetries: 0, } ) as typeof typeCheckWorker @@ -74,6 +77,7 @@ function verifyAndRunTypeScript( appDir, pagesDir, debugBuildPaths, + useTypeScriptCli, }) .then((result) => { typeCheckWorker?.end() @@ -106,6 +110,7 @@ export async function startTypeChecking({ debugBuildPaths: { app: string[]; pages: string[] } | undefined }) { const ignoreTypeScriptErrors = Boolean(config.typescript.ignoreBuildErrors) + const useTypeScriptCli = Boolean(config.experimental.useTypeScriptCli) if (ignoreTypeScriptErrors) { Log.info('Skipping validation of types') @@ -119,7 +124,14 @@ export async function startTypeChecking({ } if (typeCheckingSpinnerPrefixText) { - typeCheckingSpinner = createSpinner(typeCheckingSpinnerPrefixText) + if (useTypeScriptCli) { + // The CLI writes directly to stdout/stderr, bypassing the console hooks + // that pause an active spinner. Keep its diagnostics byte-for-byte and + // on their own lines by logging a static status line instead. + Log.info(`${typeCheckingSpinnerPrefixText} ...`) + } else { + typeCheckingSpinner = createSpinner(typeCheckingSpinnerPrefixText) + } } const typeCheckAndLintStart = process.hrtime() @@ -142,7 +154,8 @@ export async function startTypeChecking({ !!pagesDir, appDir, pagesDir, - debugBuildPaths + debugBuildPaths, + useTypeScriptCli ).then((resolved) => { const checkEnd = process.hrtime(typeCheckAndLintStart) return [resolved, checkEnd] as const @@ -165,6 +178,7 @@ export async function startTypeChecking({ inputFilesCount: verifyResult.result?.inputFilesCount, totalFilesCount: verifyResult.result?.totalFilesCount, incremental: verifyResult.result?.incremental, + typeCheckMode: verifyResult.typeCheckMode, }) ) } diff --git a/packages/next/src/cli/next-test.ts b/packages/next/src/cli/next-test.ts index f48d76b88e23..fdbb0217e197 100644 --- a/packages/next/src/cli/next-test.ts +++ b/packages/next/src/cli/next-test.ts @@ -149,6 +149,7 @@ async function runPlaywright( hasPagesDir: !!pagesDir, appDir: appDir || undefined, pagesDir: pagesDir || undefined, + useTypeScriptCli: Boolean(nextConfig.experimental.useTypeScriptCli), }) const isUsingTypeScript = !!typeScriptVersion diff --git a/packages/next/src/cli/next-typegen.ts b/packages/next/src/cli/next-typegen.ts index 7c7b6ebf43ba..1532bb271d67 100644 --- a/packages/next/src/cli/next-typegen.ts +++ b/packages/next/src/cli/next-typegen.ts @@ -56,6 +56,7 @@ const nextTypegen = async (options: NextTypegenOptions, directory?: string) => { hasPagesDir: !!pagesDir, appDir: appDir || undefined, pagesDir: pagesDir || undefined, + useTypeScriptCli: Boolean(nextConfig.experimental.useTypeScriptCli), }) console.log('Generating route types...') diff --git a/packages/next/src/lib/has-necessary-dependencies.ts b/packages/next/src/lib/has-necessary-dependencies.ts index 64a5b44adc91..25f7bc0bc35a 100644 --- a/packages/next/src/lib/has-necessary-dependencies.ts +++ b/packages/next/src/lib/has-necessary-dependencies.ts @@ -9,6 +9,8 @@ export interface MissingDependency { * If `exportsRestrict` is false, `${file}` MUST also resolve. */ pkg: string + /** Package specifier to install when it differs from pkg. */ + install?: string /** * If true, the pkg's package.json needs to be resolvable. * If true, will resolve `file` relative to the real path of the package.json. diff --git a/packages/next/src/lib/install-dependencies.ts b/packages/next/src/lib/install-dependencies.ts index ef51f6e04832..f236b015b283 100644 --- a/packages/next/src/lib/install-dependencies.ts +++ b/packages/next/src/lib/install-dependencies.ts @@ -32,7 +32,7 @@ export async function installDependencies( await install( path.resolve(baseDir), - deps.map((dep: MissingDependency) => dep.pkg), + deps.map((dep: MissingDependency) => dep.install ?? dep.pkg), { devDependencies: dev, isOnline, packageManager } ) console.log() diff --git a/packages/next/src/lib/typescript/loadTsConfig.ts b/packages/next/src/lib/typescript/loadTsConfig.ts new file mode 100644 index 000000000000..26fc08f25913 --- /dev/null +++ b/packages/next/src/lib/typescript/loadTsConfig.ts @@ -0,0 +1,190 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' + +import * as CommentJson from 'next/dist/compiled/comment-json' + +export type RelevantCompilerOptions = { + paths?: Record + /** Absolute path for an explicitly configured baseUrl. */ + baseUrl?: string + /** Absolute directory containing an inherited paths option without baseUrl. */ + pathsBasePath?: string +} + +function resolveConfigDirValue( + value: string, + configDir: string, + rootConfigDir: string +): string { + return path.resolve( + configDir, + value.replace(/\$\{configDir\}/g, rootConfigDir) + ) +} + +function resolveConfigFile(candidate: string): string | undefined { + for (const configPath of [ + candidate, + candidate.endsWith('.json') ? undefined : candidate + '.json', + ]) { + if (configPath && existsSync(configPath) && statSync(configPath).isFile()) { + return configPath + } + } +} + +function resolvePackageTsConfig( + extendsPath: string, + currentConfigDir: string +): string | undefined { + const parts = extendsPath.split('/') + const isPackageRoot = extendsPath.startsWith('@') + ? parts.length === 2 + : parts.length === 1 + + if (!isPackageRoot) { + return undefined + } + + try { + const packageJsonPath = require.resolve(extendsPath + '/package.json', { + paths: [currentConfigDir], + }) + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + if (packageJson.exports !== undefined) { + try { + const exportedConfig = require.resolve(extendsPath, { + paths: [currentConfigDir], + }) + if (path.extname(exportedConfig) === '.json') { + return exportedConfig + } + } catch {} + } + if (typeof packageJson.tsconfig === 'string') { + return resolveConfigFile( + path.resolve(path.dirname(packageJsonPath), packageJson.tsconfig) + ) + } + return resolveConfigFile( + path.join(path.dirname(packageJsonPath), 'tsconfig') + ) + } catch {} +} + +function resolveExtends(extendsPath: string, currentConfigDir: string): string { + if ( + extendsPath.startsWith('./') || + extendsPath.startsWith('../') || + path.isAbsolute(extendsPath) + ) { + const resolved = path.resolve(currentConfigDir, extendsPath) + if (existsSync(resolved)) { + return resolved + } + if (!resolved.endsWith('.json') && existsSync(resolved + '.json')) { + return resolved + '.json' + } + return resolved + } + + const packageConfigPath = resolvePackageTsConfig( + extendsPath, + currentConfigDir + ) + if (packageConfigPath) { + return packageConfigPath + } + + try { + const resolved = require.resolve(extendsPath, { paths: [currentConfigDir] }) + if (path.extname(resolved) === '.json') { + return resolved + } + } catch {} + + try { + return require.resolve(extendsPath + '/tsconfig.json', { + paths: [currentConfigDir], + }) + } catch { + return path.resolve(currentConfigDir, extendsPath) + } +} + +function loadTsConfigOptionsRecursive( + configPath: string, + visited: Set, + rootConfigDir: string +): RelevantCompilerOptions { + const resolvedPath = path.resolve(configPath) + + if (visited.has(resolvedPath) || !existsSync(resolvedPath)) { + return {} + } + + const nextVisited = new Set(visited) + nextVisited.add(resolvedPath) + + const configContent = readFileSync(resolvedPath, 'utf8') + const config = CommentJson.parse(configContent) + const configDir = path.dirname(resolvedPath) + + let mergedOptions: RelevantCompilerOptions = {} + + if (config.extends) { + const extendsList = Array.isArray(config.extends) + ? config.extends + : [config.extends] + + for (const extendsPath of extendsList) { + const parentConfigPath = resolveExtends(extendsPath, configDir) + const parentOptions = loadTsConfigOptionsRecursive( + parentConfigPath, + nextVisited, + rootConfigDir + ) + mergedOptions = { ...mergedOptions, ...parentOptions } + } + } + + const currentOptions = config.compilerOptions ?? {} + + if (Object.hasOwn(currentOptions, 'paths')) { + mergedOptions.paths = currentOptions.paths + mergedOptions.pathsBasePath = configDir + } + + if ( + Object.hasOwn(currentOptions, 'baseUrl') && + typeof currentOptions.baseUrl === 'string' + ) { + mergedOptions.baseUrl = resolveConfigDirValue( + currentOptions.baseUrl, + configDir, + rootConfigDir + ) + } + + if (mergedOptions.baseUrl) { + mergedOptions.pathsBasePath = undefined + } + + return mergedOptions +} + +/** + * Loads only the tsconfig options Next.js needs outside of the TypeScript API. + * Paths and baseUrl keep the directory they were declared in while resolving + * an extends chain, including arrays of extended configs. + */ +export function loadTsConfigOptions( + configPath: string +): RelevantCompilerOptions { + const resolvedPath = path.resolve(configPath) + return loadTsConfigOptionsRecursive( + resolvedPath, + new Set(), + path.dirname(resolvedPath) + ) +} diff --git a/packages/next/src/lib/typescript/missingDependencyError.ts b/packages/next/src/lib/typescript/missingDependencyError.ts index c0444291dcf4..f32b34484b83 100644 --- a/packages/next/src/lib/typescript/missingDependencyError.ts +++ b/packages/next/src/lib/typescript/missingDependencyError.ts @@ -9,7 +9,7 @@ export function missingDepsError( missingPackages: MissingDependency[] ): never { const packagesHuman = getOxfordCommaList(missingPackages.map((p) => p.pkg)) - const packagesCli = missingPackages.map((p) => p.pkg).join(' ') + const packagesCli = missingPackages.map((p) => p.install ?? p.pkg).join(' ') const packageManager = getPkgManager(dir) const removalMsg = diff --git a/packages/next/src/lib/typescript/runTypeCheck.ts b/packages/next/src/lib/typescript/runTypeCheck.ts index e786c7d263e6..56462a875037 100644 --- a/packages/next/src/lib/typescript/runTypeCheck.ts +++ b/packages/next/src/lib/typescript/runTypeCheck.ts @@ -10,8 +10,8 @@ import { warn } from '../../build/output/log' export interface TypeCheckResult { hasWarnings: boolean warnings?: string[] - inputFilesCount: number - totalFilesCount: number + inputFilesCount?: number + totalFilesCount?: number incremental: boolean } diff --git a/packages/next/src/lib/typescript/runTypeCheckCli.ts b/packages/next/src/lib/typescript/runTypeCheckCli.ts new file mode 100644 index 000000000000..02856b0e85fa --- /dev/null +++ b/packages/next/src/lib/typescript/runTypeCheckCli.ts @@ -0,0 +1,58 @@ +import path from 'node:path' + +import { CompileError } from '../compile-error' +import type { TypeCheckResult } from './runTypeCheck' +import { + getTypeScriptConfigurationCli, + runTypeScriptCli, +} from './runTypeScriptCli' + +export async function runTypeCheckCli({ + baseDir, + tsConfigPath, + tscPath, + cacheDir, +}: { + baseDir: string + tsConfigPath: string + tscPath: string + cacheDir?: string +}): Promise { + const configuration = await getTypeScriptConfigurationCli({ + baseDir, + tsConfigPath, + tscPath, + }) + const incremental = Boolean( + configuration.compilerOptions.incremental || + configuration.compilerOptions.composite + ) + const args = [ + '--project', + tsConfigPath, + '--noEmit', + '--declarationMap', + 'false', + '--emitDeclarationOnly', + 'false', + ] + + if (incremental && cacheDir) { + args.push('--tsBuildInfoFile', path.join(cacheDir, '.tsbuildinfo')) + } + + const result = await runTypeScriptCli({ + cwd: baseDir, + tscPath, + args, + }) + + if (result.exitCode !== 0) { + throw new CompileError() + } + + return { + hasWarnings: false, + incremental, + } +} diff --git a/packages/next/src/lib/typescript/runTypeScriptCli.test.ts b/packages/next/src/lib/typescript/runTypeScriptCli.test.ts new file mode 100644 index 000000000000..7b85c96df38b --- /dev/null +++ b/packages/next/src/lib/typescript/runTypeScriptCli.test.ts @@ -0,0 +1,140 @@ +import type { ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' + +const mockSpawn = jest.fn() + +jest.mock('next/dist/compiled/cross-spawn', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockSpawn(...args), +})) + +const { runTypeScriptCli } = + require('./runTypeScriptCli') as typeof import('./runTypeScriptCli') + +const processEvents = ['exit', 'SIGINT', 'SIGTERM', 'SIGHUP'] as const + +type ProcessEvent = (typeof processEvents)[number] +type ProcessListener = (...args: any[]) => void + +function getProcessListeners(event: ProcessEvent): ProcessListener[] { + return (process as EventEmitter).listeners(event) as ProcessListener[] +} + +class MockChildProcess extends EventEmitter { + killed = false + kill = jest.fn(() => true) + stdout = new PassThrough() + stderr = new PassThrough() +} + +describe('runTypeScriptCli', () => { + let child: MockChildProcess + let originalListeners: Map + let processKill: jest.SpiedFunction + + beforeEach(() => { + child = new MockChildProcess() + mockSpawn.mockReset().mockReturnValue(child as unknown as ChildProcess) + processKill = jest.spyOn(process, 'kill').mockReturnValue(true) + originalListeners = new Map( + processEvents.map((event) => [event, getProcessListeners(event)]) + ) + }) + + afterEach(() => { + for (const event of processEvents) { + const listenersBeforeTest = originalListeners.get(event)! + for (const listener of getProcessListeners(event)) { + if (!listenersBeforeTest.includes(listener)) { + process.off(event, listener) + } + } + } + processKill.mockRestore() + }) + + function getAddedListener(event: ProcessEvent): ProcessListener { + const listenersBeforeTest = originalListeners.get(event)! + const addedListener = getProcessListeners(event).find( + (listener) => !listenersBeforeTest.includes(listener) + ) + + expect(addedListener).toBeDefined() + return addedListener! + } + + function expectListenersRestored() { + for (const event of processEvents) { + expect(getProcessListeners(event)).toEqual(originalListeners.get(event)) + } + } + + it('forwards SIGTERM to the child and cleans up all listeners on close', async () => { + const resultPromise = runTypeScriptCli({ + cwd: '/project', + tscPath: '/project/node_modules/typescript/bin/tsc', + args: ['--noEmit'], + }) + + expect(mockSpawn).toHaveBeenCalledWith( + process.execPath, + ['/project/node_modules/typescript/bin/tsc', '--noEmit'], + expect.objectContaining({ + cwd: '/project', + detached: process.platform !== 'win32', + shell: false, + stdio: 'inherit', + }) + ) + getAddedListener('SIGTERM')() + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + + child.emit('close', 0, null) + + await expect(resultPromise).rejects.toThrow( + 'TypeScript CLI interrupted by SIGTERM' + ) + expect(processKill).toHaveBeenCalledWith(process.pid, 'SIGTERM') + expectListenersRestored() + }) + + it('rejects spawn failures and cleans up all listeners', async () => { + const error = new Error('failed to spawn tsc') + const resultPromise = runTypeScriptCli({ + cwd: '/project', + tscPath: '/project/node_modules/typescript/bin/tsc', + args: ['--noEmit'], + }) + const rejection = resultPromise.catch((spawnError) => spawnError) + + child.emit('error', error) + + await expect(rejection).resolves.toBe(error) + expectListenersRestored() + }) + + it('decodes captured UTF-8 output across chunk boundaries', async () => { + const resultPromise = runTypeScriptCli({ + cwd: '/project', + tscPath: '/project/node_modules/typescript/bin/tsc', + args: ['--showConfig'], + captureOutput: true, + }) + const stdout = '{"compilerOptions":{"baseUrl":"café"}}' + const stdoutBuffer = Buffer.from(stdout) + const splitIndex = stdoutBuffer.indexOf('é') + 1 + + child.stdout.write(stdoutBuffer.subarray(0, splitIndex)) + child.stdout.write(stdoutBuffer.subarray(splitIndex)) + child.stderr.write(Buffer.from('avertissement 💡')) + child.emit('close', 0, null) + + await expect(resultPromise).resolves.toMatchObject({ + exitCode: 0, + stdout, + stderr: 'avertissement 💡', + }) + expectListenersRestored() + }) +}) diff --git a/packages/next/src/lib/typescript/runTypeScriptCli.ts b/packages/next/src/lib/typescript/runTypeScriptCli.ts new file mode 100644 index 000000000000..29ed05e970eb --- /dev/null +++ b/packages/next/src/lib/typescript/runTypeScriptCli.ts @@ -0,0 +1,250 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +import spawn from 'next/dist/compiled/cross-spawn' + +import { bold } from '../picocolors' +import { resolveFrom } from '../resolve-from' + +export interface TypeScriptPackageInfo { + packageJsonPath: string + packageDir: string + version: string + apiPath?: string + tscPath?: string +} + +export function getTypeScriptPackageInfo( + baseDir: string +): TypeScriptPackageInfo | null { + let packageJsonPath: string + try { + packageJsonPath = resolveFrom(baseDir, 'typescript/package.json') + } catch { + return null + } + + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + version: string + type?: string + bin?: string | Record + } + const packageDir = path.dirname(packageJsonPath) + const apiPath = path.join(packageDir, 'lib', 'typescript.js') + const tscBin = + typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.tsc + const tscBinPath = tscBin ? path.resolve(packageDir, tscBin) : undefined + let tscPath = tscBinPath + + if ( + tscBinPath && + existsSync(tscBinPath) && + packageJson.type === 'module' && + path.extname(tscBinPath) === '' + ) { + // TypeScript 7's extensionless ESM bin wrapper cannot be used as Node's + // main entry point on Node.js 20.9. Its imported JS entry is the same CLI + // wrapper and works across all supported Node.js versions. + const tscJsPath = path.join(packageDir, 'lib', 'tsc.js') + if (existsSync(tscJsPath)) { + tscPath = tscJsPath + } + } + + return { + packageJsonPath, + packageDir, + version: packageJson.version, + apiPath: existsSync(apiPath) ? apiPath : undefined, + tscPath: tscPath && existsSync(tscPath) ? tscPath : undefined, + } +} + +export function hasNativeTypeScriptPreview(baseDir: string): boolean { + try { + resolveFrom(baseDir, '@typescript/native-preview/package.json') + return true + } catch { + return false + } +} + +export function getTypeScriptApiMissingError(version: string): Error { + return new Error( + `TypeScript ${version} does not provide the compiler API required by Next.js. ` + + `Enable ${bold('experimental.useTypeScriptCli')} in your Next.js config to use the TypeScript CLI, ` + + `or install TypeScript 6 instead.` + ) +} + +export interface TypeScriptCliResult { + exitCode: number + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +const terminationSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'] + +export function runTypeScriptCli({ + cwd, + tscPath, + args, + captureOutput = false, +}: { + cwd: string + tscPath: string + args: string[] + captureOutput?: boolean +}): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [tscPath, ...args], { + cwd, + // TypeScript 7's Node wrapper starts the native compiler synchronously + // on older Node.js releases. A separate process group lets termination + // reach both processes instead of orphaning the native compiler. + detached: process.platform !== 'win32', + shell: false, + stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit', + env: { + ...process.env, + }, + }) + + let stdout = '' + let stderr = '' + + if (captureOutput) { + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', (chunk) => { + stdout += chunk + }) + child.stderr?.on('data', (chunk) => { + stderr += chunk + }) + } + + let terminationRequested = false + const terminateChild = (signal: NodeJS.Signals = 'SIGTERM') => { + if (terminationRequested || child.killed) { + return + } + terminationRequested = true + + if (process.platform === 'win32' && child.pid) { + spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }) + } else if (child.pid) { + try { + process.kill(-child.pid, signal) + } catch { + // The process may have exited between the lifecycle event and kill. + } + } else { + child.kill(signal) + } + } + const terminateOnExit = () => terminateChild() + process.once('exit', terminateOnExit) + + let receivedSignal: NodeJS.Signals | undefined + const signalHandlers = new Map void>() + const createSignalHandler = (signal: NodeJS.Signals) => () => { + receivedSignal ??= signal + terminateChild(signal) + } + for (const signal of terminationSignals) { + const handler = createSignalHandler(signal) + signalHandlers.set(signal, handler) + process.once(signal, handler) + } + + const cleanup = () => { + process.off('exit', terminateOnExit) + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler) + } + } + + let settled = false + const finish = (settle: () => void) => { + if (settled) { + return + } + settled = true + cleanup() + + if (receivedSignal) { + const signal = receivedSignal + try { + // Installing a signal handler replaces Node.js' default termination. + // Re-send the signal after the child exits so this process preserves + // the original exit semantics instead of treating cancellation as a + // successful type check. + process.kill(process.pid, signal) + } catch (error) { + reject(error) + return + } + + // If another listener consumes the re-sent signal, still fail instead + // of leaving the type-check promise pending indefinitely. + setImmediate(() => { + reject(new Error(`TypeScript CLI interrupted by ${signal}`)) + }) + return + } + + settle() + } + + child.on('error', (error) => { + finish(() => reject(error)) + }) + child.on('close', (code, signal) => { + finish(() => { + resolve({ + exitCode: code ?? 1, + signal, + stdout, + stderr, + }) + }) + }) + }) +} + +export async function getTypeScriptConfigurationCli({ + baseDir, + tsConfigPath, + tscPath, +}: { + baseDir: string + tsConfigPath: string + tscPath: string +}): Promise<{ compilerOptions: Record }> { + const result = await runTypeScriptCli({ + cwd: baseDir, + tscPath, + args: ['--showConfig', '--project', tsConfigPath, '--pretty', 'false'], + captureOutput: true, + }) + + if (result.exitCode !== 0) { + throw new Error( + [result.stdout, result.stderr].filter(Boolean).join('\n').trim() + ) + } + + try { + return JSON.parse(result.stdout) + } catch (cause) { + throw new Error(`Could not parse output from TypeScript's --showConfig.`, { + cause, + }) + } +} diff --git a/packages/next/src/lib/verify-typescript-setup.ts b/packages/next/src/lib/verify-typescript-setup.ts index 52c63d869348..1ba7b83121f3 100644 --- a/packages/next/src/lib/verify-typescript-setup.ts +++ b/packages/next/src/lib/verify-typescript-setup.ts @@ -1,5 +1,5 @@ import { bold, cyan, red, yellow } from './picocolors' -import path, { join } from 'path' +import path from 'path' import { hasNecessaryDependencies } from './has-necessary-dependencies' import type { @@ -17,16 +17,26 @@ import { writeConfigurationDefaults } from './typescript/writeConfigurationDefau import { installDependencies } from './install-dependencies' import { isCI } from '../server/ci-info' import { missingDepsError } from './typescript/missingDependencyError' -import { resolveFrom } from './resolve-from' +import { + getTypeScriptApiMissingError, + getTypeScriptPackageInfo, + hasNativeTypeScriptPreview, +} from './typescript/runTypeScriptCli' -const typescriptPackage: MissingDependency = { +const typescriptApiPackage: MissingDependency = { file: 'typescript/lib/typescript.js', pkg: 'typescript', + install: 'typescript@^6.0.0', exportsRestrict: true, } -const requiredPackages: MissingDependency[] = [ - typescriptPackage, +const typescriptCliPackage: MissingDependency = { + file: 'typescript/bin/tsc', + pkg: 'typescript', + exportsRestrict: true, +} + +const requiredTypePackages: MissingDependency[] = [ { file: '@types/react/index.d.ts', pkg: '@types/react', @@ -39,20 +49,6 @@ const requiredPackages: MissingDependency[] = [ }, ] -/** - * Check if @typescript/native-preview is installed as an alternative TypeScript compiler. - * This is a Go-based native TypeScript compiler that can be used instead of the standard - * TypeScript package for faster compilation. - */ -function hasNativeTypeScriptPreview(dir: string): boolean { - try { - resolveFrom(dir, '@typescript/native-preview/package.json') - return true - } catch { - return false - } -} - export async function verifyAndRunTypeScript({ dir, distDir, @@ -67,6 +63,7 @@ export async function verifyAndRunTypeScript({ appDir, pagesDir, debugBuildPaths, + useTypeScriptCli = false, }: { dir: string distDir: string @@ -81,9 +78,15 @@ export async function verifyAndRunTypeScript({ appDir?: string pagesDir?: string debugBuildPaths?: { app?: string[]; pages?: string[] } -}): Promise<{ result?: TypeCheckResult; version: string | null }> { + useTypeScriptCli?: boolean +}): Promise<{ + result?: TypeCheckResult + version: string | null + typeCheckMode: 'typescript-api' | 'typescript-cli' +}> { const tsConfigFileName = tsconfigPath || 'tsconfig.json' const resolvedTsConfigPath = path.join(dir, tsConfigFileName) + const typeCheckMode = useTypeScriptCli ? 'typescript-cli' : 'typescript-api' // Construct intentDirs from appDir and pagesDir for getTypeScriptIntent const intentDirs = [pagesDir, appDir].filter(Boolean) as string[] @@ -92,11 +95,26 @@ export async function verifyAndRunTypeScript({ // Check if the project uses TypeScript: const intent = await getTypeScriptIntent(dir, intentDirs, tsConfigFileName) if (!intent) { - return { version: null } + return { version: null, typeCheckMode } } // Check if @typescript/native-preview is installed as an alternative const hasNativePreview = hasNativeTypeScriptPreview(dir) + const installedTypeScript = getTypeScriptPackageInfo(dir) + + if ( + !useTypeScriptCli && + !hasNativePreview && + installedTypeScript && + !installedTypeScript.apiPath + ) { + throw getTypeScriptApiMissingError(installedTypeScript.version) + } + + const requiredPackages: MissingDependency[] = [ + useTypeScriptCli ? typescriptCliPackage : typescriptApiPackage, + ...requiredTypePackages, + ] // Ensure TypeScript and necessary `@types/*` are installed: let deps: NecessaryDependencies = hasNecessaryDependencies( @@ -107,7 +125,7 @@ export async function verifyAndRunTypeScript({ // If @typescript/native-preview is installed and only the typescript package is missing, // we can skip auto-installing typescript since the native preview provides TS compilation. // However, we still need @types/react and @types/node for type checking. - if (hasNativePreview && deps.missing?.length > 0) { + if (!useTypeScriptCli && hasNativePreview && deps.missing?.length > 0) { const missingWithoutTypescript = deps.missing.filter( (dep) => dep.pkg !== 'typescript' ) @@ -133,7 +151,7 @@ export async function verifyAndRunTypeScript({ typedRoutes, }) - return { version: null } + return { version: null, typeCheckMode } } // If there are other missing deps besides typescript, only install those @@ -180,13 +198,21 @@ export async function verifyAndRunTypeScript({ deps = hasNecessaryDependencies(dir, requiredPackages) } - // Load TypeScript after we're sure it exists: - const tsPackageJsonPath = deps.resolved.get( - join('typescript', 'package.json') - )! - const typescriptPackageJson = require(tsPackageJsonPath) + const typeScriptPackage = getTypeScriptPackageInfo(dir) + const typeScriptPath = useTypeScriptCli + ? typeScriptPackage?.tscPath + : typeScriptPackage?.apiPath + + if (!typeScriptPackage || !typeScriptPath) { + missingDepsError( + dir, + deps.missing.length > 0 + ? deps.missing + : [useTypeScriptCli ? typescriptCliPackage : typescriptApiPackage] + ) + } - const typescriptVersion = typescriptPackageJson.version + const typescriptVersion = typeScriptPackage.version if (semver.lt(typescriptVersion, '5.1.0')) { log.warn( @@ -218,36 +244,54 @@ export async function verifyAndRunTypeScript({ let result if (shouldRunTypeCheck) { - const { runTypeCheck } = - require('./typescript/runTypeCheck') as typeof import('./typescript/runTypeCheck') - // Install native bindings so that code frame rendering works in the worker - const { installBindings } = - require('../build/swc/install-bindings') as typeof import('../build/swc/install-bindings') - await installBindings() + if (useTypeScriptCli) { + if (debugBuildPaths) { + log.warn( + '`experimental.useTypeScriptCli` checks the complete TypeScript project; `--debug-build-paths` does not limit type checking.' + ) + } - const tsPath = deps.resolved.get('typescript')! - const typescript = (await Promise.resolve( - require(tsPath) - )) as typeof import('typescript') + const { runTypeCheckCli } = + require('./typescript/runTypeCheckCli') as typeof import('./typescript/runTypeCheckCli') + result = await runTypeCheckCli({ + baseDir: dir, + tsConfigPath: resolvedTsConfigPath, + tscPath: typeScriptPath, + cacheDir, + }) + } else { + const { runTypeCheck } = + require('./typescript/runTypeCheck') as typeof import('./typescript/runTypeCheck') + // Install native bindings so that code frame rendering works in the worker + const { installBindings } = + require('../build/swc/install-bindings') as typeof import('../build/swc/install-bindings') + await installBindings() - // Verify the project passes type-checking before we go to webpack phase: - result = await runTypeCheck( - typescript, - dir, - distDir, - resolvedTsConfigPath, - cacheDir, - hasAppDir, - { app: appDir, pages: pagesDir }, - debugBuildPaths - ) + const typescript = (await Promise.resolve( + require(typeScriptPath) + )) as typeof import('typescript') + + // Verify the project passes type-checking before we go to webpack phase: + result = await runTypeCheck( + typescript, + dir, + distDir, + resolvedTsConfigPath, + cacheDir, + hasAppDir, + { app: appDir, pages: pagesDir }, + debugBuildPaths + ) + } } - return { result, version: typescriptVersion } + return { result, version: typescriptVersion, typeCheckMode } } catch (err) { // These are special errors that should not show a stack trace: if (err instanceof CompileError) { console.error(red('Failed to type check.\n')) - console.error(err.message) + if (err.message) { + console.error(err.message) + } process.exit(1) } diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 94ecbf3d28c7..c29a287f812e 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -406,6 +406,7 @@ export const experimentalSchema = { optimizePackageImports: z.array(z.string()).optional(), optimizeServerReact: z.boolean().optional(), strictRouteTypes: z.boolean().optional(), + useTypeScriptCli: z.boolean().optional(), clientTraceMetadata: z.array(z.string()).optional(), serverMinification: z.boolean().optional(), serverSourceMaps: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index bbfbc0cf806e..aaa3e48b60e5 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -710,6 +710,12 @@ export interface ExperimentalConfig { */ strictRouteTypes?: boolean + /** + * Runs the project-local TypeScript CLI instead of using TypeScript's + * programmatic API for build-time type checking and config loading. + */ + useTypeScriptCli?: boolean + /** * Displays an indicator when a React Transition has no other indicator rendered. * This includes displaying an indicator on client-side navigations. @@ -2148,6 +2154,7 @@ export const defaultConfig = Object.freeze({ webpackMemoryOptimizations: false, optimizeServerReact: true, strictRouteTypes: false, + useTypeScriptCli: false, viewTransition: false, removeUncaughtErrorAndRejectionListeners: false, validateRSCRequestHeaders: true, diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index d91df07f43cb..b9beff9d1e51 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -165,6 +165,7 @@ async function verifyTypeScript(opts: SetupOpts) { hasPagesDir: !!opts.pagesDir, appDir: opts.appDir, pagesDir: opts.pagesDir, + useTypeScriptCli: Boolean(opts.nextConfig.experimental.useTypeScriptCli), }) if (verifyResult.version) { diff --git a/packages/next/src/telemetry/events/build.ts b/packages/next/src/telemetry/events/build.ts index ab1c71d9fc51..93c6bef746a1 100644 --- a/packages/next/src/telemetry/events/build.ts +++ b/packages/next/src/telemetry/events/build.ts @@ -15,6 +15,7 @@ type EventTypeCheckCompleted = { inputFilesCount?: number totalFilesCount?: number incremental?: boolean + typeCheckMode: 'typescript-api' | 'typescript-cli' } export function eventTypeCheckCompleted(event: EventTypeCheckCompleted): { diff --git a/test/production/app-dir/typescript-cli/.npmrc b/test/production/app-dir/typescript-cli/.npmrc new file mode 100644 index 000000000000..6b2257f1b207 --- /dev/null +++ b/test/production/app-dir/typescript-cli/.npmrc @@ -0,0 +1,2 @@ +# TypeScript 7 is intentionally tested immediately after release. +minimum-release-age=0 diff --git a/test/production/app-dir/typescript-cli/app/layout.tsx b/test/production/app-dir/typescript-cli/app/layout.tsx new file mode 100644 index 000000000000..7c3f422f0039 --- /dev/null +++ b/test/production/app-dir/typescript-cli/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/production/app-dir/typescript-cli/app/page.tsx b/test/production/app-dir/typescript-cli/app/page.tsx new file mode 100644 index 000000000000..0cfb09fdcbda --- /dev/null +++ b/test/production/app-dir/typescript-cli/app/page.tsx @@ -0,0 +1,5 @@ +import { message } from '@fixture/message' + +export default function Page() { + return

{message}

+} diff --git a/test/production/app-dir/typescript-cli/config/tsconfig.paths.json b/test/production/app-dir/typescript-cli/config/tsconfig.paths.json new file mode 100644 index 000000000000..369f78587f06 --- /dev/null +++ b/test/production/app-dir/typescript-cli/config/tsconfig.paths.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "paths": { + "@fixture/*": ["../src/*"] + } + } +} diff --git a/test/production/app-dir/typescript-cli/next.config.js b/test/production/app-dir/typescript-cli/next.config.js new file mode 100644 index 000000000000..91a3b08a1171 --- /dev/null +++ b/test/production/app-dir/typescript-cli/next.config.js @@ -0,0 +1,13 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + useTypeScriptCli: true, + }, + typescript: { + tsconfigPath: 'tsconfig.build.json', + }, +} + +module.exports = nextConfig diff --git a/test/production/app-dir/typescript-cli/src/message.ts b/test/production/app-dir/typescript-cli/src/message.ts new file mode 100644 index 000000000000..b79fbec04214 --- /dev/null +++ b/test/production/app-dir/typescript-cli/src/message.ts @@ -0,0 +1 @@ +export const message = 'hello from an inherited path alias' diff --git a/test/production/app-dir/typescript-cli/tsconfig.build.json b/test/production/app-dir/typescript-cli/tsconfig.build.json new file mode 100644 index 000000000000..ef016d80bb80 --- /dev/null +++ b/test/production/app-dir/typescript-cli/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "./config/tsconfig.paths.json", + "compilerOptions": { + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "app/**/*.ts", + "app/**/*.tsx", + "src/**/*.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/test/production/app-dir/typescript-cli/typescript-cli.test.ts b/test/production/app-dir/typescript-cli/typescript-cli.test.ts new file mode 100644 index 000000000000..2c9e5289dc52 --- /dev/null +++ b/test/production/app-dir/typescript-cli/typescript-cli.test.ts @@ -0,0 +1,170 @@ +import { nextTestSetup } from 'e2e-utils' + +const cliConfig = `module.exports = { + experimental: { useTypeScriptCli: true }, + typescript: { tsconfigPath: 'tsconfig.build.json' }, +} +` + +const apiConfig = `module.exports = { + typescript: { tsconfigPath: 'tsconfig.build.json' }, +} +` + +const typeError = `export const invalidValue: number = 'not a number' +` + +describe('experimental TypeScript CLI backend', () => { + describe('TypeScript 7', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipStart: true, + skipDeployment: true, + dependencies: { + typescript: '7.0.2', + }, + }) + + if (skipped) return + + let originalTsConfig: string + + beforeAll(async () => { + originalTsConfig = await next.readFile('tsconfig.build.json') + }) + + afterEach(async () => { + await next.patchFile('next.config.js', cliConfig) + await next.patchFile('tsconfig.build.json', originalTsConfig) + await next.deleteFile('src/type-error.ts').catch(() => {}) + await next + .deleteFile('.next/dev/types/stale-type-error.test.ts') + .catch(() => {}) + await next.deleteFile('.next/cache/.tsbuildinfo').catch(() => {}) + }) + + it('builds with the native CLI, a custom tsconfig, and inherited paths', async () => { + const result = await next.build({ + env: { NEXT_TELEMETRY_DEBUG: '1' }, + }) + + expect(result.exitCode).toBe(0) + expect(result.cliOutput).toContain('NEXT_TYPE_CHECK_COMPLETED') + expect(result.cliOutput).toContain('"typeCheckMode": "typescript-cli"') + expect(result.cliOutput).not.toContain('"inputFilesCount"') + expect(result.cliOutput).not.toContain('"totalFilesCount"') + expect(await next.hasFile('.next/cache/.tsbuildinfo')).toBe(true) + }) + + it('prints raw TypeScript CLI diagnostics', async () => { + await next.patchFile('src/type-error.ts', typeError) + + const result = await next.build() + + expect(result.exitCode).toBe(1) + expect(result.cliOutput).toContain('src/type-error.ts') + expect(result.cliOutput).toContain('error TS2322') + expect(result.cliOutput).toContain( + "Type 'string' is not assignable to type 'number'" + ) + }) + + it('does not create incremental state when incremental is disabled', async () => { + await next.patchFile( + 'tsconfig.build.json', + originalTsConfig.replace('"incremental": true', '"incremental": false') + ) + + const result = await next.build() + + expect(result.exitCode).toBe(0) + expect(await next.hasFile('.next/cache/.tsbuildinfo')).toBe(false) + }) + + it('honors typescript.ignoreBuildErrors', async () => { + await next.patchFile('src/type-error.ts', typeError) + await next.patchFile( + 'next.config.js', + `module.exports = { + experimental: { useTypeScriptCli: true }, + typescript: { + ignoreBuildErrors: true, + tsconfigPath: 'tsconfig.build.json', + }, +} +` + ) + + const result = await next.build() + + expect(result.exitCode).toBe(0) + expect(result.cliOutput).toContain('Skipping validation of types') + }) + + it('checks the complete project with --debug-build-paths', async () => { + await next.patchFile( + '.next/dev/types/stale-type-error.test.ts', + typeError + ) + + const result = await next.build({ + args: ['--debug-build-paths', 'app/page.tsx'], + }) + + expect(result.exitCode).toBe(1) + expect(result.cliOutput).toContain( + '`experimental.useTypeScriptCli` checks the complete TypeScript project; `--debug-build-paths` does not limit type checking.' + ) + expect(result.cliOutput).toContain( + '.next/dev/types/stale-type-error.test.ts' + ) + expect(result.cliOutput).toContain('error TS2322') + }) + }) + + describe('TypeScript 7 without the opt-in', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipStart: true, + skipDeployment: true, + dependencies: { + typescript: '7.0.2', + }, + }) + + if (skipped) return + + it('fails with actionable migration guidance', async () => { + await next.patchFile('next.config.js', apiConfig) + + const result = await next.build() + + expect(result.exitCode).toBe(1) + expect(result.cliOutput).toContain( + 'TypeScript 7.0.2 does not provide the compiler API required by Next.js' + ) + expect(result.cliOutput).toContain('experimental.useTypeScriptCli') + expect(result.cliOutput).toContain('install TypeScript 6 instead') + }) + }) + + describe('TypeScript 6', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipStart: true, + skipDeployment: true, + dependencies: { + typescript: '6.0.2', + }, + }) + + if (skipped) return + + it('uses the same project-local tsc entry point', async () => { + const result = await next.build() + + expect(result.exitCode).toBe(0) + expect(await next.hasFile('.next/cache/.tsbuildinfo')).toBe(true) + }) + }) +}) diff --git a/test/production/app-dir/typescript-cli/unused.ts b/test/production/app-dir/typescript-cli/unused.ts new file mode 100644 index 000000000000..66bd3aecf5a1 --- /dev/null +++ b/test/production/app-dir/typescript-cli/unused.ts @@ -0,0 +1 @@ +export const shouldNotBeChecked: number = 'custom tsconfig was ignored' diff --git a/test/production/ci-missing-typescript-deps/index.test.ts b/test/production/ci-missing-typescript-deps/index.test.ts index b368f2678c44..63ed5c8bc4dd 100644 --- a/test/production/ci-missing-typescript-deps/index.test.ts +++ b/test/production/ci-missing-typescript-deps/index.test.ts @@ -30,6 +30,46 @@ describe('ci-missing-typescript-deps', () => { `It looks like you're trying to use TypeScript but do not have the required package(s) installed.` ) expect(next.cliOutput).toContain(`Please install`) + expect(next.cliOutput).toContain( + 'pnpm install --save-dev typescript@^6.0.0' + ) + expect(next.cliOutput).not.toContain('Call retries were exceeded') + expect(next.cliOutput).not.toContain('WorkerError') + }) + }) + + describe('missing TypeScript CLI dependency', () => { + const { next } = nextTestSetup({ + files: { + 'next.config.js': ` + module.exports = { + experimental: { useTypeScriptCli: true }, + } + `, + 'pages/index.tsx': ` + export default function Page() { + return

hello world

+ } + `, + }, + env: { + CI: '1', + }, + skipStart: true, + dependencies: { + typescript: undefined, + }, + }) + + it('should recommend the latest TypeScript package in CI', async () => { + let error + await next.start().catch((err) => { + error = err + }) + + expect(error).toBeDefined() + expect(next.cliOutput).toContain('pnpm install --save-dev typescript') + expect(next.cliOutput).not.toContain('typescript@^6.0.0') expect(next.cliOutput).not.toContain('Call retries were exceeded') expect(next.cliOutput).not.toContain('WorkerError') }) diff --git a/test/unit/typescript-cli-config-origin/fixture/config-dir/base.json b/test/unit/typescript-cli-config-origin/fixture/config-dir/base.json new file mode 100644 index 000000000000..ee6dc10099d4 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/config-dir/base.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "baseUrl": "${configDir}/base" + } +} diff --git a/test/unit/typescript-cli-config-origin/fixture/config-dir/project/tsconfig.json b/test/unit/typescript-cli-config-origin/fixture/config-dir/project/tsconfig.json new file mode 100644 index 000000000000..0e129851fbd7 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/config-dir/project/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../base.json" +} diff --git a/test/unit/typescript-cli-config-origin/fixture/default-project/tsconfig.json b/test/unit/typescript-cli-config-origin/fixture/default-project/tsconfig.json new file mode 100644 index 000000000000..02317f2abc11 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/default-project/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@fixture/default-tsconfig" +} diff --git a/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/nested/main.json b/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/nested/main.json new file mode 100644 index 000000000000..86af782b4fcb --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/nested/main.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@wrong/*": ["source/*"] + } + } +} diff --git a/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/package.json b/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/package.json new file mode 100644 index 000000000000..dcf18f662cd9 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/default-tsconfig", + "version": "1.0.0", + "main": "nested/main.json" +} diff --git a/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/tsconfig.json b/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/tsconfig.json new file mode 100644 index 000000000000..1d58719ee532 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/default-tsconfig-package/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@default/*": ["source/*"] + } + } +} diff --git a/test/unit/typescript-cli-config-origin/fixture/project/tsconfig.json b/test/unit/typescript-cli-config-origin/fixture/project/tsconfig.json new file mode 100644 index 000000000000..c001216719df --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/project/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@fixture/tsconfig" +} diff --git a/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/configs/base.json b/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/configs/base.json new file mode 100644 index 000000000000..231a8c91da61 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/configs/base.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@fixture/*": ["source/*"] + } + } +} diff --git a/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/index.js b/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/index.js new file mode 100644 index 000000000000..4ba52ba2c8df --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/index.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/package.json b/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/package.json new file mode 100644 index 000000000000..c4cb4bf17cab --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/tsconfig-package/package.json @@ -0,0 +1,6 @@ +{ + "name": "@fixture/tsconfig", + "version": "1.0.0", + "main": "index.js", + "tsconfig": "configs/base.json" +} diff --git a/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/bin/tsc b/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/bin/tsc new file mode 100644 index 000000000000..62d755c8ce9e --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/bin/tsc @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../lib/tsc.js' diff --git a/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/lib/tsc.js b/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/lib/tsc.js new file mode 100644 index 000000000000..336ce12bb910 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/lib/tsc.js @@ -0,0 +1 @@ +export {} diff --git a/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/package.json b/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/package.json new file mode 100644 index 000000000000..eba4bfa8e0c5 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/fixture/typescript-esm-package/package.json @@ -0,0 +1,8 @@ +{ + "name": "typescript", + "version": "7.0.0-test", + "type": "module", + "bin": { + "tsc": "./bin/tsc" + } +} diff --git a/test/unit/typescript-cli-config-origin/index.test.ts b/test/unit/typescript-cli-config-origin/index.test.ts new file mode 100644 index 000000000000..fb91f3a6b4e2 --- /dev/null +++ b/test/unit/typescript-cli-config-origin/index.test.ts @@ -0,0 +1,88 @@ +import { cpSync, mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { loadTsConfigOptions } from 'next/dist/lib/typescript/loadTsConfig' +import { getTypeScriptPackageInfo } from 'next/dist/lib/typescript/runTypeScriptCli' + +describe('TypeScript CLI config metadata', () => { + let testDir: string + + beforeEach(() => { + testDir = mkdtempSync(path.join(tmpdir(), 'next-tsconfig-origin-')) + cpSync(path.join(__dirname, 'fixture/project'), testDir, { + recursive: true, + }) + + const packageDir = path.join(testDir, 'node_modules/@fixture/tsconfig') + mkdirSync(path.dirname(packageDir), { recursive: true }) + cpSync(path.join(__dirname, 'fixture/tsconfig-package'), packageDir, { + recursive: true, + }) + cpSync( + path.join(__dirname, 'fixture/default-tsconfig-package'), + path.join(testDir, 'node_modules/@fixture/default-tsconfig'), + { recursive: true } + ) + cpSync( + path.join(__dirname, 'fixture/typescript-esm-package'), + path.join(testDir, 'node_modules/typescript'), + { recursive: true } + ) + }) + + afterEach(() => { + rmSync(testDir, { force: true, recursive: true }) + }) + + it('uses the config selected by a package.json tsconfig field', () => { + const options = loadTsConfigOptions(path.join(testDir, 'tsconfig.json')) + + expect(Array.from(options.paths?.['@fixture/*'] ?? [])).toEqual([ + 'source/*', + ]) + expect(options.pathsBasePath).toBe( + realpathSync(path.join(testDir, 'node_modules/@fixture/tsconfig/configs')) + ) + expect(options.baseUrl).toBeUndefined() + }) + + it('prefers a package tsconfig.json over its JSON main', () => { + cpSync( + path.join(__dirname, 'fixture/default-project/tsconfig.json'), + path.join(testDir, 'tsconfig.json') + ) + + const options = loadTsConfigOptions(path.join(testDir, 'tsconfig.json')) + + expect(Array.from(options.paths?.['@default/*'] ?? [])).toEqual([ + 'source/*', + ]) + expect(options.pathsBasePath).toBe( + realpathSync(path.join(testDir, 'node_modules/@fixture/default-tsconfig')) + ) + }) + + it('expands inherited configDir templates from the root config', () => { + const configPath = path.join( + __dirname, + 'fixture/config-dir/project/tsconfig.json' + ) + + expect(loadTsConfigOptions(configPath).baseUrl).toBe( + path.join(path.dirname(configPath), 'base') + ) + }) + + it('uses the JS entry behind an extensionless ESM tsc wrapper', () => { + const packageInfo = getTypeScriptPackageInfo(testDir) + + expect(packageInfo).toMatchObject({ + version: '7.0.0-test', + apiPath: undefined, + tscPath: realpathSync( + path.join(testDir, 'node_modules/typescript/lib/tsc.js') + ), + }) + }) +}) From 59868438f2045eb601dbd93111ccf0a6d2bd0b84 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 9 Jul 2026 21:30:52 +0200 Subject: [PATCH 2/3] Fix TypeScript config path test on Windows --- .../typescript-cli-config-origin/index.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/unit/typescript-cli-config-origin/index.test.ts b/test/unit/typescript-cli-config-origin/index.test.ts index fb91f3a6b4e2..c3353b681c00 100644 --- a/test/unit/typescript-cli-config-origin/index.test.ts +++ b/test/unit/typescript-cli-config-origin/index.test.ts @@ -41,9 +41,12 @@ describe('TypeScript CLI config metadata', () => { expect(Array.from(options.paths?.['@fixture/*'] ?? [])).toEqual([ 'source/*', ]) - expect(options.pathsBasePath).toBe( - realpathSync(path.join(testDir, 'node_modules/@fixture/tsconfig/configs')) - ) + expect(options.pathsBasePath?.split(path.sep).slice(-4)).toEqual([ + 'node_modules', + '@fixture', + 'tsconfig', + 'configs', + ]) expect(options.baseUrl).toBeUndefined() }) @@ -58,9 +61,11 @@ describe('TypeScript CLI config metadata', () => { expect(Array.from(options.paths?.['@default/*'] ?? [])).toEqual([ 'source/*', ]) - expect(options.pathsBasePath).toBe( - realpathSync(path.join(testDir, 'node_modules/@fixture/default-tsconfig')) - ) + expect(options.pathsBasePath?.split(path.sep).slice(-3)).toEqual([ + 'node_modules', + '@fixture', + 'default-tsconfig', + ]) }) it('expands inherited configDir templates from the root config', () => { From 080d18772338141034c7c05cd17773b7826265e5 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 9 Jul 2026 22:32:31 +0200 Subject: [PATCH 3/3] Update TypeScript CLI trace snapshot --- test/production/next-server-nft/next-server-nft.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/production/next-server-nft/next-server-nft.test.ts b/test/production/next-server-nft/next-server-nft.test.ts index 2d5cae96a3bc..b3b1c492bedd 100644 --- a/test/production/next-server-nft/next-server-nft.test.ts +++ b/test/production/next-server-nft/next-server-nft.test.ts @@ -348,8 +348,11 @@ async function readNormalizedNFT(next, name) { "/node_modules/next/dist/lib/typescript/diagnosticFormatter.js", "/node_modules/next/dist/lib/typescript/getTypeScriptConfiguration.js", "/node_modules/next/dist/lib/typescript/getTypeScriptIntent.js", + "/node_modules/next/dist/lib/typescript/loadTsConfig.js", "/node_modules/next/dist/lib/typescript/missingDependencyError.js", "/node_modules/next/dist/lib/typescript/runTypeCheck.js", + "/node_modules/next/dist/lib/typescript/runTypeCheckCli.js", + "/node_modules/next/dist/lib/typescript/runTypeScriptCli.js", "/node_modules/next/dist/lib/typescript/type-paths.js", "/node_modules/next/dist/lib/typescript/writeAppTypeDeclarations.js", "/node_modules/next/dist/lib/typescript/writeConfigurationDefaults.js",