Skip to content

Commit

Permalink
prefer .js over .ts in node_modules (#3019)
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jun 9, 2023
1 parent b7426bd commit 54ae996
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 26 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

There is now a way to try esbuild live on esbuild's website without installing it: https://esbuild.github.io/try/. In addition to being able to more easily evaluate esbuild, this should also make it more efficient to generate esbuild bug reports. For example, you can use it to compare the behavior of different versions of esbuild on the same input. The state of the page is stored in the URL for easy sharing. Many thanks to [@hyrious](https://github.com/hyrious) for creating https://hyrious.me/esbuild-repl/, which was the main inspiration for this addition to esbuild's website.

Two forms of build options are supported: either CLI-style ([example](https://esbuild.github.io/try/#dAAwLjE3LjE5AC0tbG9hZGVyPXRzeCAtLW1pbmlmeSAtLXNvdXJjZW1hcD1pbmxpbmUAbGV0IGVsOiBKU1guRWxlbWVudCA9IDxkaXYgLz4)) or JS-style ([example](https://esbuild.github.io/try/#dAAwLjE3LjE5AHsKICBsb2FkZXI6ICd0c3gnLAogIG1pbmlmeTogdHJ1ZSwKICBzb3VyY2VtYXA6ICdpbmxpbmUnLAp9AGxldCBlbDogSlNYLkVsZW1lbnQgPSA8ZGl2IC8+)). Both are converted into a JS object that's passed to esbuild's WebAssembly API. The CLI-style argument parser is a custom one that simulates shell quoting rules, and the JS-style argument parser is also custom and parses a superset of JSON (basically JSON5 + regular expressions). So argument parsing is an approximate simulation of what happens for real but hopefully it should be close enough.
Two forms of build options are supported: either CLI-style ([example](https://esbuild.github.io/try/#dAAwLjE3LjE5AC0tbG9hZGVyPXRzeCAtLW1pbmlmeSAtLXNvdXJjZW1hcABsZXQgZWw6IEpTWC5FbGVtZW50ID0gPGRpdiAvPg)) or JS-style ([example](https://esbuild.github.io/try/#dAAwLjE3LjE5AHsKICBsb2FkZXI6ICd0c3gnLAogIG1pbmlmeTogdHJ1ZSwKICBzb3VyY2VtYXA6IHRydWUsCn0AbGV0IGVsOiBKU1guRWxlbWVudCA9IDxkaXYgLz4)). Both are converted into a JS object that's passed to esbuild's WebAssembly API. The CLI-style argument parser is a custom one that simulates shell quoting rules, and the JS-style argument parser is also custom and parses a superset of JSON (basically JSON5 + regular expressions). So argument parsing is an approximate simulation of what happens for real but hopefully it should be close enough.

* Changes to esbuild's `tsconfig.json` support ([#3019](https://github.com/evanw/esbuild/issues/3019)):

Expand Down Expand Up @@ -105,6 +105,10 @@
Previously fields in `tsconfig.json` related to path resolution (e.g. `paths`) were respected for all files in the subtree containing that `tsconfig.json` file, even within a nested `node_modules` subdirectory. This meant that a project's `paths` settings could potentially affect any bundled packages. With this release, esbuild will no longer use `tsconfig.json` settings during path resolution inside nested `node_modules` subdirectories.

* Prefer `.js` over `.ts` within `node_modules` ([#3019](https://github.com/evanw/esbuild/issues/3019))

The default list of implicit extensions that esbuild will try appending to import paths contains `.ts` before `.js`. This makes it possible to bundle TypeScript projects that reference other files in the project using extension-less imports (e.g. `./some-file` to load `./some-file.ts` instead of `./some-file.js`). However, this behavior is undesirable within `node_modules` directories. Some package authors publish both their original TypeScript code and their compiled JavaScript code side-by-side. In these cases, esbuild should arguably be using the compiled JavaScript files instead of the original TypeScript files because the TypeScript compilation settings for files within the package should be determined by the package author, not the user of esbuild. So with this release, esbuild will now prefer implicit `.js` extensions over `.ts` when searching for import paths within `node_modules`.

These changes are intended to improve esbuild's compatibility with `tsc` and reduce the number of unfortunate behaviors regarding `tsconfig.json` and esbuild.
* Add a workaround for bugs in Safari 16.2 and earlier ([#3072](https://github.com/evanw/esbuild/issues/3072))
Expand Down
34 changes: 34 additions & 0 deletions internal/bundler_tests/bundler_ts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2436,3 +2436,37 @@ func TestTSEnumUseBeforeDeclare(t *testing.T) {
},
})
}

func TestTSPreferJSOverTSInsideNodeModules(t *testing.T) {
// We now prefer ".js" over ".ts" inside "node_modules"
ts_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/src/main.ts": `
// Implicit extensions
import './relative/path'
import 'package/path'
// Explicit extensions
import './relative2/path.js'
import 'package2/path.js'
`,

"/Users/user/project/src/relative/path.ts": `console.log('success')`,
"/Users/user/project/src/relative/path.js": `console.log('FAILURE')`,

"/Users/user/project/src/relative2/path.ts": `console.log('FAILURE')`,
"/Users/user/project/src/relative2/path.js": `console.log('success')`,

"/Users/user/project/node_modules/package/path.ts": `console.log('FAILURE')`,
"/Users/user/project/node_modules/package/path.js": `console.log('success')`,

"/Users/user/project/node_modules/package2/path.ts": `console.log('FAILURE')`,
"/Users/user/project/node_modules/package2/path.js": `console.log('success')`,
},
entryPaths: []string{"/Users/user/project/src/main.ts"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
},
})
}
15 changes: 15 additions & 0 deletions internal/bundler_tests/snapshots/snapshots_ts.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,21 @@ function foo(){let u;return(n=>(n[n.A=0]="A",n[n.B=1]="B",n[n.C=n]="C"))(u||(u={
---------- /b.js ----------
export function foo(){let e;return(n=>(n[n.X=0]="X",n[n.Y=1]="Y",n[n.Z=n]="Z"))(e||(e={})),e}

================================================================================
TestTSPreferJSOverTSInsideNodeModules
---------- /out/main.js ----------
// Users/user/project/src/relative/path.ts
console.log("success");

// Users/user/project/node_modules/package/path.js
console.log("success");

// Users/user/project/src/relative2/path.js
console.log("success");

// Users/user/project/node_modules/package2/path.js
console.log("success");

================================================================================
TestTSSiblingEnum
---------- /out/number.js ----------
Expand Down
71 changes: 46 additions & 25 deletions internal/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,22 @@ type Resolver struct {
// picture but it's better than some alternatives and probably pretty good.
atImportExtensionOrder []string

// A special sorted import order for imports inside packages.
//
// The "resolve extensions" setting determines the order of implicit
// extensions to try when resolving imports with the extension omitted.
// Sometimes people author a package using TypeScript and publish both the
// compiled JavaScript and the original TypeScript. The compiled JavaScript
// depends on the "tsconfig.json" settings that were passed to "tsc" when
// it was compiled, and we don't know what they are (they may even be
// unknowable if the "tsconfig.json" file wasn't published).
//
// To work around this, we sort TypeScript file extensions after JavaScript
// file extensions (but only within packages) so that esbuild doesn't load
// the original source code in these scenarios. Instead we should load the
// compiled code, which is what will be loaded by node at run-time.
nodeModulesExtensionOrder []string

// This cache maps a directory path to information about that directory and
// all parent directories
dirCache map[string]*dirInfo
Expand Down Expand Up @@ -226,10 +242,23 @@ func NewResolver(call config.APICall, fs fs.FS, log logger.Log, caches *cache.Ca
// Filter out non-CSS extensions for CSS "@import" imports
atImportExtensionOrder := make([]string, 0, len(options.ExtensionOrder))
for _, ext := range options.ExtensionOrder {
if loader, ok := options.ExtensionToLoader[ext]; ok && loader != config.LoaderCSS {
continue
if loader, ok := options.ExtensionToLoader[ext]; !ok || loader == config.LoaderCSS {
atImportExtensionOrder = append(atImportExtensionOrder, ext)
}
}

// Sort all JavaScript file extensions after TypeScript file extensions
// for imports of files inside of "node_modules" directories
nodeModulesExtensionOrder := make([]string, 0, len(options.ExtensionOrder))
for _, ext := range options.ExtensionOrder {
if loader, ok := options.ExtensionToLoader[ext]; !ok || !loader.IsTypeScript() {
nodeModulesExtensionOrder = append(nodeModulesExtensionOrder, ext)
}
}
for _, ext := range options.ExtensionOrder {
if loader, ok := options.ExtensionToLoader[ext]; ok && loader.IsTypeScript() {
nodeModulesExtensionOrder = append(nodeModulesExtensionOrder, ext)
}
atImportExtensionOrder = append(atImportExtensionOrder, ext)
}

// Generate the condition sets for interpreting the "exports" field
Expand All @@ -253,15 +282,16 @@ func NewResolver(call config.APICall, fs fs.FS, log logger.Log, caches *cache.Ca
fs.Cwd()

res := &Resolver{
fs: fs,
log: log,
options: *options,
caches: caches,
dirCache: make(map[string]*dirInfo),
atImportExtensionOrder: atImportExtensionOrder,
esmConditionsDefault: esmConditionsDefault,
esmConditionsImport: esmConditionsImport,
esmConditionsRequire: esmConditionsRequire,
fs: fs,
log: log,
options: *options,
caches: caches,
dirCache: make(map[string]*dirInfo),
atImportExtensionOrder: atImportExtensionOrder,
nodeModulesExtensionOrder: nodeModulesExtensionOrder,
esmConditionsDefault: esmConditionsDefault,
esmConditionsImport: esmConditionsImport,
esmConditionsRequire: esmConditionsRequire,
}

// Handle the "tsconfig.json" override when the resolver is created. This
Expand Down Expand Up @@ -1465,18 +1495,6 @@ var rewrittenFileExtensions = map[string][]string{
".cjs": {".cts"},
}

var tsExtensionsToRemove = map[string]bool{
".mjs": true,
".mts": true,
".cjs": true,
".cts": true,
".ts": true,
".js": true,
".tsx": true,
".jsx": true,
".json": true,
}

func (r resolverQuery) loadAsFile(path string, extensionOrder []string) (string, bool, *fs.DifferentCase) {
if r.debugLogs != nil {
r.debugLogs.addNote(fmt.Sprintf("Attempting to load %q as a file", path))
Expand Down Expand Up @@ -1680,10 +1698,13 @@ func getBool(json js_ast.Expr) (bool, bool) {
}

func (r resolverQuery) loadAsFileOrDirectory(path string) (PathPair, bool, *fs.DifferentCase) {
// Use a special import order for CSS "@import" imports
extensionOrder := r.options.ExtensionOrder
if r.kind == ast.ImportAt || r.kind == ast.ImportAtConditional {
// Use a special import order for CSS "@import" imports
extensionOrder = r.atImportExtensionOrder
} else if helpers.IsInsideNodeModules(path) {
// Use a special import order for imports inside "node_modules"
extensionOrder = r.nodeModulesExtensionOrder
}

// Is this a file?
Expand Down

0 comments on commit 54ae996

Please sign in to comment.