diff --git a/src/builder/bundless/dts/index.ts b/src/builder/bundless/dts/index.ts index 044fd7c3..de727be9 100644 --- a/src/builder/bundless/dts/index.ts +++ b/src/builder/bundless/dts/index.ts @@ -1,4 +1,5 @@ import { chalk, fsExtra, semver, winPath } from '@umijs/utils'; +import { AsyncLocalStorage } from 'async_hooks'; import { execFile } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -10,6 +11,17 @@ import { getContentHash } from '../../utils'; const execFileAsync = promisify(execFile); +const tsconfigCache = new AsyncLocalStorage<{ + paths: Map; + configs: Map; +}>(); + +// Limit reuse to a file batch so watch rebuilds also pick up changes to +// extended configs and the files matched by include/exclude. +export function withTsconfigCache(callback: () => T): T { + return tsconfigCache.run({ paths: new Map(), configs: new Map() }, callback); +} + type ParsedTsconfig = ReturnType; type DeclarationOutput = { file: string; @@ -21,20 +33,31 @@ type DeclarationOutput = { * get tsconfig.json path for specific path */ export function getTsconfigPath(cwd: string) { + const cache = tsconfigCache.getStore()?.paths; + const cacheKey = JSON.stringify([ + path.resolve(cwd), + process.env.FATHER_TSCONFIG_NAME, + ]); + if (cache?.has(cacheKey)) return cache.get(cacheKey); + // use require() rather than import(), to avoid jest runner to fail // ref: https://github.com/nodejs/node/issues/35889 const ts: typeof import('typescript') = require('typescript'); - return ts.findConfigFile( + const configPath = ts.findConfigFile( cwd, ts.sys.fileExists, process.env.FATHER_TSCONFIG_NAME, ); + cache?.set(cacheKey, configPath); + return configPath; } /** * get parsed tsconfig.json for specific path * ref: https://github.com/privatenumber/get-tsconfig#how-can-i-use-typescript-to-parse-tsconfigjson + * NOTE: the result is shared by all callers within a `withTsconfigCache` batch, + * copy it before making any changes */ export function getTsconfig(cwd: string) { // use require() rather than import(), to avoid jest runner to fail @@ -43,8 +66,13 @@ export function getTsconfig(cwd: string) { const tsconfigPath = getTsconfigPath(cwd); if (tsconfigPath) { + const cache = tsconfigCache.getStore()?.configs; + const cacheKey = path.resolve(tsconfigPath); + const cached = cache?.get(cacheKey); + if (cached) return cached; + const tsconfigFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); - return ts.parseJsonConfigFileContent( + const config = ts.parseJsonConfigFileContent( tsconfigFile.config, ts.sys, path.dirname(tsconfigPath), @@ -53,6 +81,8 @@ export function getTsconfig(cwd: string) { // ref: https://github.com/microsoft/TypeScript/blob/0464e91c8b67579a4ed840e5783575a493c958e0/src/compiler/program.ts#L2325 tsconfigPath, ); + cache?.set(cacheKey, config); + return config; } } @@ -482,7 +512,13 @@ export default async function getDeclarations( // use require() rather than import(), to avoid jest runner to fail // ref: https://github.com/nodejs/node/issues/35889 const ts: typeof import('typescript') = require('typescript'); - const tsconfig = getTsconfig(opts.cwd); + const parsedTsconfig = getTsconfig(opts.cwd); + // Declaration generation adjusts compiler options for its own emit. Keep + // those changes out of the configuration shared with loaders in this batch. + const tsconfig = parsedTsconfig && { + ...parsedTsconfig, + options: { ...parsedTsconfig.options }, + }; if (tsconfig) { // check tsconfig error diff --git a/src/builder/bundless/index.ts b/src/builder/bundless/index.ts index d4a86abb..60d85267 100644 --- a/src/builder/bundless/index.ts +++ b/src/builder/bundless/index.ts @@ -16,7 +16,7 @@ import { } from '../../constants'; import { logger } from '../../utils'; import type { BundlessConfigProvider } from '../config'; -import getDeclarations from './dts'; +import getDeclarations, { withTsconfigCache } from './dts'; import type { IDeclarationResult, ILoaderArgs } from './loaders'; import runLoaders from './loaders'; import { IJSTransformer, IJSTransformerFn } from './loaders/types'; @@ -97,7 +97,7 @@ function replacePathExt(filePath: string, ext: string) { /** * transform specific files */ -async function transformFiles( +function transformFiles( files: string[], opts: { cwd: string; @@ -105,6 +105,13 @@ async function transformFiles( watch?: true; incremental?: boolean; }, +): Promise { + return withTsconfigCache(() => runTransformFiles(files, opts)); +} + +async function runTransformFiles( + files: string[], + opts: Parameters[1], ) { try { let count = 0; diff --git a/tests/dev.test.ts b/tests/dev.test.ts index 049d35dd..dfe2c3f7 100644 --- a/tests/dev.test.ts +++ b/tests/dev.test.ts @@ -18,6 +18,7 @@ const wait = ( // use global for mock scope global.TMP_WATCHERS = []; +global.TMP_WATCHER_READY = []; global.TMP_CASE_CONFIG = CASE_CONFIG; jest.mock('../src/builder/bundle/index.ts', () => { @@ -49,6 +50,12 @@ jest.mock('@umijs/utils', () => { const ret = originalModule.chokidar.watch(...args); global.TMP_WATCHERS.push(ret); + global.TMP_WATCHER_READY.push( + new Promise((resolve, reject) => { + ret.once('ready', resolve); + ret.once('error', reject); + }), + ); return ret; }, @@ -92,13 +99,14 @@ beforeAll(async () => { }); // await chokidar ready - await wait(100); + await Promise.all(global.TMP_WATCHER_READY); }); afterAll(async () => { // close watchers await Promise.all(global.TMP_WATCHERS.map((watcher) => watcher.close())); delete global.TMP_WATCHERS; + delete global.TMP_WATCHER_READY; delete global.TMP_CASE_CONFIG; delete global.TMP_TRANSFORMER; delete process.env.APP_ROOT; diff --git a/tests/tsconfig.test.ts b/tests/tsconfig.test.ts new file mode 100644 index 00000000..da61e2e7 --- /dev/null +++ b/tests/tsconfig.test.ts @@ -0,0 +1,223 @@ +import { winPath } from '@umijs/utils'; +import fs from 'fs'; +import path from 'path'; +import ts from 'typescript'; +import getDeclarations, { + getTsconfig, + withTsconfigCache, +} from '../src/builder/bundless/dts'; + +const ENV_KEYS = [ + 'FATHER_TSCONFIG_NAME', + 'FATHER_CACHE', + 'FATHER_CACHE_DIR', + 'NODE_ENV', +] as const; + +let cwd: string; +let originalEnv: Partial>; + +function writeFile(file: string, content: string) { + const filename = path.join(cwd, file); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, content); + return winPath(filename); +} + +function writeConfig(config: object, filename = 'tsconfig.json') { + return writeFile(filename, JSON.stringify(config)); +} + +beforeEach(() => { + const fixtureParent = path.join(process.cwd(), 'node_modules', '.cache'); + fs.mkdirSync(fixtureParent, { recursive: true }); + cwd = fs.mkdtempSync(path.join(fixtureParent, 'father-tsconfig-')); + originalEnv = {}; + ENV_KEYS.forEach((key) => { + originalEnv[key] = process.env[key]; + }); + delete process.env.FATHER_TSCONFIG_NAME; +}); + +afterEach(() => { + jest.restoreAllMocks(); + ENV_KEYS.forEach((key) => { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + }); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test('scans a shared tsconfig only once for files in different directories', () => { + const first = writeFile('src/first/index.ts', 'export const first = 1;'); + const second = writeFile('src/second/index.ts', 'export const second = 2;'); + writeConfig({ compilerOptions: { declaration: true }, include: ['src'] }); + const readDirectory = jest.spyOn(ts.sys, 'readDirectory'); + + withTsconfigCache(() => { + for (const directory of [cwd, path.dirname(first), path.dirname(second)]) { + const config = getTsconfig(directory)!; + expect(config.options.declaration).toBe(true); + expect(config.fileNames).toEqual(expect.arrayContaining([first, second])); + } + }); + + expect(readDirectory).toHaveBeenCalledTimes(1); +}); + +test('keeps the nearest tsconfig for nested source directories', () => { + const outer = writeFile('src/index.ts', 'export const outer = 1;'); + const inner = writeFile('src/nested/index.ts', 'export const inner = 2;'); + writeConfig({ compilerOptions: { jsx: 'react-jsx' }, include: ['src'] }); + writeConfig( + { compilerOptions: { jsx: 'react' }, include: ['*.ts'] }, + 'src/nested/tsconfig.json', + ); + + withTsconfigCache(() => { + expect(getTsconfig(path.dirname(outer))!.options.jsx).toBe( + ts.JsxEmit.ReactJSX, + ); + const nestedConfig = getTsconfig(path.dirname(inner))!; + expect(nestedConfig.options.jsx).toBe(ts.JsxEmit.React); + expect(nestedConfig.fileNames).toEqual([inner]); + expect(getTsconfig(cwd)!.options.jsx).toBe(ts.JsxEmit.ReactJSX); + }); +}); + +test('reads current configuration outside a build scope', () => { + writeFile('src/index.ts', 'export const value = 1;'); + writeConfig({ compilerOptions: { strict: false }, include: ['src'] }); + expect(getTsconfig(cwd)!.options.strict).toBe(false); + + writeConfig({ compilerOptions: { strict: true }, include: ['src'] }); + expect(getTsconfig(cwd)!.options.strict).toBe(true); +}); + +test('refreshes extended configuration and included files for the next batch', () => { + const first = writeFile('src/first.ts', 'export const first = 1;'); + writeConfig({ compilerOptions: { strict: false } }, 'tsconfig.base.json'); + writeConfig({ extends: './tsconfig.base.json', include: ['src'] }); + + withTsconfigCache(() => { + expect(getTsconfig(cwd)!.options.strict).toBe(false); + expect(getTsconfig(cwd)!.fileNames).toEqual([first]); + }); + + writeConfig({ compilerOptions: { strict: true } }, 'tsconfig.base.json'); + const second = writeFile('src/second.ts', 'export const second = 2;'); + + withTsconfigCache(() => { + const config = getTsconfig(cwd)!; + expect(config.options.strict).toBe(true); + expect(config.fileNames).toEqual(expect.arrayContaining([first, second])); + expect(config.fileNames).toHaveLength(2); + }); + + writeConfig({ extends: './tsconfig.base.json', files: ['src/second.ts'] }); + withTsconfigCache(() => { + expect(getTsconfig(cwd)!.fileNames).toEqual([second]); + }); +}); + +test('respects custom config names when they change within a scope', () => { + writeFile('src/index.ts', 'export const value = 1;'); + writeConfig({ compilerOptions: { strict: false }, include: ['src'] }); + writeConfig( + { compilerOptions: { strict: true }, include: ['src'] }, + 'tsconfig.custom.json', + ); + + withTsconfigCache(() => { + expect(getTsconfig(cwd)!.options.strict).toBe(false); + process.env.FATHER_TSCONFIG_NAME = 'tsconfig.custom.json'; + expect(getTsconfig(cwd)!.options.strict).toBe(true); + delete process.env.FATHER_TSCONFIG_NAME; + expect(getTsconfig(cwd)!.options.strict).toBe(false); + }); +}); + +test('does not keep missing configurations across batches', () => { + const filename = `${path.basename(cwd)}.json`; + process.env.FATHER_TSCONFIG_NAME = filename; + writeFile('src/index.ts', 'export const value = 1;'); + withTsconfigCache(() => { + expect(getTsconfig(cwd)).toBeUndefined(); + expect(getTsconfig(cwd)).toBeUndefined(); + }); + + writeConfig( + { compilerOptions: { strict: true }, include: ['src'] }, + filename, + ); + withTsconfigCache(() => { + expect(getTsconfig(cwd)!.options.strict).toBe(true); + }); +}); + +test('keeps overlapping asynchronous build scopes independent', async () => { + writeFile('src/index.ts', 'export const value = 1;'); + writeConfig({ compilerOptions: { strict: false }, include: ['src'] }); + let releaseFirst!: () => void; + const gate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const firstBuild = withTsconfigCache(async () => { + expect(getTsconfig(cwd)!.options.strict).toBe(false); + await gate; + expect(getTsconfig(cwd)!.options.strict).toBe(false); + }); + + writeConfig({ compilerOptions: { strict: true }, include: ['src'] }); + try { + await withTsconfigCache(async () => { + expect(getTsconfig(cwd)!.options.strict).toBe(true); + await Promise.resolve(); + expect(getTsconfig(cwd)!.options.strict).toBe(true); + }); + } finally { + releaseFirst(); + await firstBuild; + } +}); + +test('declaration generation does not change cached compiler options', async () => { + const sourceFile = writeFile('src/index.ts', 'export const value = 1;'); + writeConfig({ + compilerOptions: { + declaration: true, + target: 'ES2019', + types: [], + skipLibCheck: true, + }, + include: ['src'], + }); + process.env.NODE_ENV = 'development'; + delete process.env.FATHER_CACHE; + // Both process-relative and fixture-relative cache paths stay inside the + // fixture. A relative path also avoids joining two Windows drive paths. + process.env.FATHER_CACHE_DIR = path.relative( + process.cwd(), + path.join(cwd, 'cache'), + ); + + await withTsconfigCache(async () => { + const before = { ...getTsconfig(cwd)!.options }; + const declarations = await getDeclarations([sourceFile], { cwd }); + + expect(declarations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: 'index.d.ts', + content: expect.stringContaining('export declare const value = 1;'), + }), + expect.objectContaining({ file: 'index.d.ts.map' }), + ]), + ); + expect(getTsconfig(cwd)!.options).toEqual(before); + }); +});