├── proposal-template.md ├── proposals ├── element-internals-declaration.md ├── defer-hydration.md ├── pending-task.md ├── slottable-request.md ├── context.md └── on-demand-definitions.md └── README.md /proposal-template.md: -------------------------------------------------------------------------------- 1 | # Proposal name 2 | 3 | **Author**: Name here 4 | 5 | **Status**: Proposal 6 | 7 | **Created**: Date here 8 | 9 | **Last updated**: Date here 10 | 11 | # Summary 12 | 13 | A brief, one or two sentence explanation of the proposal. 14 | 15 | # Example 16 | 17 | Provide an example of the API using type definitions and/or code example. 18 | 19 | # Goals 20 | 21 | - Write an explicit list. 22 | - Of what this proposal hopes to achieve. 23 | - Be as specific and broad as possible. 24 | 25 | # Non-goals 26 | 27 | - It's also important to be explicit about non-goals. 28 | - This prevents feature creep and tangent conversations. 29 | - Both goals and non-goals are valid topics to debate and can make/break the proposal. 30 | 31 | # Design detail 32 | 33 | Go into detail on the design of the API, This is where you go past the surface and explain the mechanics of how the API works. If a step-by-step direction is possible, provide it. 34 | 35 | # Open questions 36 | 37 | The first draft of any proposal is not going to have all of the answers. Take this space to be clear of what is still unknown. 38 | 39 | # Previous considerations 40 | 41 | List any previous proposals or priority art that inspired this proposal. -------------------------------------------------------------------------------- /proposals/element-internals-declaration.md: -------------------------------------------------------------------------------- 1 | # Proposal name 2 | 3 | **Author**: Dave Rupert 4 | 5 | **Status**: Proposal 6 | 7 | **Created**: April 22, 2025 8 | 9 | **Last updated**: April 22, 2025 10 | 11 | # Summary 12 | 13 | There's no standard API to expose `ElementInternals` to `@public`. Third-party tooling like Automated Accessibility Testing tools are unable to reliably "guess" and find those exposed internals (e.g. cannot find `aria-role`). Problems that arise from this are best summed up on the DequeuLabs aXe-core Github Repo: 14 | 15 | https://github.com/dequelabs/axe-core/issues/4259 16 | 17 | Both aXe (on thread) and Microsoft Accessibility Insights (privately) have indicated that a community standard here would go a long way to supporting the probing of `ElementInternals` for valid roles. 18 | 19 | Another potential win here would be an SSR story where tooling to make static HTML could elevate internals like `role` or a default `:state()` to the template. 20 | 21 | For those reasons, it would be good for the Web Components Community Group to have an agreed upon standard for exposing `ElementInternals` so other tooling can build from our convention. 22 | 23 | # Example 24 | 25 | Here is a softball proposal for how we could consistently expose internals publicly. 26 | 27 | ```ts 28 | class FooElement extends HTMLElement { 29 | // @public 30 | const internals: ElementInternals = this.attachInternals() 31 | } 32 | ``` 33 | 34 | # Goals 35 | 36 | - Determine an agreed upon declaration naming convention for exposing `ElementInternals` to third-party tooling. 37 | - Identify all the places (ARIA roles, custom states, forms, SSR, etc) where consistent interface would be ideal. 38 | 39 | # Non-goals 40 | 41 | - Fight about names forever. 42 | 43 | # Design detail 44 | 45 | Infinite options exist on what we could name the interface each with its plusses and minuses. 46 | 47 | ```js 48 | const elementInternals = this.attachInternals() // verbose but specific 49 | const internals = this.attachInternals() // shorter but more vague 50 | const guts = this.attachInternals() // this is nonsense. 51 | ``` 52 | 53 | Another common convention is to attach internals as a private/internal variable. 54 | 55 | ```js 56 | const #internals = this.attachInternals() // very private 57 | const _internals = this.attachInternals() // this underscores the need for consistency 58 | ``` 59 | 60 | This makes logical sense to make `attachInternals` internal, but based on the established goal of this being a public API that is consistently exposed to third-parties it seems a public declaration would be more suitable. 61 | 62 | 63 | # Open questions 64 | 65 | 1. Would closed shadowroots need/desire something a private field like `#internals`? 66 | 67 | # Previous considerations 68 | 69 | Prior art: 70 | 71 | - Benny Powers' suggested patch to aXe https://github.com/dequelabs/axe-core/issues/4259#issuecomment-2238912894 72 | - Westbrook Johnson's [axe-core-element-internals](https://github.com/Westbrook/axe-core-element-internals) 73 | 74 | 75 | -------------------------------------------------------------------------------- /proposals/defer-hydration.md: -------------------------------------------------------------------------------- 1 | # Defer Hydration Protocol 2 | 3 | An open protocol for controlling hydration on the client. 4 | 5 | Author: Justin Fagnani 6 | 7 | Status: Proposal 8 | 9 | Last update: 2021-06-24 10 | 11 | # Background 12 | 13 | In server-side rendered (SSR) applications, the process of a component running code to re-associate its template with the server-rendered DOM is called "hydration". The defer-hydration protocol is design to allow controler over hydration to solve two related problems: 14 | 15 | 1. Interoperable incremental hydration across web components. Componentents should not automatically hydrate upon being defined, but wait for a signal from their parent or other coordinator. 16 | 2. Hydration ordering independent of definition order. Because components usually depend on data from their parent, and the parent won't usually set data on a child until it's hydrated, we need hydration to occur in a top-down order. 17 | 18 | `defer-hydration` enables us to decouple loading the code for web component definitions from starting the work of hydration, and enables top-down ordering and sophisticated coordination of hydration, including triggering hydration only on interaction or data changes for specific components. 19 | 20 | # Overview 21 | 22 | The Defer Hydration Protocol specifies an attribute named `defer-hydration` that is placed on elements, usually during server rendering, to tell them not to hydrate when they are upgraded. Removing this attribute is a signal that the element should hydrate. Elements can observe the attribute being removed via `observedAttribute` and `attributeChangedCallback`. 23 | 24 | When an element hydrates it can remove the `defer-hydration` attribute from its shadow children to hydrate them, or keep the attribute if itself can determine a more optimal time to hydrate all or certain children. By making the parent responsible for removing the `defer-hydration` attribute from it's children, we ensure top-down ordering. 25 | 26 | ## Use case 1: Auto-hydration with top-down odering 27 | 28 | In this use case we want to page to hydrate as soon as elements are defined, but we want to force top-down ordering to avoid invalid child states. Here we configure the server-rendering step to add the `defer-hydration` attribute to all elements _except_ the top-most defer-hydration-aware elements in the document. 29 | 30 | When the top-most elements are defined, they will run their hydrations steps since they don't have a `defer-hydration` attribute, and will trigger their subtrees to hydrate by removing `defer-hydration` from children. 31 | 32 | Example HTML: 33 | 34 | ```html 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 49 | 50 | 53 | 54 | 55 | 56 | ``` 57 | 58 | ## Use case 2: On-demand hydration 59 | 60 | Hydration can be deferred until some data or user-driven signal, such as interacting with an element. In this case server-rendering is configured to add `defer-hydration` to all elements so that nothing will automatically hydrate. 61 | 62 | An app-level coordinator may implement an event delegation/buffering/replay system to detect user-events within an element and remove `defer-hydration` on demand before replaying events. 63 | 64 | *TODO: do we need to have an event that signals that hydration is complete before replaying events?* 65 | 66 | ## Hydrating children 67 | 68 | ```ts 69 | class MyElement extends HTMLElement { 70 | static observedAttributes = ['defer-hydration']; 71 | 72 | attributeChangedCallback(name, oldValue, newValue) { 73 | if (name === 'defer-hydration' && newValue === null) { 74 | this._hydrate(); 75 | } 76 | } 77 | 78 | _hydrate() { 79 | // do template hydrate work 80 | // ... 81 | 82 | // hydrate children 83 | const deferredChildren = 84 | this.shadowRoot.querySelectorAll('[defer-hydration]'); 85 | for (const child of deferredChildren) { 86 | child.removeAttribute('defer-hydration'); 87 | } 88 | } 89 | } 90 | ``` 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # community-protocols 2 | 3 | As the diversity of custom elements grows wider and deeper, so do the benefits of those elements being able to communicate with each other. Making this possible via reusably well-known protocols can enable components not just from X vendor to communicate with themselves, but also to interoperate with components from Y vendor and Z vendor. This project looks to define community protocols for cross-component coordination that reduce the overhead required to make that possible. 4 | 5 | Think of things like a context API, async work (Suspense-like), SSR-interop, incremental hydration, etc., many of these capabilities get locked into framework APIs that increase the barriers to interoperability. With a community defined protocol, these features and more become attainable and portable across the web components ecosystem. 6 | 7 | ## Get involved 8 | 9 | Check out the [Issues](https://github.com/webcomponents/community-protocols/issues) and [PRs](https://github.com/webcomponents/community-protocols/pulls) currently open on this repo to join in on conversations already inflight regarding a number of exciting areas. If you have ideas on a protocol you'd like raised to the community, open a new [issue](https://github.com/webcomponents/community-protocols/issues/new) so that authors and consumers of libraries and components from across the community who likely have (or wanted to have) implemented features in that area can join in with their use cases as well. Once the rough edges have been hammered out, submit a PR (pull-request) with specs of the protocol and append it to the "Protocols" list below so it can be formalized and put into use across the community. Use this [template](./proposal-template.md) for your proposal's PRs. 10 | 11 | ## Proposals 12 | 13 | | Proposal | Author | Status | 14 | |----------------|------------------|------------| 15 | | [Context] | Benjamin Delarre | Candidate | 16 | | [Defer Hydration] | Justin Fagnani | Proposal | 17 | | [Pending Task] | Justin Fagnani | Draft | 18 | | [Slottable Request] | Kevin Schaaf | Proposal | 19 | 20 | [Context]: https://github.com/webcomponents/community-protocols/blob/main/proposals/context.md 21 | [Defer Hydration]: https://github.com/webcomponents/community-protocols/blob/main/proposals/defer-hydration.md 22 | [Pending Task]: https://github.com/webcomponents/community-protocols/blob/main/proposals/pending-task.md 23 | [Slottable Request]: https://github.com/webcomponents/community-protocols/blob/main/proposals/slottable-request.md 24 | 25 | ## Status 26 | 27 | Community Protocols will go through a number of phases during which they can gather further community insight and approval before becoming "Accepted". These phases are as follows: 28 | 29 | - *Proposal* 30 | "Proposal" status applies to just about anything that community members are interested in putting some thought into. While an issue submitted to this repo can help generate initial ideas on the protocol or space of interest and clarify the information needed to kick off a more fruitful conversation, a PR will serve to make known that you are interested in support to drive the protocol in question forward. Having an initial explainer included in this PR, while adding the protocol to the "Proposals" table shown above, will prepare the community to both communicate about and contribute to the development of the protocol asynchronously. 31 | 32 | - *Draft* 33 | A protocol generally agreed to be an interesting area of investigation is given "Draft" status. At this point, the conversation will pivot away from proving the need for a protocol and towards proving a specific pattern by which the protocol can be achieved across the web components community. Developmental work in this area can be addressed in PRs or via group meetings of the [w3c's Web Components Community Group](https://github.com/w3c/webcomponents-cg) as needed. 34 | 35 | - *Candidate* 36 | Once a protocol has received proper incubation and a pattern for applying it has solidified it will be given "Candidate" status. This status outlines that it will soon be accepted. Community members should use this opportunity to outline any final concerns or adjustments they'd like to see in the protocol before adoption. By this point, the work should mostly be done and the focus will be polish, presentation, and implementation. 37 | 38 | - *Accepted* 39 | An "Accepted" protocol is one that is ready to put into action across the community. With clear APIs, types, usage patterns included they are ready to serve the greater purpose of supporting interoperability between web components built from various contexts. 40 | 41 | To move to "Accepted" a proposal needs __2__ implementations. 42 | 43 | ### Status Graduation 44 | 45 | Community protocols are "generally agreed upon patterns" and not "browser specs", so they will always be a choice you make more so than rules a component developer or library author has to follow. In this way, graduation of a protocol from one status to the next will primarily happen in the absence of hard "nay"s. Your active participation in issues, PRs, or [w3c's Web Components Community Group](https://github.com/w3c/webcomponents-cg) meetings regarding specific protocols will be the best way to advocate for a protocol making its way through this process. 46 | -------------------------------------------------------------------------------- /proposals/pending-task.md: -------------------------------------------------------------------------------- 1 | # pending-task-protocol 2 | 3 | An open protocol for interoperable asynchronous Web Components. 4 | 5 | Author: Justin Fagnani 6 | 7 | Status: Draft 8 | 9 | Last update: 2021-06-20 10 | 11 | # Background 12 | 13 | There are a number of scenarios where a web component might depend on or perform some asynchronous task. Components may lazy-load parts of their implementation or content, perform I/O in response to user input, manage long-running computations, etc. 14 | 15 | It's often desirable to communicate the state of async tasks up the component tree to parent components so that they can display user affordances indicating whether the UI or content is pending some async operation. 16 | 17 | Frameworks have the ability to invent custom APIs and patterns to handle this kind of cross-component communication. To enable the same with web components in a way that's interoperable between components from diffrent sources, and possibly implemented with different libraries, we need a protocol that components can implement without reliance on a common implementation. 18 | 19 | # Goals 20 | 21 | * Allow components to communicate that they have pending asynchonous tasks to ancestors in the DOM tree. 22 | * Allow components to know if there are pending asynchronous tasks in descendants in the DOM tree. 23 | * Allow components to intercept, modify, and block pending task notifications. 24 | * Give guidance on the type of asynchronous work that should be notified. 25 | * Allow for some labelling or differentiation between types of asynchronous work. 26 | 27 | # Details 28 | 29 | ## Asynchronous Task States 30 | 31 | There are four main states that an asynchronous task can be in: 32 | 33 | * Not-started 34 | * Started 35 | * Completed 36 | * Failed 37 | 38 | This protocol represents three of the states (Started, Completed, and Failed) with a promise-carrying DOM event. 39 | 40 | ## The `pending-task` event 41 | 42 | Components with pending tasks indicate so by firing a composed, bubbling, `pending-task` Event, with a `promise` property: 43 | 44 | TypeScript interface: 45 | ```ts 46 | interface PendingTaskEvent extends Event { 47 | complete: Promise; 48 | } 49 | ``` 50 | 51 | Example: 52 | 53 | ```ts 54 | class PendingTaskEvent extends Event { 55 | constructor(complete: Promise) { 56 | super('pending-task', {bubbles: true, composed: true}); 57 | this.complete = complete; 58 | } 59 | } 60 | 61 | // Inside a component definition: 62 | class DoWorkElement extends HTMLElement { 63 | async doWork() { /* ... */ } 64 | 65 | startWork() { 66 | const workComplete = this.doWork(); 67 | this.dispatchEvent(new PendingTaskEvent(workComplete)); 68 | } 69 | } 70 | 71 | // Inside a container component: 72 | class IndicateWorkElement extends HTMLElement { 73 | #pendingTaskCount = 0; 74 | 75 | constructor() { 76 | super(); 77 | this.addEventListener('pending-task', async (e) => { 78 | e.stopPropagation(); 79 | if (++this.#pendingTaskCount === 1) { 80 | this.showSpinner(); 81 | } 82 | await e.complete; 83 | if (--this.#pendingTaskCount === 0) { 84 | this.hideSpinner(); 85 | } 86 | }); 87 | } 88 | } 89 | ``` 90 | 91 | The completion Promise must be resolved when the task is complete and rejected if the task fails. The value the Promise resolves to is unspecified. 92 | 93 | Using an event with a Promise allows us to represent three of the four asynchronous states: 94 | 95 | * Not-started: not represented 96 | * Started: `pending-task` event fired 97 | * Completed: Promise resolved 98 | * Failed: Promise rejected 99 | 100 | ## Cancelling tasks 101 | 102 | This proposal does _not_ cover cancelling tasks. Similar to Promises, this proposal assumes that task cancellation is best done by the task initiators with an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). Objects being notified of a task shouldn't neccessarily be able to cancel it. 103 | 104 | If a task is canceled by other means, the `completed` Promise should be rejected. 105 | 106 | ## Intercepting PendingTask events 107 | 108 | If a part of a UI shows a loading affordance for a subtree, it is recommended that it stop propagation of the PendingTask event so that only one loading affordance is shown. 109 | 110 | ```ts 111 | this.addEventListener('pending-task', async (e) => { 112 | e.stopPropagation(); 113 | // show loading indicator 114 | await e.complete; 115 | // hide loading indicator 116 | }); 117 | ``` 118 | 119 | ## Default actions 120 | 121 | Some UI controls are able to show their own pending task indicators. One example is a form submit button with an embedded spinner. Such a controller may not want to show the loading indicator if a component above it in the tree is also showing one. This can be accomplished with event default actions. 122 | 123 | Listeners can call `e.preventDefault()` on the event: 124 | 125 | ```ts 126 | this.addEventListener('pending-task', async (e) => { 127 | e.preventDefault(); 128 | // show loading indicator 129 | await e.complete; 130 | // hide loading indicator 131 | }); 132 | ``` 133 | 134 | And the control can check if the event is defaulted: 135 | 136 | ```ts 137 | const workComplete = this.doWork(); 138 | const event = new PendingTaskEvent(workComplete); 139 | this.dispatchEvent(event); 140 | if (!event.defaultPrevented) { 141 | this.showLoadingIndicator(); 142 | await workComplete; 143 | this.hideLoadingIndicator(); 144 | } 145 | ``` 146 | 147 | # Open Questions 148 | 149 | ## Types of Async Tasks 150 | 151 | There are different types of async work. Whether this proposal should attempt to classify them and add a `type` field to `PendingTaskEvent` is an open question. 152 | 153 | The argument against is that we simply do not yet know what categories there should be and how to definitively guide authors towards choosing the right type. It's also just additional complexity for component authors. 154 | 155 | An argument for is that there are some types of async work that we may not want to display UI affordances (like spinners) for. Async rendering used in order to yield to the browser's task queue for input hanlding and layout/paint for instance, should probably not trigger a progress indicator. Yet, code that measures style or layout may need to wait for rendering to complete, and so could potentially utilize pending-task for that. 156 | 157 | How can we allow UI affordances like spinners, but not to frequently create them during async rendering? 158 | 159 | We could add a field to the event that indicates the type of work and standardize a small number of types, such as `loading` and `rendering`. An event to indicate async rendering starting and stopping has existing analogies with the `animationstart` and `animationend` events. 160 | 161 | We may also want to specifically recommend against firing `pending-task` events for rendering work because of the pervasiveness of such async rendering with modern web component base classes like LitElement and Stencil. 162 | 163 | ## Work Estimation 164 | 165 | Some use cases, like a progress bar that shows how much work is remaining, could benefit from estimating how much work is pending. 166 | 167 | The `pending-task` event could carry a numeric work estimate property so that containers can estimate the total amount of pending work and incremental progress. 168 | 169 | On the other hand, this may be better suited for ProgressEvent. 170 | -------------------------------------------------------------------------------- /proposals/slottable-request.md: -------------------------------------------------------------------------------- 1 | # Slottable Request Protocol 2 | aka "Render Props for Web Components" 3 | 4 | **Author**: Kevin Schaaf (@kevinpschaaf, Google) 5 | 6 | **Status**: Draft 7 | 8 | **Last Updated**: 2023-11-08 9 | 10 | 11 | # Background 12 | 13 | There are often use cases that require a component to render UI on-demand and/or based on data, but where the specific rendering of the data should be controllable by the user. Use cases of this include: 14 | 15 | * A data table or tree component, which manages the rendering of cells or tree nodes, but where the given look and feel of the items is customizable by the user. 16 | * A virtual list component, which optimizes rendering a list of data by only rendering a subset of user-templated items based on which array instances are visible on the screen. 17 | * A tooltip component whose user-provided slotted content will be rendered rarely and on-demand based on user interactions handled by the tooltip. 18 | * Components that fetch data themselves, but provide the ability for the user to customize the rendering of the data. 19 | 20 | This proposal describes an interoperable protocol for components to request that their owning context render slotted content into themselves, using data provided via the protocol. 21 | 22 | # Goals 23 | 24 | The high-level goal is to introduce a protocol to allow a component to request that the owner provide content to be rendered on-demand, and optionally based on data that is provided by the component. 25 | 26 | Specific goals within this include: 27 | 28 | * The ability for users of a component to render requested content using a templating system of their choice. 29 | * The ability for users of a component to render requested content using data supplied by the component. 30 | * The ability for requested content to be rendered into the requesting component's shadow root at a position of its choice. 31 | * The ability for requested content to be styled in the same "user" scope the component in question was created in, and using styling mechanisms of the user's choice. 32 | * The ability to use a component that requests content via the protocol in plain JS without requiring a framework. 33 | * The ability to implement the protocol inside framework-specific wrappers or helpers that expose a user API that looks similar or identical to framework-specific patterns (i.e. "render props" for React). 34 | * The ability to provide slotted content lazily for better performance, since the component may defer rendering slotted content based on the state of the component. 35 | 36 | # Design / Proposal 37 | 38 | 39 | ## Protocol Definition 40 | 41 | Concretely, the protocol involves dispatching a well-known DOM event to request slotted content (aka "[slottables](https://dom.spec.whatwg.org/#light-tree-slotables)", per the DOM spec) be rendered with the provided `data` argument and assigned to the given `slotName`: 42 | 43 | ```ts 44 | export class SlottableRequestEvent extends Event { 45 | readonly data: T | typeof remove; 46 | readonly name: Name; 47 | readonly slotName: string; 48 | constructor(name: Name, data: unknown, key?: string) { 49 | super('slottable-request', {bubbles: false, composed: false}); 50 | this.name = name; 51 | this.data = data; 52 | this.slotName = key !== undefined ? `${name}.${key}` : name; 53 | } 54 | } 55 | 56 | export const remove = Symbol.for('slottable-request-remove'); 57 | ``` 58 | 59 | When a component wants to render customizable content, it (1) fires a `slottable-request` event for a given slot name, and (2) renders a `` element into its shadow root corresponding to the requested slot name. 60 | 61 | Each time the `slottable-request` event is received, the user should use the provided `data` to render (or update) slotted content into the component that fired the event, where each top-level child rendered for a given request is assigned to the provided `slotName`. The `name` field on the event is used to determine what type of content to render; each unique `name` will generally map to a specific user-managed template. 62 | 63 | Because components may request multiple slotted instances for the same conceptual `name` (for example, in the case of a `list-item` slot request), the `slotName` may differ from the `name`. Thus, `name` should be used to select which template to render, and `slotName` should be assigned to the given rendered _instance_ and used as a unique key to identify which instance to update for subsequent requests. 64 | 65 | When requesting a specific slot instance, the event accepts an optional 3rd `key` argument; this is used to generate a unique `slotName` by suffixing the `name` with `key`. 66 | 67 | The `remove` symbol is provided as a sentinel value for `data` to indicate that the given content assigned to `slotName` should be removed, and should be fired when the associated `` is no longer rendered to avoid leaking light-DOM children. It's defined as `Symbol.for('slottable-request-remove')` so it can be shared between several implementations and make them interoperable. 68 | 69 | 70 | ## Examples 71 | 72 | * ["Simple list example"](https://lit.dev/playground/#gist=2e14f25a47afadcbb18254f7e74d9871) A simple list that demonstrates basic slottable request protocol with list items. 73 | * ["Travel Calendar"](https://lit.dev/playground/#gist=205ee0ccc0ea4d0420608808942d2655) customization example (raw with no helpers) 74 | * [Lit proof-of-concept](https://lit.dev/playground/#gist=2974fec927ef67b30d82a6ff7d05740a). Includes demos of: 75 | * Raw implementation of handling the `slottable-request` event using Lit 76 | * Implementing the protocol using a Lit directive (see [this description](https://gist.github.com/kevinpschaaf/0fe117368411f340aa3019dceeaa465e) of possible API for Lit-specific helpers) 77 | * Implementing the protocol in a React web component wrapper 78 | 79 | 80 | ## Comparison with Render Props 81 | 82 | Simply modeling the need expressed above as a "render prop", aka a property on the component that accepts a function that receives data and produces/updates DOM based on that data, introduces a number of complications making it ill-suited for interoperable web components: 83 | 84 | 1. **Different rendering libraries may be used between the caller and the author.** When both the usage site and the component itself are implemented using the same rendering library, render props are as simple as providing a framework-specific template reference (often a function) to the component that the component can natively render. However, Web Components introduce the capability to implement the "inside" of a component with a different rendering library than the outside library rendering the component (if any). Requiring a render prop to be provided using the same rendering library the component was authored with leaks implementation details and has negative DX implications, forcing the user to context-switch into a different templating language they may not otherwise be familiar with. 85 | 2. **Rendering a user-provided template to Light DOM is risky.** The light DOM children of a component are logically owned by the calling scope (the "user" of the component, not the component itself). Rendering children outside of the shadow root may conflict with user-scope rendering libraries by violating assumptions it makes for reconciling DOM (e.g., incremental DOM diffs against live DOM and may remove children it did not previously render). 86 | 3. **Rendering a user-provided template to Shadow DOM is problematic for styling.** If the component used a render prop to render DOM into its shadow root, that DOM would be subject to the component's shadow styling, rather than the styling of the user's scope. Providing a mechanism to render user styles into the shadow root along with the protocol would likely feel foreign to the caller. 87 | 88 | Defining a "framework-agnostic render prop" protocol was explored in but rejected due to these complications. Instead, this proposal defines an event-based protocol to request the outside scope render and provide slotted children, side-stepping virtually all of the issues raised there. 89 | 90 | ## API alternatives: 91 | 92 | * The "remove" case is handled as a sentinel using the same event. We could have a separate `remove-slottable-request` event, but that seemed a bit annoying to listen for two events. 93 | * Explicitly treating `key` as a first-class thing is a choice; it could be up to the user to generate and request unique slot names, but then it would become another protocol/parsing exercise for the receiver to know that e.g. `item.4` and `item.42` should use the `item` template but `header.3` should use the header template. That seems annoying, so I landed on the requestor providing the `name` and `key` and the user getting a pre-baked `slotName` to use for it, but they still need the un-concatenated `name` to select the template. 94 | * Rather than sending individual `slottable-request` events for each name/key instance, we could define a more complex payload for the event to describe all of the instance data & slots required. That does not lend itself as nicely to helpers to allow rendering different named slots into different parts of the template, since all of those requests would need to be batched by one orchestrator. It's certainly possible to build such a thing, with the batching keyed off of e.g. `part.options.host`, but didn't want to go directly there first. Also, each slottable-request conceptually mapping to a render-prop call seems good for keeping the concepts aligned. We clearly need to perf-test this, however, and that might push us to a single-event model. 95 | -------------------------------------------------------------------------------- /proposals/context.md: -------------------------------------------------------------------------------- 1 | # Context Protocol 2 | 3 | An open protocol for data passing between components. 4 | 5 | Author: Benjamin Delarre 6 | 7 | Document status: Candidate 8 | 9 | Last update: 2025-05-02 10 | 11 | # Background 12 | 13 | There are a number of scenarios where a web component might require data that is provided from outside itself. While components can specify properties and attributes to receive that data imperatively or declaratively, it is often the case that the data may be owned somewhere further up the DOM tree. 14 | 15 | One approach to passing this data down to components is commonly referred to as *prop drilling*, whereby components pass properties all the way down through the hierarchy, passing from component to component until it is consumed at its destination. This is generally considered undesirable as it often requires intermediate components in the tree to have knowledge of the data necessary in its descendents. 16 | 17 | Frameworks and libraries often provide mechanisms for this, these can range from the simple Context implementation available in React, to more complex Dependency Injection frameworks. Web components need a similar protocol in order to solve this problem. 18 | 19 | # Goals 20 | 21 | - Allow elements in the DOM to retrieve data based on their contextual position in the DOM 22 | - Alleviate the problem of *prop drilling* 23 | - Simple API that is easily implemented in any framework / library 24 | - Synchronous protocol, while supporting asynchronous patterns 25 | - Support single or multiple delivery of context values 26 | 27 | # Non-Goals 28 | 29 | **Context API !== Dependency Injection Framework** 30 | 31 | The Context API does not intend to cover all cases and forms of Dependency Injection. It does not specify constructor, factory or property injection patterns. Its only goal is to formalize the pattern of sharing data across the hierarchy in the DOM, specifically avoiding *prop drilling* type scenarios. Dependency Injection patterns could be implemented using this protocol, but this is not the goal and should remain explicitly outside the scope of Context API for simplicity. 32 | 33 | **Context API is not a state management alternative** 34 | 35 | State management libraries often need to solve some similar problems that the Context API helps to solve. An element deep in the DOM tree may need access to some state, and may need to respond to that state being changed. 36 | 37 | While state management could be built using the Context API, it is not a primary goal of the Context API to solve this problem. It is, however, appropriate for state management libraries to use the Context API to resolve state stores and other associated dependencies from deep within the DOM hierarchy; e.g. a component could request a Redux state store via Context. 38 | 39 | # Overview 40 | 41 | At a high level, the Context API is an event-based protocol that components can use to retrieve data from any location in the DOM: 42 | 43 | - A component requiring some data fires a `context-request` event. 44 | - The event carries a `context` value that denotes the data requested, and a `callback` which will receive the data. 45 | - Providers can attach event listeners for `context-request` events to handle them and provide the requested data. 46 | - Once a provider satisfies a request it calls `stopImmediatePropagation()` on the event. 47 | 48 | # Details 49 | 50 | ## The `context-request` event 51 | 52 | Components which wish to receive some data from their ancestors should initiate the request by firing a composed, bubbling, `context-request` Event, with a `callback` property. 53 | 54 | TypeScript interface: 55 | 56 | ```typescript 57 | interface ContextRequestEvent> extends Event { 58 | /** 59 | * The name of the context that is requested 60 | */ 61 | readonly context: T; 62 | /** 63 | * A boolean indicating if the context should be provided more than once. 64 | */ 65 | readonly subscribe?: boolean; 66 | /** 67 | * A callback which a provider of this named callback should invoke. 68 | */ 69 | readonly callback: ContextCallback; 70 | } 71 | ``` 72 | 73 | A full TypeScript definition for this event and its associated types can be found in the [Definitions](#definitions) section at the end of this document. 74 | 75 | ## Context objects 76 | 77 | `ContextRequestEvent`s carry a `context` value that is used to identify specific contexts. This value may sometimes be referred to as the "context key", and can be of any type. 78 | 79 | ### Context equality 80 | 81 | Matching contexts between a provider and consumer is done with strict equality (`===`). This means that a context can be made guaranteed unique by using a key value that's unique under strict equality, like a unique Symbol (not using `Symbol.for()`) or an object. A context can be intentionally made to match other contexts by using a key that's not unique under strict equality like a string or `Symbol.for()`. 82 | 83 | ### Context types 84 | 85 | In TypeScript it's possible to cast values to an interface that carries additional type information. This is useful for contexts to convey the type of the value that the context provides. 86 | 87 | To be interoperable, implementations should cast context keys to a type with the `__context__` key: 88 | 89 | ```ts 90 | export type Context = KeyType & {__context__: ValueType}; 91 | ``` 92 | 93 | Then values can be cast to this to create a typed context key: 94 | 95 | ```ts 96 | export const myContext = 'my-context' as Context; 97 | ``` 98 | 99 | The value type of a Context can then be extracted with a utility type: 100 | 101 | ```ts 102 | export type ContextType = 103 | T extends Context ? V : never; 104 | ``` 105 | 106 | Usage: 107 | ```ts 108 | // MyContextType = number 109 | type MyContextType = ContextType; 110 | ``` 111 | 112 | It is recommended that TypeScript-based implementations provide both `Context` and `ContextType` types. 113 | 114 | ### `createContext` functions 115 | 116 | It is recommended that TypeScript implementations provide a `createContext()` function which is used to create a `Context`. This function can just cast to a `Context`: 117 | 118 | ```ts 119 | export const createContext = (key: unknown) => 120 | key as Context; 121 | ``` 122 | 123 | ## Context Providers 124 | 125 | A context provider will satisfy a `context-request` event, passing the `callback` the requested data whenever the data changes. A provider will attach an event listener to the DOM tree to catch the event, and if it will be able to satisfy the request it _MUST_ call `stopImmediatePropagation` on the event. 126 | 127 | If the provider has data available to satisfy the request then it should immediately invoke the `callback` passing the data. If the event has a truthy `subscribe` property, then the provider can assume that the `callback` can be invoked multiple times, and may retain a reference to the callback to invoke as the data changes. If this is the case the provider should pass the second `unsubscribe` parameter to the callback when invoking it in order to allow the requester to disconnect itself from the providers notifications. 128 | 129 | The provider _MUST NOT_ retain a reference to the `callback` nor pass an `unsubscribe` callback if the `context-request` event's `subscribe` property is not truthy. Doing so may cause a memory leak as the consumer may not ever call the `unsubscribe` callback. 130 | 131 | To safeguard against memory leaks caused by non-compliant consumers that don't call the `unsubscribe` callback, it is recommended that the provider uses WeakRefs to reference subscription callbacks. 132 | 133 | The provider _SHOULD_ call `stopImmediatePropagation` before invoking the callback, or call the callback in a try/catch block, to ensure that an error thrown by the callback does not prevent immediate propagation from being stopped: 134 | 135 | ```js 136 | this.addEventListener('context-request', event => { 137 | event.stopImmediatePropagation(); 138 | // If the callback throws, propagation is already stopped 139 | event.callback('some data'); 140 | }); 141 | ``` 142 | 143 | A provider does not necessarily have to be a Custom Element, but this may be a convenient mechanism. 144 | 145 | ## Usage 146 | 147 | An element which wishes to receive some context and participate in the Context API should emit an event with the `context-request` type. It is suggested that an implementation of the `ContextRequestEvent` would be used something like this: 148 | 149 | ```js 150 | // get a context from somewhere (this could be in any module) 151 | const coolThingContext = createContext('cool-thing'); 152 | 153 | this.dispatchEvent( 154 | new ContextRequestEvent( 155 | coolThingContext, // the context we want to retrieve 156 | (coolThing) => { 157 | this.myCoolThing = coolThing; // do something with value 158 | } 159 | ) 160 | ); 161 | ``` 162 | 163 | If a provider listening for this event can provide the requested context it will invoke the callback passed in the payload of the event. The element can then do whatever it wishes with this value. 164 | 165 | It may also be the case that a provider can retain a reference to this callback, and can then invoke the callback multiple times. In this case providers should pass an unsubscribe function as a second argument to the callback to allow consumers to inform the provider that it should no longer update the element, and should dispose of the callback. 166 | 167 | An element may also provide a `subscribe` boolean on the event detail to indicate that it is interested in receiving updates to the value. 168 | 169 | Consumers should be aware that given that there is a loose coupling between implementations with this protocol that they may need to implement the `callback` handling defensively. An example of a defensive consumer that only wants a value once is provided below: 170 | 171 | ```js 172 | let providedAlready = false; 173 | this.dispatchEvent( 174 | // Note, this event is not a subscribing event: 175 | new ContextRequestEvent(coolThingContext, (coolThing, unsubscribe) => { 176 | // Guard against multiple callback calls in case of bad actor providers 177 | if (!providedAlready) { 178 | this.myCoolThing = coolThing; // do something with value 179 | } 180 | // `unsubscribe()` should be given if `subscribe` was true on the request 181 | // event. But if a bad provider passed an unsubscribe callback anyway, 182 | // you could unsubscribe immediately since we only want it once. 183 | unsubscribe?.(); 184 | }) 185 | ); 186 | ``` 187 | 188 | It is recommended that custom elements which participate in the Context API should fire their `context-request` events in their `connectedCallback()` method. Likewise in their `disconnectedCallback()` method they should invoke any unsubscribe callbacks they have received. 189 | 190 | A more complete example is as follows: 191 | 192 | ```js 193 | class SimpleElement extends HTMLElement { 194 | connectedCallback() { 195 | this.dispatchEvent( 196 | new ContextRequestEvent( 197 | loggerContext, 198 | (value, unsubscribe) => { 199 | // Call the old unsubscribe callback if the unsubscribe call has 200 | // changed. This probably means we have a new provider. 201 | if (unsubscribe !== this.loggerUnsubscribe) { 202 | this.loggerUnsubscribe.?(); 203 | } 204 | this.logger = value; 205 | this.loggerUnsubscribe = unsubscribe; 206 | }, 207 | true // we want this event multiple times (if the logger changes) 208 | ) 209 | ); 210 | } 211 | disconnectedCallback() { 212 | this.loggerUnsubscribe?.(); 213 | this.loggerUnsubscribe = undefined; 214 | this.logger = undefined; 215 | } 216 | } 217 | ``` 218 | 219 | # Open Questions / Previous considerations 220 | 221 | ## Could we use Promises instead of callbacks? 222 | 223 | Many have commented that Promises could be used instead of callback functions. One major drawback of promises is that they cannot be resolved synchronously which would complicate usage of the Context API for simple use-cases. 224 | 225 | Another issue with promises is that they do not allow multiple-resolution. Therefore we would not be able to handle cases where a requested value changes over time. This is a capability that we see in the React Context API and believe is valuable for a variety of use cases. 226 | 227 | While we could restrict this API to only support a single resolution of a requested value, and then use observable mechanisms on that value to achieve data update behaviors, it is believed that this will complicate simple use-cases unnecessarily. 228 | 229 | ## Should requesters get to 'accept' providers? 230 | 231 | The current API as proposed does not allow a requestor to 'approve' that a provider is going to give it the right object. We have some capability to enforce this in TypeScript, but we could provide a slightly different API that would allow the requesting component to check the value it will receive: 232 | 233 | ```js 234 | this.dispatchEvent( 235 | new ContextRequestEvent(loggerContext, (candidate) => { 236 | if (typeof candidate.log === 'function' && typeof candidate.info === 'function') { 237 | // we can accept this candidate so return the callback to the provider 238 | return (logger, unsubscribe) => { 239 | this.logger = logger; 240 | }; 241 | } 242 | }); 243 | ) 244 | ``` 245 | 246 | In this alternative, we expect to always synchronously receive a 'candidate' data value that a provider may give us. We can then inspect this candidate value in our component to determine if it has the correct shape, and then return our callback function to accept updates to it. 247 | 248 | In this proposal we would likely enforce that the callback always be invoked synchronously. 249 | 250 | Alternative APIs could also be explored in this approach, we could for instance have providers append themselves to a list of potential providers along with candidate value objects, and then allow our components to pick which provider they wish to use: 251 | 252 | ```js 253 | const contextRequest = new ContextRequestEvent(loggerContext); 254 | this.dispatchEvent(context); 255 | if (!contextRequest.providers) { 256 | // no providers for logger 257 | return; 258 | } 259 | const provider = contextRequest.providers.find((loggerProvider) => { 260 | // test if the provider is the type we want or the value it provides is right 261 | }); 262 | const unsubscribe = provider.provide(this, (logger) => { 263 | this.logger = logger; 264 | }); 265 | // later... 266 | unsubscribe(); // don't need updates anymore 267 | ``` 268 | 269 | These alternatives do provide more capability, but it's an open question as to whether or not this complexity is warranted or desired. It also opens up a larger question about what would the candidate value be? Would it have to be an object of the requested type, could it be some other protocol to determine uniformity between the requested data and the actual data? This begins to seem more complex than we really need here for unnecessary type safety overhead. It is suggested if consumers want type safety then they should use TypeScript to achieve this. 270 | 271 | # Definitions 272 | 273 | Below are some TypeScript definitions for the common parts of the proposed protocol: 274 | 275 | ```typescript 276 | /** 277 | * A context key. 278 | * 279 | * A context key can be any type of object, including strings and symbols. The 280 | * Context type brands the key type with the `__context__` property that 281 | * carries the type of the value the context references. 282 | */ 283 | export type Context = KeyType & {__context__: ValueType}; 284 | 285 | /** 286 | * An unknown context type 287 | */ 288 | export type UnknownContext = Context; 289 | 290 | /** 291 | * A helper type which can extract a Context value type from a Context type 292 | */ 293 | export type ContextType = 294 | T extends Context ? V : never; 295 | 296 | /** 297 | * A function which creates a Context value object 298 | */ 299 | export const createContext = (key: unknown) => 300 | key as Context; 301 | 302 | /** 303 | * A callback which is provided by a context requester and is called with the value satisfying the request. 304 | * This callback can be called multiple times by context providers as the requested value is changed. 305 | */ 306 | export type ContextCallback = ( 307 | value: ValueType, 308 | unsubscribe?: () => void 309 | ) => void; 310 | 311 | /** 312 | * An event fired by a context requester to signal it desires a named context. 313 | * 314 | * A provider should inspect the `context` property of the event to determine if it has a value that can 315 | * satisfy the request, calling the `callback` with the requested value if so. 316 | * 317 | * If the requested context event contains a truthy `subscribe` value, then a provider can call the callback 318 | * multiple times if the value is changed, if this is the case the provider should pass an `unsubscribe` 319 | * function to the callback which requesters can invoke to indicate they no longer wish to receive these updates. 320 | */ 321 | export class ContextRequestEvent extends Event { 322 | public constructor( 323 | public readonly context: T, 324 | public readonly callback: ContextCallback>, 325 | public readonly subscribe?: boolean 326 | ) { 327 | super('context-request', {bubbles: true, composed: true}); 328 | } 329 | } 330 | 331 | declare global { 332 | interface HTMLElementEventMap { 333 | /** 334 | * A 'context-request' event can be emitted by any element which desires 335 | * a context value to be injected by an external provider. 336 | */ 337 | 'context-request': ContextRequestEvent>; 338 | } 339 | } 340 | ``` 341 | 342 | # Changes 343 | 344 | - 2025-05-02: Added changelog to track changes. 345 | - 2025-05-02: Changed `stopPropagation` to `stopImmediatePropagation`. 346 | - 2024-10-17: Added a provider recommendation to safeguard against memory leaks. 347 | - 2024-06-27: Cleaned up proposal overview. 348 | - 2024-04-09: Fixed type inconsistencies. 349 | - 2023-11-04: Moved proposal to candidate status. 350 | - 2023-04-13: Fixed typos. 351 | - 2023-02-03: Renamed `multiple` to `subscribe`. 352 | - 2023-02-03: Updated context object interface description to allow any type as a context. 353 | - 2022-05-20: Fixed typos. 354 | - 2022-04-21: Clarified ordering of `stopPropagation` / `callback`. 355 | - 2021-11-22: Fixed typos. 356 | - 2021-09-01: Cleaned up proposal overview. 357 | - 2021-07-02: Initial draft. 358 | -------------------------------------------------------------------------------- /proposals/on-demand-definitions.md: -------------------------------------------------------------------------------- 1 | # On-Demand Definitions Protocol 2 | 3 | A protocol for defining custom elements on-demand without relying on top-level 4 | side-effects. 5 | 6 | **Author**: [dgp1130](https://github.com/dgp1130/) 7 | 8 | **Status**: Draft 9 | 10 | **Created**: 2024-11-26 11 | 12 | **Last updated**: 2024-12-16 13 | 14 | ## Background 15 | 16 | Custom elements are defined via the 17 | [`customElements.define`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define) 18 | API. Defining an element is inherently a side-effectful operation, as it 19 | upgrades all elements with the associated tag name which already exist in the 20 | document. 21 | 22 | Traditionally, the `customElements.define` call is placed adjacent to the custom 23 | element class declaration like so: 24 | 25 | ```javascript 26 | export class MyElement extends HTMLElement { 27 | // ... 28 | } 29 | 30 | customElements.define('my-element', MyElement); 31 | ``` 32 | 33 | For the purposes of this protocol, this pattern is considered a "top-level" 34 | custom element definition because the element is defined in the top-level scope. 35 | Any consumer which imports this file will always call `customElements.define` 36 | just by nature of importing it. Code inside functions, classes, or other 37 | constructs which is _not_ executed during the `import` is not considered 38 | "top-level". 39 | 40 | ```javascript 41 | console.log('This is the top-level'); 42 | 43 | export function foo() { 44 | console.log('This is also top-level because it is called from the top-level below.'); 45 | } 46 | foo(); // Calling `foo` from the top-level. 47 | 48 | export function bar() { 49 | console.log('This is *not* top-level because a consumer needs to explicitly call it after import.'); 50 | } 51 | ``` 52 | 53 | Defining custom elements in the top-level scope is useful to ensure they are 54 | always defined, but this creates a top-level side-effect which has a few 55 | negative consequences: 56 | 57 | ### Problem 1: Easy to forget to import a custom element 58 | 59 | It is trivially easy to use a custom element while forgetting to import its 60 | definition, meaning there is no guarantee the element will be defined at 61 | runtime. 62 | 63 | ```javascript 64 | document.querySelector('my-element').doSomething(); 65 | // ^ Could error: No guarantee `my-element` has been defined. 66 | ``` 67 | 68 | Contrast this with: 69 | 70 | ```javascript 71 | import './my-element.js'; 72 | 73 | document.querySelector('my-element').doSomething(); 74 | // ^ Know `my-element` definition has been loaded. 75 | ``` 76 | 77 | An import is necessary to express that this file depends on the definition of 78 | `my-element` as a top-level side-effect from `my-element.js`. This import could 79 | be hundreds or even thousands of lines away from its usage with no clear 80 | association between the two. 81 | 82 | ### Problem 2: Top-level side-effects are not tree shakable 83 | 84 | Consider a file which exports two elements: 85 | 86 | ```javascript 87 | export class MyFirstElement extends HTMLElement { 88 | // ... 89 | } 90 | 91 | customElements.define('my-first-element', MyFirstElement); 92 | 93 | export class MySecondElement extends HTMLElement { 94 | // ... 95 | } 96 | 97 | customElements.define('my-second-element', MySecondElement); 98 | ``` 99 | 100 | A consumer of this file can trivially choose to import only `MyFirstElement` xor 101 | `MySecondElement`. It could also run 102 | `document.createElement('my-first-element')` xor 103 | `document.createElement('my-second-element')`. However, any bundler needs to 104 | retain _both_ `customElements.define` calls because they exist in the top-level 105 | scope. This is necessary for correctness (as `MySecondElement` could depend on 106 | `MyFirstElement`) and because a bundler cannot confidently prove that either 107 | element will not used at runtime. 108 | 109 | Any module which imports _any_ symbol from this file is forced to retain _all_ 110 | of them in the final JS bundle. This is just one example, but the issue extends 111 | to all usages of all custom elements. In order to safely use a custom element, a 112 | module must import that element, but since doing so typically calls 113 | `customElements.define` in the top-level scope, that element can _never_ be tree 114 | shaken, even if it is ultimately never used. Consider the following dev-mode 115 | only usage of a particular element: 116 | 117 | ```javascript 118 | import './my-element.js'; 119 | 120 | // Set to a compile-time constant known by the bundler. 121 | if (import.meta.env.DEV) { 122 | document.querySelector('my-element').doSomething(); 123 | } 124 | ``` 125 | 126 | Even if a bundler correctly configures the production build such that 127 | `import.meta.env.DEV === false`, it can only remove the `doSomething` call from 128 | the bundle. `import './my-element,js';` still needs to remain due to the global 129 | side-effect it creates and which the bundler cannot prove is unused. 130 | 131 | ### Problem 3: Side-effectful imports are sub-optimal 132 | 133 | Relying on side-effectful imports brings negative consequences. Tooling can not 134 | statically detect whether such an import is required or not. If a future 135 | developer removes `.doSomething()` in the above example, no automated tooling 136 | will instruct them to also remove `import './my-element.js';`. This leads to 137 | extra, unneeded dependencies and reduces confidence for developers who may be 138 | hesitant to remove the import because it is hard to know whether anything in the 139 | module actually does depend on any side-effects from `my-element.js`. 140 | 141 | For human developers, it is in no way obvious that calling `doSomething` 142 | requires an import of `./my-element.js` or that the import exists to provide 143 | `doSomething`. Extensive comments are necessary to communicate the relationship 144 | between these two statements for developers unfamiliar with these constraints. 145 | 146 | ### Problem 4: File ordering 147 | 148 | When forgetting an import on a custom element, using that element is actually 149 | not guaranteed to fail. It only has the _potential_ to fail, which is in many 150 | ways worse. When two JavaScript files do not have any `import`-relationship 151 | between them, which one is actually executed first is ultimately 152 | _unpredictable_. 153 | 154 | The ordering between such files is largely dependent on which other files in the 155 | program import them. Different bundlers may have different behavior and sort 156 | these files completely differently. Normally that's fine: if the files don't 157 | import each other and have no dependency relationship between them, it doesn't 158 | actually matter which one comes first. 159 | 160 | However, side-effects from the first file executed are observable in the second. 161 | Since defining a custom element is inherently a side-effectful operation, there 162 | is a potential file ordering hazard. When one file defines a custom element, and 163 | the other uses that custom element, the system will work only when that ordering 164 | aligns in this unpredictable way. 165 | 166 | ```javascript 167 | // my-element.js 168 | 169 | class MyElement extends HTMLElement { 170 | doSomething() { 171 | console.log('Doing something!'); 172 | } 173 | } 174 | 175 | customElements.define('my-element', MyElement); 176 | ``` 177 | 178 | ```javascript 179 | // my-user.js 180 | 181 | // Note the absence of an `import` here. 182 | 183 | document.querySelector('my-element').doSomething(); 184 | // ^ MIGHT fail. 185 | ``` 186 | 187 | The lack of an import between `my-user.js` and `my-element.js` means that this 188 | will work if-and-only-if `my-element.js` happens to be sorted _before_ 189 | `my-user.js`, which depends entirely on the bundler implementation in use as 190 | well as other files and import edges in the program. Unrelated refactorings can 191 | change the ordering of these two files and lead to unexpected errors. 192 | 193 | ## Goals 194 | 195 | * Provide a common primitive for custom element libraries and frameworks to 196 | confidently rely on a custom element they don't own being correctly defined. 197 | * Support tree-shakable custom elements. 198 | * More closely couple a custom element's usage with a dependency on its 199 | implementation. 200 | * Improve the ability for standard JavaScript tools (bundlers, type checkers, 201 | linters, etc.) to reason about custom element usage. 202 | * Reduce reliance on side-effects from files not explicitly depended upon. 203 | * Continue to support custom element definition as a top-level side-effect 204 | when necessary. 205 | 206 | ## Non-goals 207 | 208 | * _Do not_ address the problem of identifying and defining 209 | ["entry-point components"](#defining-entry-point-elements). 210 | * _Do not_ remove all top-level side-effects from custom element definitions. 211 | Some may still be necessary. 212 | 213 | ## Overview 214 | 215 | This protocol specifies a static `define` property on custom elements which 216 | calls `customElements.define` if the element has not already been defined. This 217 | allows anything with a reference to a component's class to define that component 218 | "on demand" before using it. 219 | 220 | ### Example 221 | 222 | An element can implement this protocol by specifying a static `define` function: 223 | 224 | ```javascript 225 | export class MyElement extends HTMLElement { 226 | static define() { 227 | // Check if the tag name was already defined by another class. 228 | const existing = customElements.get('my-element'); 229 | if (existing) { 230 | if (existing === MyElement) { 231 | return; // Already defined as the correct class, no-op. 232 | } else { 233 | throw new Error(`Tag name \`my-element\` already defined as \`${ 234 | existing.name}\`.`); 235 | } 236 | } 237 | 238 | customElements.define('my-element', MyElement); 239 | } 240 | } 241 | ``` 242 | 243 | A consumer can use this protocol by checking if the `define` property exists and 244 | calling it before using the element. 245 | 246 | ```javascript 247 | import {MyElement} from './my-element.js'; 248 | 249 | MyElement.define(); 250 | document.querySelector('my-element').doSomething(); 251 | // ^ Always works! 252 | ``` 253 | 254 | ## Design detail 255 | 256 | This structure removes usage of `customElements.define` in the top-level scope 257 | and allows web component libraries / frameworks to design APIs which depend on 258 | this primitive. 259 | 260 | ### Framework utilization 261 | 262 | As two small examples, [HydroActive](https://github.com/dgp1130/HydroActive/) 263 | already implements this draft. All `HydroActive` components come with a built-in 264 | `define` implementation and using a component 265 | [requires a reference to its component class](#hydroactive) before making that 266 | component accessible to developers. HydroActive uses this class to automatically 267 | call `define` and ensure they are properly defined being being interacted with 268 | by the developer. 269 | 270 | DISCLAIMER: The author of this proposal is also the maintainer of HydroActive. 271 | 272 | ```javascript 273 | import {component} from 'hydroactive'; 274 | import {SomeComp} from './some-comp.js'; 275 | 276 | export const MyElement = component('my-element', (host) => { 277 | // `.access(SomeComp)` implicitly calls `SomeComp.define()`. 278 | host.query('some-comp').access(SomeComp).element.doSomething(); 279 | }); 280 | ``` 281 | 282 | This approach provides a guarantee that `SomeComp` is defined before 283 | `doSomething` is called. It also allows `SomeComp` to be tree-shaken from the 284 | bundle if it is not used by `MyElement` or if `MyElement` is itself unused and 285 | eligible for tree-shaking. 286 | 287 | One more potential example would be `lit-html`, which currently relies on 288 | [`lit-analyzer`](#lit-analyzer) to ensure all dependencies of a custom element 289 | have been defined. Lit could hypothetically be updated to use explicit 290 | references to custom element classes: 291 | 292 | ```javascript 293 | import {MyElement} from './my-element.js'; 294 | 295 | function renderMyElement() { 296 | return html` 297 | <${MyElement}>Hello, World! 298 | `; 299 | } 300 | ``` 301 | 302 | The `html` tagged template literal could implicitly call `MyElement.define()` to 303 | ensure it is defined prior to rendering. This ensures `MyElement` is indeed 304 | defined and allows it to be tree-shaken if `renderMyElement` is itself 305 | tree-shaken. 306 | 307 | ### Benefits 308 | 309 | This protocol fixes or improves all of the above specified problems: 310 | 311 | 1. Removing `customElements.define` from the top-level scope allows bundlers to 312 | effectively tree-shake any unused components. In order to define an element 313 | with `MyElement.define()`, users must have a reference to `MyElement` which 314 | is known to the bundler. 315 | 2. This API provides a common primitive for web component libraries and 316 | frameworks to [build on top of](#framework-utilization). Designing APIs with 317 | this protocol in mind allows compilers and linters to effectively require 318 | that an import is used where necessary. 319 | 3. Similar to 2., web component APIs can 320 | [be designed to leverage this protocol](#framework-utilization) and more 321 | closely associate a web component class with its usage. 322 | 4. Leveraging an import on a custom element class enforces strict ordering and 323 | makes it harder for users of that class to depend on an element prior to its 324 | class' definition. 325 | 326 | ### Compatibility with top-level definitions 327 | 328 | Any usages of a custom element which do require it to be defined at top-level 329 | execution can still be supported by calling `MyElement.define()` at that 330 | location. It is perfectly valid to write: 331 | 332 | ```javascript 333 | export class MyElement extends HTMLElement { 334 | static define() { /* ... */ } 335 | 336 | doSomething() { 337 | console.log('Doing something!'); 338 | } 339 | } 340 | 341 | MyElement.define(); 342 | 343 | document.querySelector('my-element').doSomething(); 344 | ``` 345 | 346 | Even though `MyElement.define` is called in the top-level scope, this is still a 347 | correct implementation and usage of the static `define`. Any other consumers of 348 | `MyElement` can call `MyElement.define` to enforce that the element is indeed 349 | defined before they use it, so this is still a useful pattern. The fact that the 350 | element happens to always be defined before any consumers attempt to use it is 351 | an implementation detail those consumers are intentionally abstracting away from 352 | themselves by calling `MyElement.define`. 353 | 354 | ## Open questions 355 | 356 | Some more nuanced questions with this API. 357 | 358 | ### Naming 359 | 360 | The name `define` was chosen for being short, direct, and explicit about what it 361 | does: defining an element in the registry. 362 | 363 | This name might be too short such that developers may want to use the name for 364 | their own purposes. For example, a custom element representing words in a 365 | dictionary may want a `define` function which shows the definition of its word. 366 | 367 | Another challenge is that `define` does not always define its element. If the 368 | associated element is already defined, then the `define` function is technically 369 | a no-op, which can be confusing. 370 | 371 | ```javascript 372 | import { MyElement } from './my-element.js'; 373 | 374 | // Defines the element. 375 | MyElement.define(); 376 | 377 | // Silently does nothing. 378 | MyElement.define(); 379 | ``` 380 | 381 | The necessary post-condition for this function is that `MyElement` is defined in 382 | the custom elements registry. Even if the second call technically had no effect, 383 | the outcome is still that `MyElement` is definitely defined. 384 | 385 | Alternatives names may be considered for bike-shedding. 386 | 387 | ### Scoped Custom Element Registries support 388 | 389 | The 390 | [Scoped Custom Element Registries proposal](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Scoped-Custom-Element-Registries.md) 391 | is not yet finalized and therefore not included in the implementation of this 392 | proposal. However, if and when that proposal is completed, it can be easily 393 | included with a small addition which accepts a custom element registry and a tag 394 | name as input parameters: 395 | 396 | ```javascript 397 | export class MyElement extends HTMLElement { 398 | static define(registry = customElements, tagName = 'my-element') { 399 | // Tag name can only be modified when not in the global registry. 400 | if (registry === customElements && tagName !== 'my-element') { 401 | throw new Error('Cannot use a non-default tag name in the global custom element registry.'); 402 | } 403 | 404 | // Check if the tag name was already defined by another class. 405 | const existing = registry.get(tagName); 406 | if (existing) { 407 | if (existing === Clazz) { 408 | return; // Already defined as the correct class, no-op. 409 | } else { 410 | throw new Error(`Tag name \`${tagName}\` already defined as \`${ 411 | existing.name}\`.`); 412 | } 413 | } 414 | 415 | // Define the class. 416 | registry.define(tagName, Clazz, options); 417 | } 418 | } 419 | 420 | // Usage: 421 | const registry = new CustomElementRegistry(); 422 | MyElement.define(registry); 423 | 424 | // With custom tag name: 425 | MyElement.define(registry, 'my-other-element'); 426 | ``` 427 | 428 | This implementation registers the custom element on any given registry, 429 | defaulting to the global registry. 430 | 431 | Attempting to define a custom tag name on the global registry still throws in 432 | order to maintain the invariant that all consumers of the global registry use 433 | the agreed-upon name. See [custom tag name](#custom-tag-name). 434 | 435 | ### Scoped Custom Element Registries as an alternative 436 | 437 | Scoped Custom Element Registries have many nice properties which potentially 438 | allow them to serve as an alternative solution to many of the goals of this 439 | proposal. 440 | 441 | Scoped registries do not require global side-effects like a top-level 442 | `customElements.define` call and allow every consumer to create their own 443 | registry and define dependencies at an appropriate time. This enables components 444 | to be tree-shaken when not used. 445 | 446 | ```javascript 447 | class MyElement extends HTMLElement { 448 | // ... 449 | } 450 | 451 | function useMyElement() { 452 | const myRegistry = new CustomElementRegistry(); 453 | myRegistry.define('my-element', MyElement); 454 | // ... 455 | } 456 | ``` 457 | 458 | However, scoped custom element registries have some drawbacks which make them a 459 | non-ideal solution to this proposal. 460 | 461 | First, scoped registries are coupled to shadow DOM, which not all custom 462 | elements use. This On-Demand Definitions proposal supports all custom elements, 463 | even light DOM components. Note that a 464 | [more recent scoped registry proposal](https://github.com/whatwg/html/issues/10854) 465 | may lift this particular restriction. 466 | 467 | Second, scoped registries require creating an entirely distinct registry with 468 | potentially decoupled tag names. This places a constraint on consumers which 469 | need to manually define a mapping of `some-tag-name` -> `MyElement`. This 470 | constraint is reasonable within the context of a scoped registry, but is 471 | completely unnecessary for the goals of this proposal. Not every consumer of an 472 | element wants its own custom registry or to decouple and own its own mapping of 473 | tag names. 474 | 475 | Third, as shown in 476 | [scoped registries support](#scoped-custom-element-registries-support), this 477 | proposal can support scoped registries and even provides some benefit for them. 478 | Having a `define` function owned by the component author provides an abstraction 479 | over the tag name in the global registry and 480 | [the `options` field](#allowing-options). 481 | 482 | Fourth, scoped registries also require removing the top-level 483 | `customElements.define` call anyways to realize their benefits, which On-Demand 484 | Definitions naturally achieves as well. 485 | 486 | Fifth, some component consumers may use a scoped custom elements registry, but 487 | others may not and should still receive the benefits of this proposal. Using a 488 | scoped registry does not address any of these problems for components in the 489 | global registry, while this On-Demand Definitions proposal does. It is perfectly 490 | valid for two different consumers to call `MyElement.define()` in the global 491 | registry while a third consumer uses a scoped registry. All three receive the 492 | benefits of this proposal. 493 | 494 | For these reasons, scoped registries are not a better solution for the goals of 495 | this proposal. 496 | 497 | ### No-op when already defined 498 | 499 | `define` silently no-ops when its element is already defined, meaning multiple 500 | consumers can safely call `define` without fear of negative effects. 501 | 502 | ALTERNATIVE PROPOSAL: Let `define` throw if the element is already defined and 503 | have callers check `customElements.get` before invoking it. 504 | 505 | Requiring callers to check `customElements.get` means that every usage of 506 | `define` either needs separate knowledge of the element's tag name or needs to 507 | manually call `customElements.getName`, which is extra effort which accomplishes 508 | very little. No user of a custom element can safely assume it is the _only_ user 509 | of that element and unconditionally call `define`. 510 | 511 | ### Custom tag name 512 | 513 | `define` only supports defining the element with one hard-coded tag name. This 514 | behavior is equivalent to calling `customElements.define` in the top-level 515 | scope. Consumers of the class are not able to influence the tag name used. 516 | 517 | ALTERNATIVE PROPOSAL: Let the user provide an optional tag name to give 518 | consumers more flexibility. 519 | 520 | ```javascript 521 | export class MyElement extends HTMLElement { 522 | static define(tagName) { 523 | // ... 524 | 525 | customElements.define(tagName ?? 'my-element', MyElement); 526 | } 527 | } 528 | ``` 529 | 530 | Supporting user-defined tag names means a custom element no longer has static 531 | knowledge of its tag name. While some custom element authors may be comfortable 532 | that constraint, it is likely not something this protocol would want to force on 533 | its implementers. 534 | 535 | `customElements.define` throws when given the same class for multiple tag names. 536 | This is an issue when multiple consumers call `MyElement.define` with different 537 | tag names, expecting to safely define their chosen tag name, only to encounter 538 | an error. 539 | 540 | The main goal of this protocol is to allow multiple, unrelated consumers to 541 | safely use a custom element without interfering with each other or relying on 542 | unexpected side-effects. Allowing each consumer to pick its own tag name and 543 | break any other consumer using a different tag name actively fights against the 544 | goals of this protocol. 545 | 546 | ### Allowing options 547 | 548 | The static `define` function does not support any additional options, such as 549 | [the options of `customElements.define`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#options). 550 | 551 | ALTERNATIVE PROPOSAL: Allow `options` to be passed through as parameters to 552 | `define`: 553 | 554 | ```javascript 555 | class MyElement extends HTMLElement { 556 | static define(options) { 557 | // ... 558 | 559 | customElements.define('my-element', MyElement, options); 560 | } 561 | } 562 | ``` 563 | 564 | Currently, the only option supported by `customElements.define` is 565 | [`extends`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#extends) 566 | which allows a custom element to extend an existing built-in tag name and 567 | leverage the 568 | [`is` attribute](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#using_a_custom_element). 569 | 570 | Whether or not an element extends a built-in class is a decision made by the 571 | author of that element, not its consumers. Allowing consumers to decide the 572 | `extends` option would increase the likelihood of the field being set 573 | incorrectly and encountering unsupported behavior. 574 | 575 | It also raises the question of what happens when two `define` calls disagree on 576 | the option. 577 | 578 | ```javascript 579 | import { MyElement } from './my-element.js'; 580 | 581 | export function createParagraph() { 582 | MyElement.define({ extends: 'p' }); 583 | return document.createElement('p', { 584 | is: 'my-element', 585 | }); 586 | } 587 | 588 | export function createSection() { 589 | MyElement.define({ extends: 'section' }); 590 | return document.createElement('section', { 591 | is: 'my-element', 592 | }); 593 | } 594 | ``` 595 | 596 | Whichever function is called first will dictate the actual `extends` value used. 597 | Meanwhile the other function will find `MyElement` defined with the wrong 598 | `extends` value and its returned element will not be an instance of `MyElement` 599 | as expected. If ordering is non-deterministic or unpredictable, this behavior 600 | can easily lead to bugs. 601 | 602 | Instead, custom element definition options may only be specified within the 603 | `define` function itself, where the component author can control its value. 604 | 605 | ```javascript 606 | class MyElement extends HTMLParagraphElement { 607 | static define() { 608 | // ... 609 | 610 | customElements.define('my-element', MyElement, { extends: 'p' }); 611 | } 612 | } 613 | ``` 614 | 615 | Future additions to this options object maybe be more appropriate to make 616 | configurable for individual consumers and considered on a case-by-case basis. 617 | However, `customElements.define` is naturally creating a side-effect which 618 | stores the provided component class and its configuration. When multiple 619 | consumers are defining a component on-demand, they need to agree on that 620 | configuration. This implies that no consumer can have direct control over the 621 | configuration or else it would risk breaking other consumers when they are 622 | forced to use a component with a configuration they did not expect. It is highly 623 | unlikely future options introduced to `customElements.define` will support being 624 | independently configurable for multiple consumers, therefore that functionality 625 | is intentionally _not_ exposed. 626 | 627 | ### Defining entry-point elements 628 | 629 | A problem deliberately _not_ solved by this protocol is identification of 630 | "entry-point elements". In this context, an "entry-point element" is one which 631 | must be defined in the top-level scope of some script in order to create a 632 | desired behavior on the page. 633 | 634 | If all custom elements move their definitions into a static `define` and no 635 | script calls any of them from the top-level scope, then _no_ element is ever 636 | defined or upgraded. For example, consider an application with the following 637 | HTML: 638 | 639 | ```html 640 | 641 | 642 | 643 | ``` 644 | 645 | Consider also that `MyApp` is defined in line with this protocol, omitting 646 | top-level side-effects: 647 | 648 | ```javascript 649 | // my-app.js 650 | 651 | import {SomeComp} from './some-comp.js'; 652 | 653 | export class MyApp extends HTMLElement { 654 | static define() { 655 | // ... 656 | } 657 | 658 | // ... 659 | } 660 | 661 | // No top-level `customElements.define` or `MyApp.define`. 662 | ``` 663 | 664 | `my-app.js` _needs_ to call `customElements.define('my-app', MyApp)` / 665 | `MyApp.define()` in its top-level scope in order for the application to start. 666 | 667 | However in this scenario, `MyApp.define` is never called and the entire element, 668 | as well as its dependencies, are never defined or have any effect on the 669 | document. 670 | 671 | This illustrates the need for _some_ custom element to be intentionally defined 672 | in the top-level scope in order to upgrade its element during script execution 673 | as well as define any custom element dependencies it may require. In this case, 674 | something needs to identify `MyApp` as an "entry-point element" which must be 675 | defined in the top-level scope and call `MyApp.define()`. This can be hard-coded 676 | by the developer in the top-level scope of `my-app.js` or handled by some other 677 | tool. 678 | 679 | Identifying entry-point elements, retaining them in the bundle, and triggering 680 | their definition is a complex problem in its own right and out of scope for this 681 | particular proposal. 682 | 683 | ### Development-only checks 684 | 685 | The [example implementation](#example) includes a check which throws if the tag 686 | name was already defined by a different class. This is useful for development 687 | purposes in case of a tag name conflict, However, it is very unlikely to be a 688 | problem in production applications after the developer has resolved any relevant 689 | issues. 690 | 691 | Therefore it is acceptable to omit this particular check and no-op in production 692 | for the case of conflicting class definitions. This enables a small bundle size 693 | improvement without affecting valid usage. A _minimal_ implementation of the 694 | protocol looks like: 695 | 696 | ```javascript 697 | export class MyElement extends HTMLElement { 698 | static define() { 699 | // If already defined, no-op. 700 | // Might be the wrong class, but we don't care in a well-formed application. 701 | if (customElements.get('my-element')) return; 702 | 703 | customElements.define('my-element', MyElement); 704 | } 705 | } 706 | ``` 707 | 708 | ### Why not inline the `define` implementation? 709 | 710 | ALTERNATIVE PROPOSAL: The static `define` implementation is relatively small and 711 | condensed, just execute that whenever there is a need to use a custom element. 712 | 713 | ```javascript 714 | import {MyElement} from './my-element.js'; 715 | 716 | if (!customElements.get('my-element')) { 717 | customElements.define('my-element', MyElement); 718 | } 719 | 720 | document.querySelector('my-element').doSomething(); 721 | ``` 722 | 723 | This is functionally equivalent to calling `MyElement.define` but does not 724 | require `MyElement` to opt-in to this community protocol. Inlining `define` does 725 | come with a few costs however. 726 | 727 | First, `MyElement.define` provides an abstraction which encapsulates the tag 728 | name and options passed to `customElements.define`. Without this abstraction, it 729 | becomes more likely multiple consumers will define the same element multiple 730 | times with different choices and run into the same problems as 731 | [allowing options in the static `define` function](#allowing-options) does. 732 | 733 | Second, inlining requires knowledge of the tag name. To call 734 | `customElements.define`, the caller must know the tag name to define. In 735 | general, every consumer of an element likely does need to know the tag name it 736 | is consuming, however libraries or frameworks may want to handle calling 737 | `customElements.define` automatically in a context where they don't necessarily 738 | know the expected tag name and would require a product developer to manually 739 | provide this information every time. 740 | 741 | Third, when given a custom element which has not been defined, is it reasonable 742 | to directly define that element? Most existing custom elements expect a single, 743 | centralized `customElements.define` call and do not anticipate that they may be 744 | defined at any time. 745 | 746 | Consider a component written in the traditional style with an adjacent top-level 747 | side-effect and no knowledge of the On-Demand Definitions protocol or an 748 | equivalent "Just call `customElements.define` before you use it" convention: 749 | 750 | ```javascript 751 | export class MyElement extends HTMLElement { 752 | // ... 753 | } 754 | 755 | doSomething(MyElement); 756 | 757 | customElements.define('my-element', MyElement); // Throws an error. 758 | 759 | function doSomething(elClass) { 760 | // Conditionally define the class if necessary. 761 | if (!customElements.get('my-element')) { 762 | customElements.define('my-element', elClass); 763 | } 764 | 765 | document.querySelector('my-element').doSomethingElse(); 766 | } 767 | ``` 768 | 769 | Because `doSomething` uses the convention of conditionally defining the element, 770 | it is able to define `MyElement` *before* the adjacent top-level side-effect, 771 | causing it to throw an error. Any code with a reference to `MyElement` prior to 772 | its definition could potentially cause this problem. An unexpected early 773 | definition can also be observed through other `customElements` APIs like `get`, 774 | `getName`, or `whenDefined` which could affect component logic in unanticipated 775 | ways. 776 | 777 | Therefore On-Demand Definitions is better implemented as an opt-in decision by 778 | any given component. By implementing the static `define` function, a component 779 | essentially states: "I do not expect to be defined by a specific, centralized 780 | `customElements.define` call." This guarantee is what allows decentralized 781 | `define` calls to work consistently. 782 | 783 | Fourth, to support tree-shaking, top-level side-effects need to be removed 784 | regardless of whether a separate `define` abstraction is used. This begs the 785 | question: What should a web component author do with their existing 786 | `customElements.define` call? There are a few options: 787 | 788 | 1. Move it to a separate ES module and tell consumers to import that when 789 | top-level side-effects are needed. 790 | 1. Delete it entirely and expect every consumer to follow the convention of 791 | conditionally defining `MyElement` before using it. 792 | 1. Move it into their own (not specified by a community protocol) version of a 793 | static `defineMyComponent` function with none of the interoperability 794 | benefits. 795 | 796 | These each have their own trade offs and every component author is likely to 797 | make an independent decision leading to divergence within the web component 798 | space. This makes consuming web components even harder because consumers have to 799 | ask "How do I ensure this component is defined?" every time they adopt a new 800 | custom element. 801 | 802 | On-Demand Definitions provides a recommended answer to this complicated question 803 | which maximizes compatibility with the rest of the ecosystem. 804 | 805 | Finally, there is a small bundle size and stability argument to make here. 806 | Conditionally defining a custom element is not quite trivial and has a few 807 | unexpected edge cases (ex. the component is already defined but with a different 808 | class). The fewer times this function is implemented, the less likely bugs will 809 | be introduced and the less JavaScript users need to download. Also components 810 | are likely to be consumed more frequently than they will be implemented. 811 | Therefore it follows that it will be slightly more optimal for component 812 | definitions to implement `define` rather than asking every consumer of the 813 | component to implement conditional define logic itself. 814 | 815 | Note that custom element libraries and frameworks do skew this reasoning 816 | slightly, however the general rule of component usage (even if implemented by a 817 | small number of frameworks) outnumbering component definitions (even if also 818 | implemented by a small number of frameworks) should hold in most environments. 819 | 820 | ### Why not use `customElements.whenDefined`? 821 | 822 | It is possible to await a custom element definition via 823 | `customElements.whenDefined` which would allow a module to be more tolerant of a 824 | dependency component being defined after it. 825 | 826 | ALTERNATIVE PROPOSAL: Use `customElements.whenDefined` to wait for a component 827 | to be defined somewhere else. 828 | 829 | ```javascript 830 | const el = document.querySelector('my-element'); 831 | 832 | // Wait until the element is defined. 833 | customElements.whenDefined('my-element').then(() => { 834 | el.doSomething(); // Definitely defined now. 835 | }); 836 | ``` 837 | 838 | While `customElements.whenDefined` is a useful primitive, it is insufficient to 839 | meet the goals of this proposal because it: 840 | 841 | 1. relies on _some other module_ in the program to import the definition 842 | of `my-element`, which is not guaranteed to happen synchronously or ever at 843 | all. 844 | 1. "colors" all usage of custom elements to be async which can 845 | block many otherwise-reasonable API contracts. 846 | * For example, both the Lit and HydroActive use cases are synchronous and 847 | would be incompatible with asynchronously waiting for dependencies to be 848 | defined. 849 | 1. requires independent knowledge of the tag name for an element which might 850 | not be known in generic contexts such as libraries or frameworks. 851 | * `MyElement.define();` only requires a reference to the custom element 852 | class, not its tag name. 853 | 1. does not improve tree-shakability of components. 854 | 855 | Beyond those functional points, `customElements.whenDefined` notably does *not* 856 | define a custom element or provide a definition, weakening the relationship 857 | between a custom element and this specific usage. This is very different from 858 | the intent behind On-Demand Definitions which opts to strengthen this 859 | relationship to ensure that a specific, known custom element class is defined 860 | before it is used. 861 | 862 | `customElements.whenDefined` is great for use cases attempting to use a custom 863 | element's definition which may or may not be provided by something else on the 864 | page at any time. That does not describe the problem statement of this proposal 865 | which wants a specific custom element to always be defined at a specific moment 866 | in time. This indicates that `customElements.whenDefined` is the wrong primitive 867 | to solve this particular problem. 868 | 869 | ## Previous considerations 870 | 871 | There is some prior art to be aware of in this space. 872 | 873 | ### `lit-analyzer` 874 | 875 | Lit components expect any of their dependencies to already be defined on the 876 | page prior to being used. This results in a pattern where a Lit element needs to 877 | have a side-effectful import on its dependencies to ensure any custom elements 878 | it needs are already defined. 879 | 880 | ```javascript 881 | import './my-element.js'; 882 | 883 | function renderMyElement() { 884 | return html` 885 | Hello, World! 886 | `; 887 | } 888 | ``` 889 | 890 | A problem with this developer experience is that it is very easy to forget to 891 | import `my-element.js` or accidentally rely on the side-effect of another file 892 | importing it. 893 | 894 | [`lit-analyzer`](https://www.npmjs.com/package/lit-analyzer) solves this with 895 | its `no-unknown-tag-name` and `no-missing-import` checks which analyze source 896 | code to ensure that any usage of an element in the `html` literal is covered by 897 | a direct import. This validates that every Lit component has a direct dependency 898 | on any elements they require. 899 | 900 | While useful, `lit-analyzer` unfortunately requires the developer to integrate a 901 | distinct service into their toolchain with special knowledge of Lit templates, 902 | which otherwise does not require any such tooling. 903 | 904 | ### HydroActive 905 | 906 | A key goal of [HydroActive](https://github.com/dgp1130/HydroActive/) is to 907 | ensure that custom elements are not accessible to the developer until they have 908 | been defined and hydrated. To that end, HydroActive intentionally hides custom 909 | elements from developers and throws when attempting to access them directly. 910 | 911 | ```javascript 912 | import {component} from 'hydroactive'; 913 | 914 | export const MyElement = component('my-element', (host) => { 915 | // Can access native elements like `
` directly. 916 | host.query('div').access().element.textContent = 'Hello, World!'; 917 | 918 | // ERROR: Custom element `SomeComp` requires an element class. 919 | host.query('some-comp').access(); 920 | }); 921 | ``` 922 | 923 | This throws even when `some-comp` is already defined because it could be defined 924 | coincidentally due the [file ordering problem](#problem-4-file-ordering). 925 | 926 | Instead, HydroActive requires the custom element class to be directly provided 927 | to 928 | [`.access()`](https://github.com/dgp1130/HydroActive/blob/605adcc0bfac70a820c7fdf8fce2a4bfc5c55765/src/dehydrated.ts#L85). 929 | 930 | ```javascript 931 | // Works. 932 | host.query('some-comp').access(SomeComp).element.textContent = 'Hello, World!'; 933 | ``` 934 | 935 | This ensures that the class declaration of `SomeComp` comes before `MyElement`. 936 | HydroActive intentionally makes this the only easy way of getting access to an 937 | custom element to ensure that this dependency exists. 938 | 939 | However even this approach is forced to assume that a custom element class 940 | declaration is co-located with a top-level `customElement.define` call. This 941 | assumption also prevents tree-shaking of any dependencies. HydroActive 942 | implemented the On-Demand Definitions proposal to mitigate these issues. 943 | 944 | HydroActive's design with respect to file ordering is described more thoroughly 945 | in [this video](https://youtu.be/euFQRqrTSMk?si=i5HKHayt3QvuNytf&t=736), though 946 | it is primarily focused on this problem within the context of hydration. 947 | However, correctly hydrating an element also requires defining it so this is 948 | effectively the same core problem. 949 | --------------------------------------------------------------------------------