-
Notifications
You must be signed in to change notification settings - Fork 15
Description
Hey all, first time posting here. I'm very happy to find the context proposal here as it's a pattern I've needed several times since we've started working on a web component library. I wanted to raise an issue we've run into using events to implement a context API just as a data point to consider.
When we first ran into the need for this, we started off with a very similar approach; events with callbacks dispatched by consumers. This generally works really well, but it has one requirement that ended up biting us in a couple situations: provider components generally must be defined before consumer components. If consumer components are defined first, it's possible for events dispatched by the consumers to bubble up through the provider component before it's upgraded.
Many times this isn't a problem; you can have your consumer component import the provider component (or await customElements.whenDefined(...), but we have a couple of cases where it became an issue:
- Context-based systems where the consumers don't know what module (or the name of the element) of the provider it's looking for can't import or await the definition
- In some cases, we specifically want to load the consumer component with higher priority.
The second case there is a little less obvious, so here's an example from our component library; we have a custom form component (e.g. my-form) that is essentially a <form> element with a bunch of additional features. One of the things it does is allows other custom elements the ability to hook into form submission and validation via a context API, so you can build custom inputs and various other features that hook into form state.
<my-form>
<my-input name="foo"></my-input>
<button type="submit">Submit</button>
</my-form>In this scenario, the issue for us was performance optimization: our form component is fairly heavy, but it doesn't have any styles or markup (so it doesn't affect paint at all). Conversely, the components that consume its context API tend to be smaller, but do tend to impact layout/paint. If we're trying to optimize for first paint, it makes sense to load the form component itself after these child components, but doing so breaks the event-based context registration.
The solution we came up with was to replace the event dispatch with a utility that asynchronously crawls up the DOM tree from a consumer, awaiting each custom element it encounters along the way. It looks something like this:
async function findContext<T>(
from: HTMLElement,
isMatch: (e: Element) => e is Provider<T>
) {
let el = from;
while (el.parentElement) {
el = el.parentElement;
// We can skip any builtin elements
if (!el.tagName.includes('-')) continue;
const tag = el.tagName.toLowerCase();
if (!customElements.get(tag)) {
// non-upgraded elements might be ancestors
await customElements.whenDefined(tag);
}
if (isMatch(el)) return el as Provider<T>;
}
}This ensures that the context provider will get hooked up to consumers even if it is upgraded later, but it's not perfect:
- It makes the initial registration async instead of sync; this wasn't really an issue for us but it could be for some
- It breaks if any elements sitting between the consumer and the provider never get upgraded, as it will
awaitindefinitely on each undefined custom element it encounters while crawling up the tree.
This solution is working for us for now, but I'd love to settle on something a bit more inline with what other folks are doing if it can work for these sorts of use cases.