Skip to content

Proposal: compute module format based on package.json visible to declarationDir/outDir #54546

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

Closed
wants to merge 6 commits into from
Closed
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
4 changes: 4 additions & 0 deletions src/compiler/diagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -1659,6 +1659,10 @@
"category": "Message",
"code": 2212
},
"The project root is ambiguous, but is required to determine the module format of output '.js' files. Supply the `rootDir` compiler option to disambiguate.": {
"category": "Error",
"code": 2213
},

"Duplicate identifier '{0}'.": {
"category": "Error",
Expand Down
33 changes: 32 additions & 1 deletion src/compiler/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,21 @@ export function getOutputDeclarationFileName(inputFileName: string, configFile:
);
}

function getOutputJSFileName(inputFileName: string, configFile: ParsedCommandLine, ignoreCase: boolean, getCommonSourceDirectory?: () => string) {
/** @internal */
export function getOutputDeclarationFileNameWithoutConfigFile(inputFileName: string, options: CompilerOptions, ignoreCase: boolean, currentDirectory: string, getCommonSourceDirectory: () => string) {
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need separate functions for these. whats different. i couldnt make out from the function,
configFile used in the helper method only needs options and filenames (root file names) so we have that in program so we could change to Pick<ParsedCommandLine, "options" | "fileNames"> and that should work right. or am i missing something.

const directory = (options.declarationDir || options.outDir) ? getNormalizedAbsolutePath(options.declarationDir || options.outDir!, currentDirectory) : undefined;
const outputPathWithoutChangedExtension = directory
? resolvePath(directory, getRelativePathFromDirectory(getCommonSourceDirectory(), inputFileName, ignoreCase))
: inputFileName;
const outputFileName = changeExtension(
outputPathWithoutChangedExtension,
getDeclarationEmitExtensionForPath(inputFileName)
);
return outputFileName;
}

/** @internal */
export function getOutputJSFileName(inputFileName: string, configFile: ParsedCommandLine, ignoreCase: boolean, getCommonSourceDirectory?: () => string) {
if (configFile.options.emitDeclarationOnly) return undefined;
const isJsonFile = fileExtensionIs(inputFileName, Extension.Json);
const outputFileName = changeExtension(
Expand All @@ -608,6 +622,23 @@ function getOutputJSFileName(inputFileName: string, configFile: ParsedCommandLin
undefined;
}

/** @internal */
export function getOutputJSFileNameWithoutConfigFile(inputFileName: string, options: CompilerOptions, ignoreCase: boolean, currentDirectory: string, getCommonSourceDirectory: () => string) {
if (options.emitDeclarationOnly) return undefined;
const isJsonFile = fileExtensionIs(inputFileName, Extension.Json);
const outDir = options.outDir ? getNormalizedAbsolutePath(options.outDir, currentDirectory) : undefined;
const outputPathWithoutChangedExtension = outDir
? resolvePath(outDir, getRelativePathFromDirectory(getCommonSourceDirectory(), inputFileName, ignoreCase))
: inputFileName;
const outputFileName = changeExtension(
outputPathWithoutChangedExtension,
getOutputExtension(inputFileName, options)
);
return !isJsonFile || !options.configFilePath || comparePaths(inputFileName, outputFileName, options.configFilePath, ignoreCase) !== Comparison.EqualTo ?
outputFileName :
undefined;
}

function createAddOutput() {
let outputs: string[] | undefined;
return { addOutput, getOutputs };
Expand Down
77 changes: 73 additions & 4 deletions src/compiler/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ import {
getNormalizedAbsolutePathWithoutRoot,
getNormalizedPathComponents,
getOutputDeclarationFileName,
getOutputDeclarationFileNameWithoutConfigFile,
getOutputJSFileName,
getOutputJSFileNameWithoutConfigFile,
getOutputPathsForBundle,
getPackageScopeForPath,
getPathFromPathComponents,
Expand Down Expand Up @@ -1336,6 +1339,16 @@ export function getImpliedNodeFormatForFileWorker(
}
}

function moduleFormatNeedsPackageJsonLookup(fileName: string, options: CompilerOptions) {
switch (getEmitModuleResolutionKind(options)) {
case ModuleResolutionKind.Node16:
case ModuleResolutionKind.NodeNext:
return fileExtensionIsOneOf(fileName, [Extension.Dts, Extension.Ts, Extension.Tsx, Extension.Js, Extension.Jsx]);
default:
return false;
}
}

/** @internal */
export const plainJSErrors: Set<number> = new Set([
// binder errors
Expand Down Expand Up @@ -1491,6 +1504,7 @@ export function createProgram(rootNamesOrOptions: readonly string[] | CreateProg
let files: SourceFile[];
let symlinks: SymlinkCache | undefined;
let commonSourceDirectory: string;
let assumedCommonSourceDirectory: string | undefined;
let typeChecker: TypeChecker;
let classifiableNames: Set<__String>;
const ambientModuleNameToUnmodifiedFileName = new Map<string, string>();
Expand Down Expand Up @@ -2053,6 +2067,10 @@ export function createProgram(rootNamesOrOptions: readonly string[] | CreateProg
return commonSourceDirectory;
}

function getAssumedCommonSourceDirectory() {
return commonSourceDirectory ?? (assumedCommonSourceDirectory ??= ts_getCommonSourceDirectory(options, () => rootNames, host.getCurrentDirectory(), getCanonicalFileName));
}

function getClassifiableNames() {
if (!classifiableNames) {
// Initialize a checker so that all our files are bound.
Expand Down Expand Up @@ -2367,7 +2385,7 @@ export function createProgram(rootNamesOrOptions: readonly string[] | CreateProg
const seenPackageNames = new Map<string, SeenPackageName>();

for (const oldSourceFile of oldSourceFiles) {
const sourceFileOptions = getCreateSourceFileOptions(oldSourceFile.fileName, moduleResolutionCache, host, options);
const sourceFileOptions = getCreateSourceFileOptions(oldSourceFile.fileName);
let newSourceFile = host.getSourceFileByPath
? host.getSourceFileByPath(oldSourceFile.fileName, oldSourceFile.resolvedPath, sourceFileOptions, /*onError*/ undefined, shouldCreateNewSourceFile || sourceFileOptions.impliedNodeFormat !== oldSourceFile.impliedNodeFormat)
: host.getSourceFile(oldSourceFile.fileName, sourceFileOptions, /*onError*/ undefined, shouldCreateNewSourceFile || sourceFileOptions.impliedNodeFormat !== oldSourceFile.impliedNodeFormat); // TODO: GH#18217
Expand Down Expand Up @@ -3508,11 +3526,58 @@ export function createProgram(rootNamesOrOptions: readonly string[] | CreateProg
return result;
}

function getCreateSourceFileOptions(fileName: string, moduleResolutionCache: ModuleResolutionCache | undefined, host: CompilerHost, options: CompilerOptions): CreateSourceFileOptions {
function getImpliedNodeFormatForFile(fileName: string) {
Copy link
Member Author

Choose a reason for hiding this comment

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

I’m open to suggestions for a different name to differentiate from ts.getImpliedNodeFormatForFile which is public API, but I intend to expose this one as program.getImpliedNodeFormatForFile, and add JSDoc to the top-level standalone one that says you should use the program version if possible.

Copy link
Member

Choose a reason for hiding this comment

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

I think functionality needs to be moved under getImpliedNodeFormatForFileWorker with optional new methods that are required since we expose that method publicly and we want it to be same as what program does to figure out file's implied format.

// Map to an output file before looking up a package.json that might
// contain `"type": "module"`. This allows the same input `.ts` file to be
// compiled as both CommonJS and ESM depending on the tsconfig's `outDir`.
let fileNameForModuleFormatDetection = getNormalizedAbsolutePath(fileName, currentDirectory);
if (moduleFormatNeedsPackageJsonLookup(fileName, options)) {
const projectReference = getResolvedProjectReferenceToRedirect(fileName);
if (projectReference) {
// We have a source file that is an input to a referenced project.
// This should only happen with `useSourceOfProjectReferenceRedirect`
// enabled (i.e. TS Server scenarios).
Debug.assert(useSourceOfProjectReferenceRedirect);
fileNameForModuleFormatDetection =
getOutputDeclarationFileName(
fileNameForModuleFormatDetection,
projectReference.commandLine,
!host.useCaseSensitiveFileNames()) ||
getOutputJSFileName(
fileNameForModuleFormatDetection,
projectReference.commandLine,
!host.useCaseSensitiveFileNames()) ||
fileNameForModuleFormatDetection;
}
else {
fileNameForModuleFormatDetection =
getOutputDeclarationFileNameWithoutConfigFile(
fileNameForModuleFormatDetection,
options,
!host.useCaseSensitiveFileNames(),
currentDirectory,
getAssumedCommonSourceDirectory) ||
getOutputJSFileNameWithoutConfigFile(
fileNameForModuleFormatDetection,
options,
!host.useCaseSensitiveFileNames(),
currentDirectory,
getAssumedCommonSourceDirectory) ||
fileNameForModuleFormatDetection;
Comment on lines +3554 to +3566
Copy link
Member Author

Choose a reason for hiding this comment

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

I considered checking to see if a result computed from declarationDir disagrees with a result computed from outDir and issue an error, but that seems like such an edge case that it wouldn’t be worth spending time doing that for every source file that could be influenced by it. I decided to prefer declarationDir since that seems most likely to give a library author the same analysis that their users will see. But if someone tries really hard, they can make an invalid situation by using separate outDir and declarationDir with conflicting package.json files in each.

}
}
return getImpliedNodeFormatForFileWorker(
fileNameForModuleFormatDetection,
moduleResolutionCache?.getPackageJsonInfoCache(),
host,
options);
}

function getCreateSourceFileOptions(fileName: string): CreateSourceFileOptions {
// It's a _little odd_ that we can't set `impliedNodeFormat` until the program step - but it's the first and only time we have a resolution cache
// and a freshly made source file node on hand at the same time, and we need both to set the field. Persisting the resolution cache all the way
// to the check and emit steps would be bad - so we much prefer detecting and storing the format information on the source file node upfront.
const result = getImpliedNodeFormatForFileWorker(getNormalizedAbsolutePath(fileName, currentDirectory), moduleResolutionCache?.getPackageJsonInfoCache(), host, options);
const result = getImpliedNodeFormatForFile(fileName);
const languageVersion = getEmitScriptTarget(options);
const setExternalModuleIndicator = getSetExternalModuleIndicator(options);
return typeof result === "object" ?
Expand Down Expand Up @@ -3610,7 +3675,7 @@ export function createProgram(rootNamesOrOptions: readonly string[] | CreateProg
}

// We haven't looked for this file, do so now and cache result
const sourceFileOptions = getCreateSourceFileOptions(fileName, moduleResolutionCache, host, options);
const sourceFileOptions = getCreateSourceFileOptions(fileName);
const file = host.getSourceFile(
fileName,
sourceFileOptions,
Expand Down Expand Up @@ -4304,6 +4369,10 @@ export function createProgram(rootNamesOrOptions: readonly string[] | CreateProg
}
}

if (assumedCommonSourceDirectory && assumedCommonSourceDirectory !== getCommonSourceDirectory()) {
programDiagnostics.add(createCompilerDiagnostic(Diagnostics.The_project_root_is_ambiguous_but_is_required_to_determine_the_module_format_of_output_js_files_Supply_the_rootDir_compiler_option_to_disambiguate));
}

if (options.useDefineForClassFields && languageVersion === ScriptTarget.ES3) {
createDiagnosticForOptionName(Diagnostics.Option_0_cannot_be_specified_when_option_target_is_ES3, "useDefineForClassFields");
}
Expand Down
1 change: 1 addition & 0 deletions src/testRunner/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import "./unittests/tsbuild/lateBoundSymbol";
import "./unittests/tsbuild/libraryResolution";
import "./unittests/tsbuild/moduleResolution";
import "./unittests/tsbuild/moduleSpecifiers";
import "./unittests/tsbuild/nodeModulesOutDirPackageJson";
import "./unittests/tsbuild/noEmit";
import "./unittests/tsbuild/noEmitOnError";
import "./unittests/tsbuild/outFile";
Expand Down
33 changes: 33 additions & 0 deletions src/testRunner/unittests/tsbuild/nodeModulesOutDirPackageJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
verifyTsc,
} from "../helpers/tsc";
import { loadProjectFromFiles } from "../helpers/vfs";

describe("unittests:: tsbuild:: nodeModulesOutDirPackageJson", () => {
verifyTsc({
scenario: "nodeModulesOutDirPackageJson",
subScenario: "recognizes package.json in outDir of referenced project",
commandLineArgs: ["-b", "/src/tsconfig.json", "-v"],
fs: () => loadProjectFromFiles({
"/shared/tsconfig.json": JSON.stringify({
compilerOptions: {
target: "es5",
composite: true,
outDir: "dist"
}
}),
"/shared/red.ts": "export const red = 'fish';",
"/shared/dist/package.json": JSON.stringify({ type: "module" }),

"/src/tsconfig.json": JSON.stringify({
compilerOptions: {
target: "es5",
module: "nodenext",
noEmit: true,
},
references: [{ path: "../shared" }]
}),
"/src/index.ts": "import { red } from '../shared/red';",
}),
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/src/a.ts(1,19): error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './b.js'?


==== /tsconfig.json (0 errors) ====
{
"compilerOptions": {
"module": "nodenext",
"declaration": true,
"declarationDir": "out",
"emitDeclarationOnly": true,
"rootDir": "."
},
"include": ["src"]
}

==== /src/a.ts (1 errors) ====
import { b } from "./b";
~~~~~
!!! error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './b.js'?

==== /src/b.ts (0 errors) ====
export const b = 0;

==== /out/package.json (0 errors) ====
{ "type": "module" }

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//// [tests/cases/conformance/node/nodeModulesOutDirPackageJson05_declarationDir.ts] ////

//// [package.json]
{ "type": "module" }

//// [a.ts]
import { b } from "./b";

//// [b.ts]
export const b = 0;




//// [a.d.ts]
export {};
//// [b.d.ts]
export declare const b = 0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
=== /src/a.ts ===
import { b } from "./b";
>b : Symbol(b, Decl(a.ts, 0, 8))

=== /src/b.ts ===
export const b = 0;
>b : Symbol(b, Decl(b.ts, 0, 12))

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
=== /src/a.ts ===
import { b } from "./b";
>b : any

=== /src/b.ts ===
export const b = 0;
>b : 0
>0 : 0

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/src/a.ts(1,19): error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './b.js'?


==== /out/package.json (0 errors) ====
{ "type": "module" }

==== /src/a.ts (1 errors) ====
import { b } from "./b";
~~~~~
!!! error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './b.js'?

==== /src/b.ts (0 errors) ====
export const b = 0;

16 changes: 16 additions & 0 deletions tests/baselines/reference/nodeModulesOutDirPackageJson01.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//// [tests/cases/conformance/node/nodeModulesOutDirPackageJson01.ts] ////

//// [package.json]
{ "type": "module" }

//// [a.ts]
import { b } from "./b";

//// [b.ts]
export const b = 0;


//// [/out/a.js]
export {};
//// [/out/b.js]
export var b = 0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
=== /src/a.ts ===
import { b } from "./b";
>b : Symbol(b, Decl(a.ts, 0, 8))

=== /src/b.ts ===
export const b = 0;
>b : Symbol(b, Decl(b.ts, 0, 12))

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
=== /src/a.ts ===
import { b } from "./b";
>b : any

=== /src/b.ts ===
export const b = 0;
>b : 0
>0 : 0

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/src/a.ts(1,19): error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './b.js'?


==== /tsconfig.json (0 errors) ====
{
"compilerOptions": {
"module": "nodenext",
"outDir": "out"
}
}

==== /src/a.ts (1 errors) ====
import { b } from "./b";
~~~~~
!!! error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './b.js'?

==== /src/b.ts (0 errors) ====
export const b = 0;

==== /out/package.json (0 errors) ====
{ "type": "module" }

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//// [tests/cases/conformance/node/nodeModulesOutDirPackageJson02_tsconfig.ts] ////

//// [package.json]
{ "type": "module" }

//// [a.ts]
import { b } from "./b";

//// [b.ts]
export const b = 0;


//// [/out/a.js]
export {};
//// [/out/b.js]
export const b = 0;
Loading