Skip to content

Commit

Permalink
more adjustments to import assertions/attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Nov 21, 2023
1 parent 2dad830 commit 2886b5d
Show file tree
Hide file tree
Showing 11 changed files with 168 additions and 54 deletions.
69 changes: 67 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,71 @@

## Unreleased

* Add support for bundling code that uses import attributes ([#3384](https://github.com/evanw/esbuild/issues/3384))

JavaScript is gaining new syntax for associating a map of string key-value pairs with individual ESM imports. The proposal is still a work in progress and is still undergoing significant changes before being finalized. However, the first iteration has already been shipping in Chromium-based browsers for a while, and the second iteration has landed in V8 and is now shipping in node, so it makes sense for esbuild to support it. Here are the two major iterations of this proposal (so far):

1. Import assertions (deprecated, will not be standardized)
* Uses the `assert` keyword
* Does _not_ affect module resolution
* Causes an error if the assertion fails
* Shipping in Chrome 91+ (and in esbuild 0.11.22+)

2. Import attributes (currently set to become standardized)
* Uses the `with` keyword
* Affects module resolution
* Unknown attributes cause an error
* Shipping in node 21+

You can already use esbuild to bundle code that uses import assertions (the first iteration). However, this feature is mostly useless for bundlers because import assertions are not allowed to affect module resolution. It's basically only useful as an annotation on external imports, which esbuild will then preserve in the output for use in a browser (which would otherwise refuse to load certain imports).

With this release, esbuild now supports bundling code that uses import attributes (the second iteration). This is much more useful for bundlers because they are allowed to affect module resolution, which means the key-value pairs can be provided to plugins. Here's an example, which uses esbuild's built-in support for the upcoming [JSON module standard](https://github.com/tc39/proposal-json-modules):

```js
// On static imports
import foo from './package.json' with { type: 'json' }
console.log(foo)

// On dynamic imports
const bar = await import('./package.json', { with: { type: 'json' } })
console.log(bar)
```

One important consequence of the change in semantics between import assertions and import attributes is that two imports with identical paths but different import attributes are now considered to be different modules. This is because the import attributes are provided to the loader, which might then use those attributes during loading. For example, you could imagine an image loader that produces an image of a different size depending on the import attributes.

Import attributes are now reported in the [metafile](https://esbuild.github.io/api/#metafile) and are now provided to [on-load plugins](https://esbuild.github.io/plugins/#on-load) as a map in the `with` property. For example, here's an esbuild plugin that turns all imports with a `type` import attribute equal to `'cheese'` into a module that exports the cheese emoji:

```js
const cheesePlugin = {
name: 'cheese',
setup(build) {
build.onLoad({ filter: /.*/ }, args => {
if (args.with.type === 'cheese') return {
contents: `export default "🧀"`,
}
})
}
}

require('esbuild').build({
bundle: true,
write: false,
stdin: {
contents: `
import foo from 'data:text/javascript,' with { type: 'cheese' }
console.log(foo)
`,
},
plugins: [cheesePlugin],
}).then(result => {
const code = new Function(result.outputFiles[0].text)
code()
})
```

> [!Warning]
> It's possible that the second iteration of this feature may change significantly again even though it's already shipping in real JavaScript VMs (since it has already happened once before). In that case, esbuild may end up adjusting its implementation to match the eventual standard behavior. So keep in mind that by using this, you are using an unstable upcoming JavaScript feature that may undergo breaking changes in the future.
* Adjust TypeScript experimental decorator behavior ([#3230](https://github.com/evanw/esbuild/issues/3230), [#3326](https://github.com/evanw/esbuild/issues/3326), [#3394](https://github.com/evanw/esbuild/issues/3394))

With this release, esbuild will now allow TypeScript experimental decorators to access both static class properties and `#private` class names. For example:
Expand All @@ -27,11 +92,11 @@

Note that TypeScript's experimental decorator support is currently buggy: TypeScript's compiler passes this test if only the first `@check` is present or if only the second `@check` is present, but TypeScript's compiler fails this test if both checks are present together. I haven't changed esbuild to match TypeScript's behavior exactly here because I'm waiting for TypeScript to fix these bugs instead.

Some background: TypeScript experimental decorators don't have consistent semantics regarding the context that the decorators are evaluated in. For example, TypeScript will let you use `await` within a decorator, which implies that the decorator runs outside the class (since `await` isn't supported inside a class body), but TypeScript will also let you use `#private` names, which implies that the decorator runs inside the class body (since `#private` names are only supported inside a class body). The value of `this` in a decorator is also buggy (the run-time value of `this` changes if any decorator in the class uses a `#private` name but the type of `this` doesn't change, leading to the type checker no longer matching reality). These inconsistent semantics make it hard for esbuild to implement this feature as decorator evaluation happens in some superposition of both inside and outside the class body that is particular to the internal implementation details of the TypeScript compiler.
Some background: TypeScript experimental decorators don't have consistent semantics regarding the context that the decorators are evaluated in. For example, TypeScript will let you use `await` within a decorator, which implies that the decorator runs outside the class body (since `await` isn't supported inside a class body), but TypeScript will also let you use `#private` names, which implies that the decorator runs inside the class body (since `#private` names are only supported inside a class body). The value of `this` in a decorator is also buggy (the run-time value of `this` changes if any decorator in the class uses a `#private` name but the type of `this` doesn't change, leading to the type checker no longer matching reality). These inconsistent semantics make it hard for esbuild to implement this feature as decorator evaluation happens in some superposition of both inside and outside the class body that is particular to the internal implementation details of the TypeScript compiler.

* Forbid `--keep-names` when targeting old browsers ([#3477](https://github.com/evanw/esbuild/issues/3477))

The `--keep-names` setting needs to be able to assign to the `name` property on functions and classes. However, before ES6 this property was non-configurable, and attempting to assign to it would throw an error. So with this release, esbuild will no longer allow you to enable this setting and also target a really old browser.
The `--keep-names` setting needs to be able to assign to the `name` property on functions and classes. However, before ES6 this property was non-configurable, and attempting to assign to it would throw an error. So with this release, esbuild will no longer allow you to enable this setting while also targeting a really old browser.

## 0.19.6

Expand Down
14 changes: 12 additions & 2 deletions internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,19 @@ func parseFile(args parseArgs) {
result.file.inputFile.Repr = &graph.CSSRepr{AST: ast}
result.ok = true

case config.LoaderJSON:
case config.LoaderJSON, config.LoaderWithTypeJSON:
expr, ok := args.caches.JSONCache.Parse(args.log, source, js_parser.JSONOptions{})
ast := js_parser.LazyExportAST(args.log, source, js_parser.OptionsFromConfig(&args.options), expr, "")
if loader == config.LoaderWithTypeJSON {
// The exports kind defaults to "none", in which case the linker picks
// either ESM or CommonJS depending on the situation. Dynamic imports
// causes the linker to pick CommonJS which uses "require()" and then
// converts the return value to ESM, which adds extra properties that
// aren't supposed to be there when "{ with: { type: 'json' } }" is
// present. So if there's an import attribute, we force the type to
// be ESM to avoid this.
ast.ExportsKind = js_ast.ExportsESM
}
if pluginName != "" {
result.file.inputFile.SideEffects.Kind = graph.NoSideEffects_PureData_FromPlugin
} else {
Expand Down Expand Up @@ -1054,7 +1064,7 @@ func runOnLoadPlugins(
for _, attr := range source.KeyPath.ImportAttributes.Decode() {
if attr.Key == "type" {
if attr.Value == "json" {
loader = config.LoaderJSON
loader = config.LoaderWithTypeJSON
} else {
r := importPathRange
if importWith != nil {
Expand Down
66 changes: 33 additions & 33 deletions internal/bundler_tests/bundler_default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7773,39 +7773,39 @@ func TestErrorsForAssertTypeJSON(t *testing.T) {
".copy": config.LoaderCopy,
},
},
expectedScanLog: `js-entry.js: ERROR: Cannot use non-default import "unused" with a standard JSON module
js-entry.js: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "unused" import (which is non-standard behavior).
js-entry.js: ERROR: Cannot use non-default import "used" with a standard JSON module
js-entry.js: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "used" import (which is non-standard behavior).
js-entry.js: WARNING: Non-default import "prop" is undefined with a standard JSON module
js-entry.js: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "prop" import (which is non-standard behavior).
js-entry.js: ERROR: Cannot use non-default import "exported" with a standard JSON module
js-entry.js: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "exported" import (which is non-standard behavior).
js-entry.js: ERROR: Cannot use non-default import "def3" with a standard JSON module
js-entry.js: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "def3" import (which is non-standard behavior).
expectedScanLog: `js-entry.js: ERROR: Cannot use non-default import "unused" with a JSON import assertion
js-entry.js: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "unused" import.
js-entry.js: ERROR: Cannot use non-default import "used" with a JSON import assertion
js-entry.js: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "used" import.
js-entry.js: WARNING: Non-default import "prop" is undefined with a JSON import assertion
js-entry.js: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "prop" import.
js-entry.js: ERROR: Cannot use non-default import "exported" with a JSON import assertion
js-entry.js: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "exported" import.
js-entry.js: ERROR: Cannot use non-default import "def3" with a JSON import assertion
js-entry.js: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "def3" import.
js-entry.js: ERROR: The file "foo.text" was loaded with the "text" loader
js-entry.js: NOTE: This import assertion requires the loader to be "json" instead:
NOTE: You need to either reconfigure esbuild to ensure that the loader for this file is "json" or you need to remove this import assertion.
js-entry.js: ERROR: The file "foo.file" was loaded with the "file" loader
js-entry.js: NOTE: This import assertion requires the loader to be "json" instead:
NOTE: You need to either reconfigure esbuild to ensure that the loader for this file is "json" or you need to remove this import assertion.
ts-entry.ts: ERROR: Cannot use non-default import "used" with a standard JSON module
ts-entry.ts: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "used" import (which is non-standard behavior).
ts-entry.ts: WARNING: Non-default import "prop" is undefined with a standard JSON module
ts-entry.ts: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "prop" import (which is non-standard behavior).
ts-entry.ts: ERROR: Cannot use non-default import "exported" with a standard JSON module
ts-entry.ts: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "exported" import (which is non-standard behavior).
ts-entry.ts: ERROR: Cannot use non-default import "def3" with a standard JSON module
ts-entry.ts: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "def3" import (which is non-standard behavior).
ts-entry.ts: ERROR: Cannot use non-default import "used" with a JSON import assertion
ts-entry.ts: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "used" import.
ts-entry.ts: WARNING: Non-default import "prop" is undefined with a JSON import assertion
ts-entry.ts: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "prop" import.
ts-entry.ts: ERROR: Cannot use non-default import "exported" with a JSON import assertion
ts-entry.ts: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "exported" import.
ts-entry.ts: ERROR: Cannot use non-default import "def3" with a JSON import assertion
ts-entry.ts: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "def3" import.
`,
})
}
Expand Down Expand Up @@ -7847,12 +7847,12 @@ func TestOutputForAssertTypeJSON(t *testing.T) {
".copy": config.LoaderCopy,
},
},
expectedScanLog: `js-entry.js: WARNING: Non-default import "prop" is undefined with a standard JSON module
js-entry.js: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "prop" import (which is non-standard behavior).
ts-entry.ts: WARNING: Non-default import "prop" is undefined with a standard JSON module
ts-entry.ts: NOTE: This is considered an import of a standard JSON module because of the import assertion here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "prop" import (which is non-standard behavior).
expectedScanLog: `js-entry.js: WARNING: Non-default import "prop" is undefined with a JSON import assertion
js-entry.js: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "prop" import.
ts-entry.ts: WARNING: Non-default import "prop" is undefined with a JSON import assertion
ts-entry.ts: NOTE: The JSON import assertion is here:
NOTE: You can either keep the import assertion and only use the "default" import, or you can remove the import assertion and use the "prop" import.
`,
})
}
Expand Down
19 changes: 19 additions & 0 deletions internal/bundler_tests/bundler_loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1613,3 +1613,22 @@ func TestLoaderBundleWithImportAttributes(t *testing.T) {
},
})
}

func TestLoaderBundleWithTypeJSONOnlyDefaultExport(t *testing.T) {
loader_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.js": `
import x, {foo as x2} from "./data.json"
import y, {foo as y2} from "./data.json" with { type: 'json' }
`,
"/data.json": `{ "foo": 123 }`,
},
entryPaths: []string{"/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/out.js",
},
expectedCompileLog: `entry.js: ERROR: No matching export in "data.json with { type: 'json' }" for import "foo"
`,
})
}
1 change: 1 addition & 0 deletions internal/bundler_tests/snapshots/snapshots_default.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4169,6 +4169,7 @@ x = [data_default, data_default, data_default2];
"project/data.json with { type: 'json' }": {
"bytes": 16,
"imports": [],
"format": "esm",
"with": {
"type": "json"
}
Expand Down
4 changes: 3 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ const (
LoaderGlobalCSS
LoaderJS
LoaderJSON
LoaderWithTypeJSON // Has a "with { type: 'json' }" attribute
LoaderJSX
LoaderLocalCSS
LoaderText
Expand All @@ -217,6 +218,7 @@ var LoaderToString = []string{
"global-css",
"js",
"json",
"json",
"jsx",
"local-css",
"text",
Expand Down Expand Up @@ -248,7 +250,7 @@ func (loader Loader) CanHaveSourceMap() bool {
LoaderJS, LoaderJSX,
LoaderTS, LoaderTSNoAmbiguousLessThan, LoaderTSX,
LoaderCSS, LoaderGlobalCSS, LoaderLocalCSS,
LoaderJSON, LoaderText:
LoaderJSON, LoaderWithTypeJSON, LoaderText:
return true
}
return false
Expand Down

0 comments on commit 2886b5d

Please sign in to comment.