diff --git a/proposals/on-demand-definitions.md b/proposals/on-demand-definitions.md index 630fce2..0d696f5 100644 --- a/proposals/on-demand-definitions.md +++ b/proposals/on-demand-definitions.md @@ -9,7 +9,7 @@ side-effects. **Created**: 2024-11-26 -**Last updated**: 2024-11-26 +**Last updated**: 2024-12-16 ## Background @@ -54,7 +54,32 @@ Defining custom elements in the top-level scope is useful to ensure they are always defined, but this creates a top-level side-effect which has a few negative consequences: -### Problem 1: Top-level side-effects are not tree shakable +### Problem 1: Easy to forget to import a custom element + +It is trivially easy to use a custom element while forgetting to import its +definition, meaning there is no guarantee the element will be defined at +runtime. + +```javascript +document.querySelector('my-element').doSomething(); +// ^ Could error: No guarantee `my-element` has been defined. +``` + +Contrast this with: + +```javascript +import './my-element.js'; + +document.querySelector('my-element').doSomething(); +// ^ Know `my-element` definition has been loaded. +``` + +An import is necessary to express that this file depends on the definition of +`my-element` as a top-level side-effect from `my-element.js`. This import could +be hundreds or even thousands of lines away from its usage with no clear +association between the two. + +### Problem 2: Top-level side-effects are not tree shakable Consider a file which exports two elements: @@ -86,35 +111,22 @@ of them in the final JS bundle. This is just one example, but the issue extends to all usages of all custom elements. In order to safely use a custom element, a module must import that element, but since doing so typically calls `customElements.define` in the top-level scope, that element can _never_ be tree -shaken. - -### Problem 2: Easy to forget to import a custom element - -It is trivially easy to use a custom element while forgetting to import its -definition, meaning there is no guarantee the element will be defined at -runtime. - -```javascript -document.querySelector('my-element').doSomething(); -// ^ Could error: No guarantee `my-element` has been defined. -``` - -Contrast this with: +shaken, even if it is ultimately never used. Consider the following dev-mode +only usage of a particular element: ```javascript import './my-element.js'; -document.querySelector('my-element').doSomething(); -// ^ Know `my-element` definition has been loaded. +// Set to a compile-time constant known by the bundler. +if (import.meta.env.DEV) { + document.querySelector('my-element').doSomething(); +} ``` -An import is necessary to express that this file depends on the definition of -`my-element` as a top-level side-effect from `my-element.js`. - -Technically, even importing `my-element.js` is not sufficient to know for -certain that `` has been defined. Even if `MyElement` is in that -file, there is no real guarantee `customElements.define` was called in the -top-level scope. +Even if a bundler correctly configures the production build such that +`import.meta.env.DEV === false`, it can only remove the `doSomething` call from +the bundle. `import './my-element,js';` still needs to remain due to the global +side-effect it creates and which the bundler cannot prove is unused. ### Problem 3: Side-effectful imports are sub-optimal @@ -124,13 +136,12 @@ developer removes `.doSomething()` in the above example, no automated tooling will instruct them to also remove `import './my-element.js';`. This leads to extra, unneeded dependencies and reduces confidence for developers who may be hesitant to remove the import because it is hard to know whether anything in the -module actually does depend on any side effects from `my-element.js`. +module actually does depend on any side-effects from `my-element.js`. -For human developers, it is in no way obvious that calling `.doSomething()` -requires an import of `my-element.js` or that the import exists to provide -`.doSomething()`. Extensive comments are necessary to communicate the -relationship between these two statements for developers unfamiliar with these -constraints. +For human developers, it is in no way obvious that calling `doSomething` +requires an import of `./my-element.js` or that the import exists to provide +`doSomething`. Extensive comments are necessary to communicate the relationship +between these two statements for developers unfamiliar with these constraints. ### Problem 4: File ordering @@ -146,10 +157,10 @@ these files completely differently. Normally that's fine: if the files don't import each other and have no dependency relationship between them, it doesn't actually matter which one comes first. -However, side-effects from the first file are observable in the second. Since -defining a custom element is inherently a side-effectful operation, there is a -potential file ordering hazard. When one file defines a custom element, and the -other uses that custom element, the system will work only when that ordering +However, side-effects from the first file executed are observable in the second. +Since defining a custom element is inherently a side-effectful operation, there +is a potential file ordering hazard. When one file defines a custom element, and +the other uses that custom element, the system will work only when that ordering aligns in this unpredictable way. ```javascript @@ -167,15 +178,17 @@ customElements.define('my-element', MyElement); ```javascript // my-user.js +// Note the absence of an `import` here. + document.querySelector('my-element').doSomething(); // ^ MIGHT fail. ``` The lack of an import between `my-user.js` and `my-element.js` means that this -will work with iff `my-element.js` happens to be sorted _before_ `my-user.js`, -which depends entirely on the bundler implementation in use as well as other -files and import edges in the program. Unrelated refactorings can change the -ordering of these two files and lead to unexpected errors. +will work if-and-only-if `my-element.js` happens to be sorted _before_ +`my-user.js`, which depends entirely on the bundler implementation in use as +well as other files and import edges in the program. Unrelated refactorings can +change the ordering of these two files and lead to unexpected errors. ## Goals @@ -187,16 +200,19 @@ ordering of these two files and lead to unexpected errors. * Improve the ability for standard JavaScript tools (bundlers, type checkers, linters, etc.) to reason about custom element usage. * Reduce reliance on side-effects from files not explicitly depended upon. -* Continue to support custom element definition as a top-level side-effect. +* Continue to support custom element definition as a top-level side-effect + when necessary. ## Non-goals * _Do not_ address the problem of identifying and defining ["entry-point components"](#defining-entry-point-elements). +* _Do not_ remove all top-level side-effects from custom element definitions. + Some may still be necessary. ## Overview -This protocol specifies a `static` `define` property on custom elements which +This protocol specifies a static `define` property on custom elements which calls `customElements.define` if the element has not already been defined. This allows anything with a reference to a component's class to define that component "on demand" before using it. @@ -209,12 +225,12 @@ An element can implement this protocol by specifying a static `define` function: export class MyElement extends HTMLElement { static define() { // Check if the tag name was already defined by another class. - const existing = customElements.get(tagName); + const existing = customElements.get('my-element'); if (existing) { if (existing === MyElement) { return; // Already defined as the correct class, no-op. } else { - throw new Error(`Tag name \`${tagName}\` already defined as \`${ + throw new Error(`Tag name \`my-element\` already defined as \`${ existing.name}\`.`); } } @@ -230,7 +246,7 @@ calling it before using the element. ```javascript import {MyElement} from './my-element.js'; -MyElement?.define(); +MyElement.define(); document.querySelector('my-element').doSomething(); // ^ Always works! ``` @@ -244,7 +260,7 @@ this primitive. ### Framework utilization As two small examples, [HydroActive](https://github.com/dgp1130/HydroActive/) -already implements this draft. All `HydroActive` components come with a built in +already implements this draft. All `HydroActive` components come with a built-in `define` implementation and using a component [requires a reference to its component class](#hydroactive) before making that component accessible to developers. HydroActive uses this class to automatically @@ -266,7 +282,7 @@ export const MyElement = component('my-element', (host) => { This approach provides a guarantee that `SomeComp` is defined before `doSomething` is called. It also allows `SomeComp` to be tree-shaken from the bundle if it is not used by `MyElement` or if `MyElement` is itself unused and -eligible for tree shaking. +eligible for tree-shaking. One more potential example would be `lit-html`, which currently relies on [`lit-analyzer`](#lit-analyzer) to ensure all dependencies of a custom element @@ -285,15 +301,15 @@ function renderMyElement() { The `html` tagged template literal could implicitly call `MyElement.define()` to ensure it is defined prior to rendering. This ensures `MyElement` is indeed -defined and allows it to be tree shaken if `renderMyElement` is itself tree -shaken. +defined and allows it to be tree-shaken if `renderMyElement` is itself +tree-shaken. ### Benefits This protocol fixes or improves all of the above specified problems: 1. Removing `customElements.define` from the top-level scope allows bundlers to - effectively tree shake any unused components. In order to define an element + effectively tree-shake any unused components. In order to define an element with `MyElement.define()`, users must have a reference to `MyElement` which is known to the bundler. 2. This API provides a common primitive for web component libraries and @@ -414,7 +430,7 @@ defaulting to the global registry. Attempting to define a custom tag name on the global registry still throws in order to maintain the invariant that all consumers of the global registry use -the agreed-upon name. See [Custom tag name](#custom-tag-name). +the agreed-upon name. See [custom tag name](#custom-tag-name). ### Scoped Custom Element Registries as an alternative @@ -443,16 +459,18 @@ However, scoped custom element registries have some drawbacks which make them a non-ideal solution to this proposal. First, scoped registries are coupled to shadow DOM, which not all custom -elements use. This on-demand definitions proposal supports all custom elements, -even light DOM components. +elements use. This On-Demand Definitions proposal supports all custom elements, +even light DOM components. Note that a +[more recent scoped registry proposal](https://github.com/whatwg/html/issues/10854) +may lift this particular restriction. Second, scoped registries require creating an entirely distinct registry with potentially decoupled tag names. This places a constraint on consumers which need to manually define a mapping of `some-tag-name` -> `MyElement`. This constraint is reasonable within the context of a scoped registry, but is completely unnecessary for the goals of this proposal. Not every consumer of an -element wants its own custom registry or decouple and own its own mapping of tag -names. +element wants its own custom registry or to decouple and own its own mapping of +tag names. Third, as shown in [scoped registries support](#scoped-custom-element-registries-support), this @@ -461,10 +479,14 @@ Having a `define` function owned by the component author provides an abstraction over the tag name in the global registry and [the `options` field](#allowing-options). -Scoped registries also require removing the top-level `customElements.define` -call anyways to realize their benefits, which on-demand definitions naturally -achieves as well. Also, some component consumers may use scoped custom elements, -but some may not and can still receive tree-shaking benefits. It is perfectly +Fourth, scoped registries also require removing the top-level +`customElements.define` call anyways to realize their benefits, which On-Demand +Definitions naturally achieves as well. + +Fifth, some component consumers may use a scoped custom elements registry, but +others may not and should still receive the benefits of this proposal. Using a +scoped registry does not address any of these problems for components in the +global registry, while this On-Demand Definitions proposal does. It is perfectly valid for two different consumers to call `MyElement.define()` in the global registry while a third consumer uses a scoped registry. All three receive the benefits of this proposal. @@ -590,12 +612,17 @@ class MyElement extends HTMLParagraphElement { } ``` -`customElements.define` is naturally creating a side-effect which stores the -provided component class and its configuration. When multiple consumers are -defining a component on-demand, they need to agree on that configuration. This -implies that no consumer can have direct control over the configuration or else -it would risk breaking other consumers when they are forced to use a component -with a configuration they did not expect. +Future additions to this options object maybe be more appropriate to make +configurable for individual consumers and considered on a case-by-case basis. +However, `customElements.define` is naturally creating a side-effect which +stores the provided component class and its configuration. When multiple +consumers are defining a component on-demand, they need to agree on that +configuration. This implies that no consumer can have direct control over the +configuration or else it would risk breaking other consumers when they are +forced to use a component with a configuration they did not expect. It is highly +unlikely future options introduced to `customElements.define` will support being +independently configurable for multiple consumers, therefore that functionality +is intentionally _not_ exposed. ### Defining entry-point elements @@ -615,7 +642,8 @@ HTML: ``` -Consider also that `MyApp` is defined in line with this protocol as: +Consider also that `MyApp` is defined in line with this protocol, omitting +top-level side-effects: ```javascript // my-app.js @@ -624,16 +652,16 @@ import {SomeComp} from './some-comp.js'; export class MyApp extends HTMLElement { static define() { - if (customElements.get('my-app')) return; - - customElements.define('my-app', MyApp); + // ... } // ... } + +// No top-level `customElements.define` or `MyApp.define`. ``` -`my-app.js` _must_ call `customElements.define('my-app', MyApp)` / +`my-app.js` _needs_ to call `customElements.define('my-app', MyApp)` / `MyApp.define()` in its top-level scope in order for the application to start. However in this scenario, `MyApp.define` is never called and the entire element, @@ -652,6 +680,192 @@ Identifying entry-point elements, retaining them in the bundle, and triggering their definition is a complex problem in its own right and out of scope for this particular proposal. +### Development-only checks + +The [example implementation](#example) includes a check which throws if the tag +name was already defined by a different class. This is useful for development +purposes in case of a tag name conflict, However, it is very unlikely to be a +problem in production applications after the developer has resolved any relevant +issues. + +Therefore it is acceptable to omit this particular check and no-op in production +for the case of conflicting class definitions. This enables a small bundle size +improvement without affecting valid usage. A _minimal_ implementation of the +protocol looks like: + +```javascript +export class MyElement extends HTMLElement { + static define() { + // If already defined, no-op. + // Might be the wrong class, but we don't care in a well-formed application. + if (customElements.get('my-element')) return; + + customElements.define('my-element', MyElement); + } +} +``` + +### Why not inline the `define` implementation? + +ALTERNATIVE PROPOSAL: The static `define` implementation is relatively small and +condensed, just execute that whenever there is a need to use a custom element. + +```javascript +import {MyElement} from './my-element.js'; + +if (!customElements.get('my-element')) { + customElements.define('my-element', MyElement); +} + +document.querySelector('my-element').doSomething(); +``` + +This is functionally equivalent to calling `MyElement.define` but does not +require `MyElement` to opt-in to this community protocol. Inlining `define` does +come with a few costs however. + +First, `MyElement.define` provides an abstraction which encapsulates the tag +name and options passed to `customElements.define`. Without this abstraction, it +becomes more likely multiple consumers will define the same element multiple +times with different choices and run into the same problems as +[allowing options in the static `define` function](#allowing-options) does. + +Second, inlining requires knowledge of the tag name. To call +`customElements.define`, the caller must know the tag name to define. In +general, every consumer of an element likely does need to know the tag name it +is consuming, however libraries or frameworks may want to handle calling +`customElements.define` automatically in a context where they don't necessarily +know the expected tag name and would require a product developer to manually +provide this information every time. + +Third, when given a custom element which has not been defined, is it reasonable +to directly define that element? Most existing custom elements expect a single, +centralized `customElements.define` call and do not anticipate that they may be +defined at any time. + +Consider a component written in the traditional style with an adjacent top-level +side-effect and no knowledge of the On-Demand Definitions protocol or an +equivalent "Just call `customElements.define` before you use it" convention: + +```javascript +export class MyElement extends HTMLElement { + // ... +} + +doSomething(MyElement); + +customElements.define('my-element', MyElement); // Throws an error. + +function doSomething(elClass) { + // Conditionally define the class if necessary. + if (!customElements.get('my-element')) { + customElements.define('my-element', elClass); + } + + document.querySelector('my-element').doSomethingElse(); +} +``` + +Because `doSomething` uses the convention of conditionally defining the element, +it is able to define `MyElement` *before* the adjacent top-level side-effect, +causing it to throw an error. Any code with a reference to `MyElement` prior to +its definition could potentially cause this problem. An unexpected early +definition can also be observed through other `customElements` APIs like `get`, +`getName`, or `whenDefined` which could affect component logic in unanticipated +ways. + +Therefore On-Demand Definitions is better implemented as an opt-in decision by +any given component. By implementing the static `define` function, a component +essentially states: "I do not expect to be defined by a specific, centralized +`customElements.define` call." This guarantee is what allows decentralized +`define` calls to work consistently. + +Fourth, to support tree-shaking, top-level side-effects need to be removed +regardless of whether a separate `define` abstraction is used. This begs the +question: What should a web component author do with their existing +`customElements.define` call? There are a few options: + +1. Move it to a separate ES module and tell consumers to import that when + top-level side-effects are needed. +1. Delete it entirely and expect every consumer to follow the convention of + conditionally defining `MyElement` before using it. +1. Move it into their own (not specified by a community protocol) version of a + static `defineMyComponent` function with none of the interoperability + benefits. + +These each have their own trade offs and every component author is likely to +make an independent decision leading to divergence within the web component +space. This makes consuming web components even harder because consumers have to +ask "How do I ensure this component is defined?" every time they adopt a new +custom element. + +On-Demand Definitions provides a recommended answer to this complicated question +which maximizes compatibility with the rest of the ecosystem. + +Finally, there is a small bundle size and stability argument to make here. +Conditionally defining a custom element is not quite trivial and has a few +unexpected edge cases (ex. the component is already defined but with a different +class). The fewer times this function is implemented, the less likely bugs will +be introduced and the less JavaScript users need to download. Also components +are likely to be consumed more frequently than they will be implemented. +Therefore it follows that it will be slightly more optimal for component +definitions to implement `define` rather than asking every consumer of the +component to implement conditional define logic itself. + +Note that custom element libraries and frameworks do skew this reasoning +slightly, however the general rule of component usage (even if implemented by a +small number of frameworks) outnumbering component definitions (even if also +implemented by a small number of frameworks) should hold in most environments. + +### Why not use `customElements.whenDefined`? + +It is possible to await a custom element definition via +`customElements.whenDefined` which would allow a module to be more tolerant of a +dependency component being defined after it. + +ALTERNATIVE PROPOSAL: Use `customElements.whenDefined` to wait for a component +to be defined somewhere else. + +```javascript +const el = document.querySelector('my-element'); + +// Wait until the element is defined. +customElements.whenDefined('my-element').then(() => { + el.doSomething(); // Definitely defined now. +}); +``` + +While `customElements.whenDefined` is a useful primitive, it is insufficient to +meet the goals of this proposal because it: + +1. relies on _some other module_ in the program to import the definition + of `my-element`, which is not guaranteed to happen synchronously or ever at + all. +1. "colors" all usage of custom elements to be async which can + block many otherwise-reasonable API contracts. + * For example, both the Lit and HydroActive use cases are synchronous and + would be incompatible with asynchronously waiting for dependencies to be + defined. +1. requires independent knowledge of the tag name for an element which might + not be known in generic contexts such as libraries or frameworks. + * `MyElement.define();` only requires a reference to the custom element + class, not its tag name. +1. does not improve tree-shakability of components. + +Beyond those functional points, `customElements.whenDefined` notably does *not* +define a custom element or provide a definition, weakening the relationship +between a custom element and this specific usage. This is very different from +the intent behind On-Demand Definitions which opts to strengthen this +relationship to ensure that a specific, known custom element class is defined +before it is used. + +`customElements.whenDefined` is great for use cases attempting to use a custom +element's definition which may or may not be provided by something else on the +page at any time. That does not describe the problem statement of this proposal +which wants a specific custom element to always be defined at a specific moment +in time. This indicates that `customElements.whenDefined` is the wrong primitive +to solve this particular problem. + ## Previous considerations There is some prior art to be aware of in this space. @@ -683,8 +897,9 @@ code to ensure that any usage of an element in the `html` literal is covered by a direct import. This validates that every Lit component has a direct dependency on any elements they require. -This requires the developer to integrate as distinct analyzer into their -toolchain for Lit, which otherwise doesn't require any such tooling. +While useful, `lit-analyzer` unfortunately requires the developer to integrate a +distinct service into their toolchain with special knowledge of Lit templates, +which otherwise does not require any such tooling. ### HydroActive @@ -724,7 +939,7 @@ custom element to ensure that this dependency exists. However even this approach is forced to assume that a custom element class declaration is co-located with a top-level `customElement.define` call. This assumption also prevents tree-shaking of any dependencies. HydroActive -implemented the on-demand definitions proposal to mitigate these issues. +implemented the On-Demand Definitions proposal to mitigate these issues. HydroActive's design with respect to file ordering is described more thoroughly in [this video](https://youtu.be/euFQRqrTSMk?si=i5HKHayt3QvuNytf&t=736), though