Skip to content

[Experiment] Handle package.json watch in tsc --build #48889

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
130 changes: 77 additions & 53 deletions src/compiler/tsbuildPublic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ namespace ts {
readonly rootNames: readonly string[];
readonly baseWatchOptions: WatchOptions | undefined;

readonly resolvedConfigFilePaths: ESMap<string, ResolvedConfigFilePath>;
readonly resolvedConfigFilePaths: ESMap<ResolvedConfigFileName, ResolvedConfigFilePath>;
readonly resolvedPackageJsonPaths: ESMap<ResolvedConfigFileName, string>;
readonly configFileCache: ESMap<ResolvedConfigFilePath, ConfigFileCacheEntry>;
/** Map from config file name to up-to-date status */
readonly projectStatus: ESMap<ResolvedConfigFilePath, UpToDateStatus>;
Expand Down Expand Up @@ -270,13 +271,11 @@ namespace ts {
readonly allWatchedWildcardDirectories: ESMap<ResolvedConfigFilePath, ESMap<string, WildcardDirectoryWatcher>>;
readonly allWatchedInputFiles: ESMap<ResolvedConfigFilePath, ESMap<Path, FileWatcher>>;
readonly allWatchedConfigFiles: ESMap<ResolvedConfigFilePath, FileWatcher>;
readonly allWatchedExtendedConfigFiles: ESMap<Path, SharedExtendedConfigFileWatcher<ResolvedConfigFilePath>>;
readonly allWatchedPackageJsonFiles: ESMap<ResolvedConfigFilePath, ESMap<Path, FileWatcher>>;
readonly allWatchedExtendedConfigFiles: ESMap<Path, SharedFileWatcher<ResolvedConfigFilePath>>;
readonly allWatchedPackageJsonFiles: ESMap<Path, SharedFileWatcher<ResolvedConfigFilePath>>;
readonly filesWatched: ESMap<Path, FileWatcherWithModifiedTime | Date>;
readonly outputTimeStamps: ESMap<ResolvedConfigFilePath, ESMap<Path, Date>>;

readonly lastCachedPackageJsonLookups: ESMap<ResolvedConfigFilePath, readonly (readonly [Path, object | boolean])[] | undefined>;

timerToBuildInvalidatedProject: any;
reportFileChangeDetected: boolean;
writeLog: (s: string) => void;
Expand Down Expand Up @@ -327,6 +326,7 @@ namespace ts {
baseWatchOptions,

resolvedConfigFilePaths: new Map(),
resolvedPackageJsonPaths: new Map(),
configFileCache: new Map(),
projectStatus: new Map(),
extendedConfigCache: new Map(),
Expand Down Expand Up @@ -360,8 +360,6 @@ namespace ts {
allWatchedPackageJsonFiles: new Map(),
filesWatched: new Map(),

lastCachedPackageJsonLookups: new Map(),

timerToBuildInvalidatedProject: undefined,
reportFileChangeDetected: false,
watchFile,
Expand Down Expand Up @@ -478,6 +476,7 @@ namespace ts {

// Clear all to ResolvedConfigFilePaths cache to start fresh
state.resolvedConfigFilePaths.clear();
state.resolvedPackageJsonPaths.clear();

// TODO(rbuckton): Should be a `Set`, but that requires changing the code below that uses `mutateMapSkippingNewValues`
const currentProjects = new Map(
Expand All @@ -504,14 +503,7 @@ namespace ts {
{ onDeleteValue: closeFileWatcher }
);

state.allWatchedExtendedConfigFiles.forEach(watcher => {
watcher.projects.forEach(project => {
if (!currentProjects.has(project)) {
watcher.projects.delete(project);
}
});
watcher.close();
});
state.allWatchedExtendedConfigFiles.forEach(closeSharedWatcherOfUnknownProjects);

mutateMapSkippingNewValues(
state.allWatchedWildcardDirectories,
Expand All @@ -525,13 +517,18 @@ namespace ts {
{ onDeleteValue: existingMap => existingMap.forEach(closeFileWatcher) }
);

mutateMapSkippingNewValues(
state.allWatchedPackageJsonFiles,
currentProjects,
{ onDeleteValue: existingMap => existingMap.forEach(closeFileWatcher) }
);
state.allWatchedPackageJsonFiles.forEach(closeSharedWatcherOfUnknownProjects);
}
return state.buildOrder = buildOrder;

function closeSharedWatcherOfUnknownProjects(watcher: SharedFileWatcher<ResolvedConfigFilePath>) {
watcher.projects.forEach(project => {
if (!currentProjects.has(project)) {
watcher.projects.delete(project);
}
});
watcher.close();
}
}

function getBuildOrderFor(state: SolutionBuilderState, project: string | undefined, onlyReferences: boolean | undefined): AnyBuildOrder | undefined {
Expand Down Expand Up @@ -589,7 +586,7 @@ namespace ts {
function disableCache(state: SolutionBuilderState) {
if (!state.cache) return;

const { cache, host, compilerHost, extendedConfigCache, moduleResolutionCache, typeReferenceDirectiveResolutionCache } = state;
const { cache, host, compilerHost, extendedConfigCache, moduleResolutionCache, typeReferenceDirectiveResolutionCache, } = state;

host.readFile = cache.originalReadFile;
host.fileExists = cache.originalFileExists;
Expand Down Expand Up @@ -893,11 +890,6 @@ namespace ts {
config.projectReferences
);
if (state.watch) {
state.lastCachedPackageJsonLookups.set(projectPath, state.moduleResolutionCache && map(
state.moduleResolutionCache.getPackageJsonInfoCache().entries(),
([path, data]) => ([state.host.realpath && data ? toPath(state, state.host.realpath(path)) : path, data] as const)
));

state.builderPrograms.set(projectPath, program);
}
step++;
Expand Down Expand Up @@ -1241,10 +1233,9 @@ namespace ts {
config.fileNames = getFileNamesFromConfigSpecs(config.options.configFile!.configFileSpecs!, getDirectoryPath(project), config.options, state.parseConfigFileHost);
updateErrorForNoInputFiles(config.fileNames, project, config.options.configFile!.configFileSpecs!, config.errors, canJsonReportNoInputFiles(config.raw));
watchInputFiles(state, project, projectPath, config);
watchPackageJsonFiles(state, project, projectPath, config);
}

const status = getUpToDateStatus(state, config, projectPath);
const status = getUpToDateStatus(state, config, project, projectPath);
if (!options.force) {
if (status.type === UpToDateStatusType.UpToDate) {
verboseReportProjectStatus(state, project, status);
Expand Down Expand Up @@ -1507,7 +1498,7 @@ namespace ts {
}
}

function getUpToDateStatusWorker(state: SolutionBuilderState, project: ParsedCommandLine, resolvedPath: ResolvedConfigFilePath): UpToDateStatus {
function getUpToDateStatusWorker(state: SolutionBuilderState, project: ParsedCommandLine, resolved: ResolvedConfigFileName, resolvedPath: ResolvedConfigFilePath): UpToDateStatus {
// Container if no files are specified in the project
if (!project.fileNames.length && !canJsonReportNoInputFiles(project.raw)) {
return {
Expand All @@ -1524,7 +1515,7 @@ namespace ts {
const resolvedRef = resolveProjectReferencePath(ref);
const resolvedRefPath = toResolvedConfigFilePath(state, resolvedRef);
const resolvedConfig = parseConfigFile(state, resolvedRef, resolvedRefPath)!;
const refStatus = getUpToDateStatus(state, resolvedConfig, resolvedRefPath);
const refStatus = getUpToDateStatus(state, resolvedConfig, resolvedRef, resolvedRefPath);

// Its a circular reference ignore the status of this project
if (refStatus.type === UpToDateStatusType.ComputingUpstream ||
Expand Down Expand Up @@ -1739,13 +1730,16 @@ namespace ts {
if (configStatus) return configStatus;

// Check extended config time
const extendedConfigStatus = forEach(project.options.configFile!.extendedSourceFiles || emptyArray, configFile => checkConfigFileUpToDateStatus(state, configFile, oldestOutputFileTime, oldestOutputFileName!));
const extendedConfigStatus = forEach(
project.options.configFile!.extendedSourceFiles || emptyArray,
configFile => checkConfigFileUpToDateStatus(state, configFile, oldestOutputFileTime, oldestOutputFileName!)
);
if (extendedConfigStatus) return extendedConfigStatus;

// Check package file time
const dependentPackageFileStatus = forEach(
state.lastCachedPackageJsonLookups.get(resolvedPath) || emptyArray,
([path]) => checkConfigFileUpToDateStatus(state, path, oldestOutputFileTime, oldestOutputFileName!)
getPackageJsonsFromConfig(state, resolved, project),
file => checkConfigFileUpToDateStatus(state, file, oldestOutputFileTime, oldestOutputFileName!)
);
if (dependentPackageFileStatus) return dependentPackageFileStatus;

Expand Down Expand Up @@ -1789,7 +1783,7 @@ namespace ts {
return false;
}

function getUpToDateStatus(state: SolutionBuilderState, project: ParsedCommandLine | undefined, resolvedPath: ResolvedConfigFilePath): UpToDateStatus {
function getUpToDateStatus(state: SolutionBuilderState, project: ParsedCommandLine | undefined, resolved: ResolvedConfigFileName, resolvedPath: ResolvedConfigFilePath): UpToDateStatus {
if (project === undefined) {
return { type: UpToDateStatusType.Unbuildable, reason: "File deleted mid-build" };
}
Expand All @@ -1799,7 +1793,7 @@ namespace ts {
return prior;
}

const actual = getUpToDateStatusWorker(state, project, resolvedPath);
const actual = getUpToDateStatusWorker(state, project, resolved, resolvedPath);
state.projectStatus.set(resolvedPath, actual);
return actual;
}
Expand Down Expand Up @@ -2038,6 +2032,12 @@ namespace ts {
scheduleBuildInvalidatedProject(state, 250, /*changeDetected*/ true);
}

function invalidateProjectAndScheduledBuildsOfSharedFileWacher(state: SolutionBuilderState, sharedWatcher: SharedFileWatcher<ResolvedConfigFilePath> | undefined, reloadLevel: ConfigFileProgramReloadLevel) {
sharedWatcher?.projects.forEach(projectConfigFilePath =>
invalidateProjectAndScheduleBuilds(state, projectConfigFilePath, reloadLevel)
);
}

function scheduleBuildInvalidatedProject(state: SolutionBuilderState, time: number, changeDetected: boolean) {
const { hostWithWatch } = state;
if (!hostWithWatch.setTimeout || !hostWithWatch.clearTimeout) {
Expand Down Expand Up @@ -2103,8 +2103,11 @@ namespace ts {
(extendedConfigFileName, extendedConfigFilePath) => watchFile(
state,
extendedConfigFileName,
() => state.allWatchedExtendedConfigFiles.get(extendedConfigFilePath)?.projects.forEach(projectConfigFilePath =>
invalidateProjectAndScheduleBuilds(state, projectConfigFilePath, ConfigFileProgramReloadLevel.Full)),
() => invalidateProjectAndScheduledBuildsOfSharedFileWacher(
state,
state.allWatchedExtendedConfigFiles.get(extendedConfigFilePath),
ConfigFileProgramReloadLevel.Full
),
PollingInterval.High,
parsed?.watchOptions,
WatchType.ExtendedConfigFile,
Expand Down Expand Up @@ -2164,23 +2167,44 @@ namespace ts {
);
}

function getPackageJsonsFromConfig(state: SolutionBuilderState, resolved: ResolvedConfigFileName, parsed: ParsedCommandLine): string[] {
const result: string[] = [getPackageJsonPathFromConfig(state, resolved)];
parsed.projectReferences?.forEach(ref => result.push(getPackageJsonPathFromConfig(state, resolveProjectName(state, ref.path))));
return result;
}

function getPackageJsonPathFromConfig(state: SolutionBuilderState, resolved: ResolvedConfigFileName) {
const { resolvedPackageJsonPaths } = state;
const path = resolvedPackageJsonPaths.get(resolved);
if (path !== undefined) return path;

const packageJsonPath = combinePaths(getDirectoryPath(resolved), "package.json");
resolvedPackageJsonPaths.set(resolved, packageJsonPath);
return packageJsonPath;
}

function watchPackageJsonFiles(state: SolutionBuilderState, resolved: ResolvedConfigFileName, resolvedPath: ResolvedConfigFilePath, parsed: ParsedCommandLine) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please specify the return type for this function?

if (!state.watch || !state.lastCachedPackageJsonLookups) return;
mutateMap(
getOrCreateValueMapFromConfigFileMap(state.allWatchedPackageJsonFiles, resolvedPath),
new Map(state.lastCachedPackageJsonLookups.get(resolvedPath)),
{
createNewValue: (path, _input) => watchFile(
if (!state.watch) return;
// Container if no files are specified in the project, nothing to watch
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚙️

Suggested change
// Container if no files are specified in the project, nothing to watch
// If no files are specified in the project, there is nothing to watch in the container.

if (!parsed.fileNames.length && !canJsonReportNoInputFiles(parsed.raw)) return;
updateSharedFileWatcher(
resolvedPath,
getPackageJsonsFromConfig(state, resolved, parsed),
state.allWatchedPackageJsonFiles,
(file, path) => watchFile(
state,
file,
() => invalidateProjectAndScheduledBuildsOfSharedFileWacher(
state,
path,
() => invalidateProjectAndScheduleBuilds(state, resolvedPath, ConfigFileProgramReloadLevel.None),
PollingInterval.High,
parsed?.watchOptions,
WatchType.PackageJson,
resolved
state.allWatchedPackageJsonFiles.get(path),
ConfigFileProgramReloadLevel.None,
),
onDeleteValue: closeFileWatcher,
}
PollingInterval.High,
parsed?.watchOptions,
WatchType.PackageJson,
resolved
),
fileName => toPath(state, fileName),
);
}

Expand Down Expand Up @@ -2211,7 +2235,7 @@ namespace ts {
clearMap(state.allWatchedExtendedConfigFiles, closeFileWatcherOf);
clearMap(state.allWatchedWildcardDirectories, watchedWildcardDirectories => clearMap(watchedWildcardDirectories, closeFileWatcherOf));
clearMap(state.allWatchedInputFiles, watchedWildcardDirectories => clearMap(watchedWildcardDirectories, closeFileWatcher));
clearMap(state.allWatchedPackageJsonFiles, watchedPacageJsonFiles => clearMap(watchedPacageJsonFiles, closeFileWatcher));
clearMap(state.allWatchedPackageJsonFiles, closeFileWatcherOf);
}

/**
Expand All @@ -2235,7 +2259,7 @@ namespace ts {
getUpToDateStatusOfProject: project => {
const configFileName = resolveProjectName(state, project);
const configFilePath = toResolvedConfigFilePath(state, configFileName);
return getUpToDateStatus(state, parseConfigFile(state, configFileName, configFilePath), configFilePath);
return getUpToDateStatus(state, parseConfigFile(state, configFileName, configFilePath), configFileName, configFilePath);
},
invalidateProject: (configFilePath, reloadLevel) => invalidateProject(state, configFilePath, reloadLevel || ConfigFileProgramReloadLevel.None),
close: () => stopWatching(state),
Expand Down
2 changes: 1 addition & 1 deletion src/compiler/watchPublic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ namespace ts {
let timerToUpdateProgram: any; // timer callback to recompile the program
let timerToInvalidateFailedLookupResolutions: any; // timer callback to invalidate resolutions for changes in failed lookup locations
let parsedConfigs: ESMap<Path, ParsedConfig> | undefined; // Parsed commandline and watching cached for referenced projects
let sharedExtendedConfigFileWatchers: ESMap<Path, SharedExtendedConfigFileWatcher<Path>>; // Map of file watchers for extended files, shared between different referenced projects
let sharedExtendedConfigFileWatchers: ESMap<Path, SharedFileWatcher<Path>>; // Map of file watchers for extended files, shared between different referenced projects
let extendedConfigCache = host.extendedConfigCache; // Cache for extended config evaluation
let changesAffectResolution = false; // Flag for indicating non-config changes affect module resolution
let reportFileChangeDetectedOnCreateProgram = false; // True if synchronizeProgram should report "File change detected..." when a new program is created
Expand Down
Loading