Controller Registry: adding behaviour to any HTML element

Talia's Blog

As an alternative to custom elements, or to complement them, I built library to attach external controllers to HTML objects following similar semantics to the built-in CustomElementRegistry.
javascriptcomponentswebdev

Web Components

Custom elements are cool; I use them for plenty of things, but there is one thing about them that makes them unsuited for many problems: they take full control over an element and correspond to that element's main purpose.

This is perfectly reasonable for actual components, when one wants to define a completely new type of element with one clear functionality.

They don't, however, allow combining more than one custom element in one HTML tag, and while builtin custom elements are technically a thing, in practice they aren't viable (thanks, safari) and so attaching custom behaviours to built-in HTML elements isn't possible in practice either.

Enter Controllers

Or traits, or whatever else one wants to call them.

The idea here is simple: instead of the custom behaviour existing in the tag itself, it exists in an external object (or other entity), which is attached to an HTML tag and controls it.

Controller-Registry

As a prototype to play around with this idea, I implemented controller-registry (repository, github mirror).

The API is similar to custom elements wherever it makes sense, but is still different in many ways due to the differing requirements.

In summary

  1. In HTML, controllers work like classes: one attribute that's a whitespace-separated list
  2. In JavaScript, there is one central registry associating names to behaviours
  3. Adding a controller to an element's attribute attaches a new controller automatically, while defining a new controller automatically upgrades all elements that already have that name in their list.
  4. Unlike custom elements, controllers can also be removed from elements (and re-added, as many times as desired)

Playground

Here's a codepen I prepared from the example file in the repository for anyone who wants to just play around with it before reading on.

Feel free to also open up the dev tools and manually tinker with the controller attribute of the elements; their behaviours should be updated automatically.

Implementation

All things considered, the implementation is surprisingly simple.

Most of what matters happens in the ControllerRegistry class, and its one global instance. Creating new registries is possible, but I won't focus on that here.

The class itself holds a couple of internal data structures to keep track of elements and controllers. Weak maps are really convenient here: they allow associating data with an object but still let the object be garbage-collected if it doesn't have any other references.

The registry also has a MutationObserver that listens for both DOM insertions to keep track of new nodes as well as attribute changes specifically on the controller attribute.

How much optimization happens here is up to the browser, but using the attributeFilter, I've at least given the browser all the information it needs to treat this attribute the same way it does class, and internally hook into it without even considering where in the DOM it happens. That's as much as an implementation in JS can achieve.

The define function is mostly quite simple, in that it just takes its arguments as key and value to insert into a map; but before doing that it also checks if any element is already waiting for a controller of that name to be defined so that can be attached immediately.

A little bit of extra code at the start of the method checks for functions that are constructors, and transforms them into a wrapper function that calls the constructor with new and the element as its argument, then waits for the controller to be detached again and tries calling a detach method on the controller object if it is defined. Technically the constructor only receives a revocable proxy to the element, but I am not sure if I want to keep that in the code.

Some of the functions simply mimic the public API of CustomElementRegistry; keeping things similar to what developers already know reduces friction, after all.

The update and attach methods are where most of the magic happens:

It extracts controller names from the controller attribute of the elements, then looks those up in the controller list and either attaches them or adds the element to the waiting list if a controller by that name is not yet defined.

When a controller is attached, a little bit more magic happens: A new promise is created that fulfills when the controller is detached. An abort controller is also created which will abort on disconnect. Its signal is then saved as a signal property on the promise object. In fact, the abort controller is what resolves the promise in an event listener.

This means that the promise can also be passed as the third argument to element.addEventListener to automatically remove the listener when the controller is detached. Pretty neat, isn't it? You can see this in the codepen example above.

And that is most of the exciting parts. At the top of the module there is also a helper that works a bit like a DomTokenList, so controllers can be manipulated just like classes; but its implementation isn't very complicated.

Conclusion

All in all, the proof of concept took a couple of hours to implement and maybe two or three more of tweaking, documenting and playing around with it.

The implementation was surprisingly simple considering how much this already does (thanks mostly to MutationObserver) and I can imagine this being a feature that browsers could implement out of the box without too much effort.

Feedback is always welcome, of course; either in the form of issues / pull requests on the repository or even just tagging me on fedi.