Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions src/builder/bundless/dts/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +11,17 @@ import { getContentHash } from '../../utils';

const execFileAsync = promisify(execFile);

const tsconfigCache = new AsyncLocalStorage<{
paths: Map<string, string | undefined>;
configs: Map<string, import('typescript').ParsedCommandLine>;
}>();

// 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<T>(callback: () => T): T {
return tsconfigCache.run({ paths: new Map(), configs: new Map() }, callback);
}

type ParsedTsconfig = ReturnType<typeof getTsconfig>;
type DeclarationOutput = {
file: string;
Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/builder/bundless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -97,14 +97,21 @@ function replacePathExt(filePath: string, ext: string) {
/**
* transform specific files
*/
async function transformFiles(
function transformFiles(
files: string[],
opts: {
cwd: string;
configProvider: BundlessConfigProvider;
watch?: true;
incremental?: boolean;
},
): Promise<number> {
return withTsconfigCache(() => runTransformFiles(files, opts));
}

async function runTransformFiles(
files: string[],
opts: Parameters<typeof transformFiles>[1],
) {
try {
let count = 0;
Expand Down
10 changes: 9 additions & 1 deletion tests/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
ret.once('ready', resolve);
ret.once('error', reject);
}),
);

return ret;
},
Expand Down Expand Up @@ -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;
Expand Down
223 changes: 223 additions & 0 deletions tests/tsconfig.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<(typeof ENV_KEYS)[number], string>>;

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<void>((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);
});
});
Loading