├── 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 |
43 |
44 |
45 | Example
46 |
47 |
48 |
49 |
50 |
51 | ...
52 |
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!${MyElement}>
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 |
--------------------------------------------------------------------------------