Sometimes, a feature is natively implemented in one browser and missing from another. The gap can be made up by polyfills and transpilers, which unfortunately both result in more code being shipped over the network and parsed by the browser, and generally slower startup time. This proposal provides a tool to serve different JavaScript to different browsers based in feature tests which live outside of the JavaScript code. These feature tests are declarative, rather than described in JavaScript, so that they can be executed by the browser itself.
The import maps proposal introduces the concept of configurable module specifier resolution based on fallback lists to JavaScript. This proposal uses that fallback list to decide how to implement a module based on testing for the existence of features.
Any resemblance to currently developed browsers is purely accidental.
Let's imagine that there are three popular rendering engines under active development: Mosaic, Lynx, and HotJava. They're all doing a great job participating in web standards, implementing emerging standards when it makes sense, rapidly distributing new browser versions to web users, etc. However, sometimes, one ships a feature before another.
The X Framework aims to work well on Mosaic, Lynx and HotJava. It's really excited about emerging standards, so it aims to make use of them as soon as possible, falling back to polyfills and transpiled code when necessary.
Mosaic and Lynx have been shipping the dynamic import proposal for a year, but HotJava has not yet implemented it yet. The X framework wants to use import()
for code splitting when possible, since the polyfill results in slower loading. However, as import()
is a syntax error in HotJava, it can't simply use it behind a JavaScript conditional. It's also context-dependent and hard to move from one file to another, given how relative URLs are resolved based on where the module is.
As part of the X Framework's build process, it puts it in two directories: js-new/
includes native use of import()
, and js-old
transpiles away use of import()
. Accompanying this is the following import map:
{
"imports": {
"js/": [
{ "if": { "javascript-syntax": "import(null)" }, "then": "js-new/" },
"js-old/"
]
}
}
HotJava was the first to implement and ship the entire Intl.RelativeTimeFormat
proposal. Then, Lynx shipped most of it, but omitting the Intl.RelativeTimeFormat.prototype.formatToParts
method. Meanwhile, it looks like it'll take a little longer to get an implementation done in Mosaic. The X Framework wants to use Intl.RelativeTimeFormat
, but it'd like to avoid shipping a full polyfill to all users. And for Lynx users, it'd be best to ship just the polyfill for the one missing method.
The X Framework's Widget component makes heavy use of Intl.RelativeTimeFormat
. To support Mosaic, Lynx and HotJava well, it includes both a full polyfill in "/intl-relative-time-format.mjs"
, as well as a polyfill which just adds formatToParts
based on the built-in library in "/intl-relative-time-format-to-parts.mjs"
. There's an empty module at "/empty.mjs"
. These are selected as follows:
{
"imports": {
"intl-relative-time-format": [
{
"if": {
"global": "Intl",
"property": "RelativeTimeFormat.prototype.formatToParts"
},
"then": "/empty.mjs"
},
{
"if": {
"global": "Intl",
"property": "RelativeTimeFormat"
},
"then": "/intl-relative-time-format-to-parts.mjs"
},
"/intl-relative-time-format.mjs"
]
}
}
Before using Intl.RelativeTimeFormat
, the polyfill is loaded with the statement, import "intl-relative-time-format"
.
Lynx implemented and shipped BigInt
proposal, but omitted the BigInt.prototype.toLocaleString
method because the specification was not yet mature. Later, the method shipped in Lynx, but old versions of Lynx remain in broad use.
A developer wants to create a calculator widget, which can operate accurately on large integers, and needs to output them in a locale-dependent way. The X Framework encourages developers to write code using JSBI, then transpiles it to native BigInt
for browsers that support it using babel-plugin-transform-jsbi-to-bigint.
The widget is included using <script type=module src="/calculator.mjs"></script>
. /calculator.mjs
contains the code using BigInt, and starts with the line import "/bigint-to-locale-string-polyfill.mjs"
. The code based on JSBI is in "/calculator-jsbi.mjs".
{
"imports": {
"/calculator.mjs": [
{ "if": { "javascript-syntax": "0n" }, "then": "./calculator.mjs" },
"/calculator-jsbi.mjs"
],
"/bigint-to-locale-string-polyfill.mjs": [
{
"if": { "global": "BigInt", "property": "prototype.toLocaleString" },
"then": "/empty.mjs"
},
"/bigint-to-locale-string-polyfill.mjs"
]
}
}
The initial std:temporal
module ships, initially, exporting five classes: Instant
, ZonedInstant
, DateTime
, Date
and Time
. Mosaic, Lynx and HotJava implementations are released around the same time, based on a single implementation in JavaScript which can be easily retargeted to multiple browsers. One year later, the need for a Duration
type is noted, and it is added as a sixth named export of the std:temporal
module.
Unfortunately, even though std:temporal.Duration
is released rapidly to users of new browsers, a significant portion of the web still uses various older versions of Lynx and HotJava, taking time to update. There are many users out there who are missing std:temporal
entirely, while others have the five exports but not the sixth.
The X Framework makes use of std:temporal
all over the place, and quickly adopted the new Duration
feature.
{
"imports": {
"std:temporal": [
{
"if": { "module": "std:temporal", "exports": "Duration" },
"then": "std:temporal"
},
{ "if": { "module": "std:temporal" }, "then": "./duration-wrapper.mjs" },
"./full-temporal-polyfill.mjs"
]
},
"scopes": {
"/duration-wrapper.mjs": {
"std:temporal": "std:temporal"
}
}
}
/duration-wrapper.mjs
contains export * from "std:temporal"; export class Duration { /* ... */ }
.
The Bulk Memory Operations Proposal for WebAssembly adds more efficient operations for zeroing a large range, copying memory, etc. These are things that were already possible to implement previously with smaller instructions, but can be done more efficiently across large ranges with a special intrinsic.
In WebAssembly, features can be tested imperatively using the WebAssembly.validate
method. Small WebAssembly programs can be validated to test if they include only supported opcodes. These results can be used to inform which module is pulled in with WebAssembly.instantiateStreaming
.
In the context of the WebAssembly/ESM integration proposal, module specifiers can directly map to WebAssembly modules. When importing a WebAssembly module as an ES module, there's no particular chance to do these imperative validate
checks.
The X Framework has an image processing component which needs to copy the memory backing some large ranges. It has compiled two different WebAssembly modules, one which uses the new feature proposal "/image.wasm"
and one which does not "/image-legacy.wasm"
. Imagine that the string "nf0q29843n0vq340nfwe"
is the result of base-64 encoding a WebAssembly module which exercises the memcpy
feature. The image processing component would include the following in its import map, to choose the right Wasm module:
{
"imports": {
"/image.wasm": [
{ "if": { "wasm-valid": "nf0q29843n0vq340nfwe" }, "then": "/image.wasm" },
"/image-legacy.wasm"
]
}
}
On browsers without the new bulk memory instruction, the WebAssembly binary will fail to validate because it calls an undefined instruction, and the legacy alternative will be selected.
Everyone's talking about custom elements, and the X Framework works to build on them as closely as possible, helping it to be lightweight, efficient and composable. Mosaic, Lynx and HotJava all implement autonomous custom elements, but customized built-in elements is only supported by Mosaic and HotJava at the moment. The X Framework wants to use customized built-in elements for improved initial render time and accessibility in its component library, and use alternative, lengthy, component-specific JavaScript logic when this is not possible.
The X Framework's component library includes some components that, internally, use customized built-in elements. The component "/component.mjs"
has a fallback "/component-legacy.mjs"
for when customized built-in elements are not available. The import map includes the following:
{
"imports": {
"/component.mjs": [
{
"if": {
"global": "customElements",
"property": "define",
"option": "extend"
},
"then": "./component.mjs"
},
"./component-legacy.mjs"
]
}
}
This proposal defines a new, JSON-based mini-language to describe whether a particular feature is available in JavaScript.
To check whether a module with a particular module specifier exists, for a module with the specifier "std:name"
, use:
{ "module": "std:name" }
These module tests are checking if the module is present after import maps, as a built-in module.
To check whether a particular JavaScript expression parses, for a JavaScript source string (interpreted as a module) "module"
, use:
{ "javascript-valid": "module" }
No JavaScript is executed here--a typical JavaScript "preparser" to catch syntax errors would be enough here, rather than a full parser generating bytecode.
To check whether a WebAssembly module is valid, encode the WebAssembly module's binary format in base64, and if that is "no9atr2aon28afs32"
, then use:
{ "wasm-valid" : "no9atr2aon28afs32" }
To check whether a module "std:name"
has an export of the name "exp"
, use:
{ "module": "std:name", "export": "exp" }
To check whether a property of the global object Interface
exists, use:
{ "global": "Interface" }
Note that this would include things like attributes operations on the global object, not just interfaces. Even if this property of the global object is a getter with a side effect, the getter is not run here--it is just checked whether the global object has such a property (including through its prototype chain, but not including anything which is populated based on the document, such as the named properties object).
To check whether a module "std:name"
's export of the name "exp"
has a property "prop"
(i.e., whether import { exp } from "std:name"; "prop" in exp
):
{ "module": "std:name", "export": "exp", "property": "prop" }
This can be used for nested properties (separated by .
), and properties of globals as well.
The semantics of this are a bit complicated to define (since it should cover both JS-style specs and WebIDL), but it's meant to roughly correspond to, "Would this chain of property accesses exist when run in a new environment?". No JavaScript is run when this test happens--it is simply checking whether the name is present in a listing that could be pre-computed and sent to another process.
To check whether a particular method reads a particular property from an options bag, with that options bag passed as the last argument of, e.g., an export "fn"
from module "std:name"
, with the option named "opt"
:
{ "module": "std:name", "export": "fn", "option": "opt" }
"option"
can also be used with globals, properties, etc.
Semantics here are also a bit complicated, but roughly correspond to, "Would this method read a property with this name off of the last parameter of the method?". The method is not executed when evaluating this; it would be based on a pre-defined sense of what the method would do.
In a fallback list in an import map, a new { "if": conditional, "then": fallback-list }
construct is supported, with conditional
being one of the forms listed above, and fallback-list
being either a single module specifier or an array of them (possibly including further conditionals).
There could also be some syntax for supporting this conditional applying to a broader chunk of the import map, if needed. If you have a use case for that, please open an issue.
There are already various imperative APIs for these tests. They have certain caveats, but addressing these caveats wouldn't be as powerful as defining a declarative solution. Imperative tests lead to the lose-lose choice of, send the polyfill to the client unconditionally, or insert a later load with something like document.write
or import()
--the fetches aren't visible to the browser until some JavaScript is already running, and they come in one by one, leading to slower startup time in the cases where polyfills ar needed. The prefetch scanner could speculate that all of these fetches are needed if it sees them in source, but this speculation could lead to excessive network usage in low-bandwidth situations--which are precisely the times when slow load times are the most acute. This proposal gives the prefetch scanner deeper, declarative knowledge of what's needed, so it can make smarter choices.
The problem of identifying these historical issues is very complex, but polyfills have already been solving it through runtime tests. It could be hard to define a common lanugage to describe these issues besides the imperative tests. One possibility would be to define a "version number" for each property, and increment it to indicate the lack of a particular known bug. However, coordinating browsers to maintain such a version would be difficult. This refinement is left for a future proposal, and for now, people can continue to use the existing strategy.
The import map can be either hand-written or generated by tools. Jan Krems' package exports proposal adds a field to packages.json to list exported modules, in a way which can be used to generate import maps. This proposal is designed to fit well into that one; see discussion.
Maybe! File an issue and let's discuss it.
For other environments where import maps make sense, this proposal might be useful for both JavaScript and WebAssembly. There many be further tests which also make sense, in an environment-specific way. Please file an issue if you have any thoughts about how this proposal applies to non-web environments.
A complementary proposal by Mathias Bynens and Kristofer Baxter takes this approach. Larger presets could be helpful to reduce the size and complexity of tests sent over, so they may be a very practical option for tooling today. Some reasons why this repository focuses on finer grained tests as well as usage of the results:
- It's difficult to define what the presents contain. Previous attempts (e.g., hasFeature) didn't go well (browsers lie!).
- There's a lot of interest in shipping code where not all new browsers support the feature yet. Reducing to the lowest common denominaotr wouldn't provide this capability.
- In the context of import maps providing a mechanism for loading polyfills, it makes sense for the polyfill loaded to depend on the features provided by that polyfill, without that necessarily affecting other imports.
If both features are standardized, they could be a great combination: a srcset
for import maps could be used to reduce the size of the import map delivered to newer browsers, if it gets too big over time with all of this feature testing.
If import maps are the mechanism for loading polyfills of built-in modules, and if we consider it important that finer feature tests lead to how polyfills for built-in modules are loaded, then it's important that these tests work well with import maps.
Import maps already provide the core technology that this proposal builds on: A fallback list used to resolve module specifiers. Import maps give us space to work by being based on a JSON format which skips over errors. It would be a lot of extra work to build this infrastructure in another way, and it wouldn't make much sense to have multiple redundant mechanisms for the same thing on the web.
How does this proposal interact with APIs which are only exposed in secure contexts, withheld from Workers, etc?
Import maps are always evaluated within a particular JavaScript realm, so the existence of properties, globals, modules, etc. with respect to the [Exposed] and [SecureContext] extended attributes should be well-defined. This existence is already visible to import maps, as kv-storage may be only present in the module map in secure contexts.
Why investigate this direction now, rather than waiting to see how import maps pans out in practice?
Whether modules will eventually have feature testing for their contents affects how modules should be designed. For example, if something like this proposal is not adopted, then in the Temporal Duration
class case, we may want to put Duration
into a sub-module like std:temporal/duration
so that it can be polyfilled separately, without needing to load the polyfill in browsers. If that's going to be the eventual shape, then maybe std:temporal
should be broken up into several tiny modules from the beginning, for each class it exports, for consistency.
However, import maps stands well on its own as an initial feature, and for that reason, this repository describes finer feature tests as a potential follow-on.
Efficient implementation is a goal, but whether this proposal meets that goal is unclear. The hope would be that the import map remains interpretable by the network process, when scanning for what to fetch. Asking the network process to understand which JavaScript APIs are available, and to parse JavaScript/validate WebAssembly, would be new, and could increase memory usage. If the queries need to be interpreted by the renderer process, startup time may be slower. However, the reduced volume of code fetched, parsed and executed may compensate. More investigation on implementability is needed before concluding that this feature is feasible.
Does JavaScript need to be executed in order to evaluate these conditionals? What about the security implications?
The goal is that no JavaScript or WebAssembly would be needed to evaluate any of these conditionals. Getters are not run when checking for globals or properties; functions are not called when checking options. The JavaScript preparser and WebAssembly validator need to be available, and these are complex software components which may have security issues. To check whether globals, properties or options exist, only the list of names are needed.
Maybe, but they would basically be syntactic sugar. "and" can be done with nested conditionals. For "not", just use the next fallback. For "or", two different branches can map to the same thing. For this reason, they are left out of this initial version of this proposal.
The Target Features Section of the WebAssembly tooling conventions for linking gives names to various WebAssembly features. These could be used, rather than validation, for simpler WebAssembly feature testing. Possibly another thing to add to the language. This draft does not use them, as they have not yet been used in a web standard, but it could be considered for a future draft.
It's often the most practical to do feature testing on the client side, as many polyfills do, and as import maps do as well. Some problems with depending on the UA string:
- Not all layers of the frontend software stack have the ability to reconfigure how serving works.
- UAs can choose their own UA string, and this might not correspond to the features they support.
- Because it can be difficult to deploy UA string testing locally, there are some centralized services like polyfill.io which provide it, but some projects hesitate to depend on external services.
- Browsers have a long history of lying about their UA string in order to influence how UA string testing code acts; encouraging this mechanism more broadly could make the problem worse. Developers have been taught to not do this.
No. This information is already available through existing JavaScript APIs shipped to the web.