This blog post teaches an elegant way to isolate a complex Lit ContextProvider object in a ReactiveController, so you can compose it into multiple applications, and you can compose multiple different contexts into one application, without duplicate code or visual clutter. We tap into the host's lifecycle to correctly manage creating and populating and providing event listeners to handle context update requests.
It also shows how to write a Mixin to access that context without having to spam your entire app with context changes or force your developers to hand-write consumer code.
As a bonus, it includes both an elegant way to declare a base class with multiple mixins, and a nifty trick for creating truly private fieldnames without the Private Fieldname syntax, because the latter has a known performance problem.
We use Lit at authentik as the basis of our UI, and I've recently been tasked with upgrading the UI to Lit 3 and Patternfly 5. One of the issues we have with our product is that it has a lot of Context objects. At least count, there were five or six standard contexts plus two derivatives. We also have different tiers of applications: those that don't need a user, those than do, and those that both need a user and need administrative contextual information as well, and finally those that need an administrative context and have our enterprise features as well.
Like many such implementations, we have a top-level container, an AppShell
, that houses these
contexts. Now, we could do like React and nest these. In Lit, though, nesting creates new
ShadowDOM
borders all the way down, and managing things like focus and accessibility through so many layers
can be... painful. Especially since, unlike React, each new context object creates a new HTML tag in
the source code of the generated document. What we wanted was a way to define the top-level
application shell out of component parts, so that we could have a single instance.
In Lit, global contexts come from a ContextProvider
, which
uses a unique identity (a Symbol) to register it in Lit's quite small and fast runtime, and which
any child object in the hierarchy can then @consume(contextIdentity)
to access the values inside a
context and to schedule re-renders when that value changes.
In our product, we want both the AppShell and any of its children to respond to state changes, so we're maintaining both the context for the children and the state object for the AppShell itself. We want changes to the context to be reflected in the state, and changes to the state to be reflected in the context.
Here's what the pattern used to look like:
export class AppShell extends LitElement {
_configContext = new ContextProvider(this, {
context: authentikConfigContext,
initialValue: undefined,
});
_config?: Config;
@state()
set config(c: Config) {
this._config = c;
this._configContext.setValue(c);
this.requestUpdate();
}
get config(): Config | undefined {
return this._config;
}
constructor() {
super();
new RootApi(DEFAULT_CONFIG).rootConfigRetrieve().then((config) => {
this.config = config;
});
}
Now, that's not horrible, but when you have five or six of them all in a row, it can get kinda ugly. When each of them has bespoke code for loading their configuration information, and extra event handlers for responding to child objects' requests to update or reload the context, it can get very messy! Especially when some of the code for handling those requests is in a completely different subproject of the monorepo! And that reload code handler is a load-time hack.
One thing I really wanted was to move these out of the AppShell and make them a mixin or a decorator. While I was working on something else, I had to revise one of these and, inspired by recent work I'd done with Lit's ReactiveController api, decided to see if I could make it work in one. I had tried this before but been stymied by a misunderstanding of how ContextProviders work; I had assumed they had to be instantiated on the host. They don't, they just have to know which host object they're being instantiated on, and ReactiveControllers provide that knowledge.
One other thing I wanted was to keep the fields of the context object completely private. And while
I know the #
syntax provides for private fields, the current implementations of that privacy
syntax are... not great. Especially when working with tables and grids, a lot of private fields can
be a memory hog and a performance drain. As this comment on the TypeScript Standards
Track points out:
Creating many instances of classes with private fields or private methods with this syntax may cause a lot of overhead for the garbage collector. JavaScript implementations engines store weak values in an actual map object instead of as hidden properties on the keys themselves, and large map objects can cause performance issues with garbage collection.
There is an alternative, though: name private fields using Symbols. Symbols are unique, unguessable,
and cannot be iterated via standard methods such as Object.keys()
. So I'm going to create a
ConfigContextController
(phew! Feels a bit like Java, don't it?) and make it do all the work.
Here's what our AppShell looks like now:
// Create a unique name for out context controller field
const configContext = Symbol("configContext");
export class AppShell extends LitElement {
// Use that unique name to hold the context controller
[configContext]!: ConfigContextController;
@state()
config?: Config;
constructor() {
super();
this[configContext] = new ConfigContextController(this);
}
Now we need only the two fields and the constructor for the controller version of the field. The controller itself can live in a different file where it can, in the ancient wisdow of Unix, "do one thing and do it well."
A ReactiveController is always attached to a host, and from the host it derives its lifecycle. Here's how we're going to define the ConfigContextController:
export class ConfigContextController implements ReactiveController {
host!: ReactiveElementHost;
context!: ContextProvider<{ __context__: Config | undefined }>;
constructor(host: ReactiveElementHost) {
this.host = host;
this.context = new ContextProvider(this.host, {
context: authentikConfigContext,
initialValue: undefined,
});
this.fetch = this.fetch.bind(this);
this.fetch();
}
fetch() {
new RootApi(DEFAULT_CONFIG).rootConfigRetrieve().then((config) => {
this.context.setValue(config);
this.host.config = config;
});
}
The context
field manages our context; the Controller contains the context and provides the handle
by which any object than needs to create a context Provider holds onto that provider, and the
Controller provides access to the host reference the Provider needs.
We also then fetch the data this context is supposed to hold.
The next bit is that we care if some configuration tool sends a "refresh the config" event.
hostConnected() {
window.addEventListener(EVENT_REFRESH, this.fetch);
}
hostDisconnected() {
window.removeEventListener(EVENT_REFRESH, this.fetch);
}
These two methods are provided by the ReactiveController API, and they're triggered when the host is connected to (or disconnected from) the DOM. Both of these happen while the host is within the DOM, and they're the perfect place to set up these listeners.
This explains why I did the this.fetch.bind(this)
in the constructor, rather than use an arrow
construction. This way, fetch()
is a uniquely bound reference to this object's instance of
fetch, and the disconnect will find the correct combination of event name and function instance, and
do the right thing.
And finally, one more bit of magic:
hostUpdate() {
if (this.host.config !== this.context.value) {
this.context.setValue(this.host.config);
}
}
hostUpdate()
is a method that will be called on the Controller whenever the host begins a
re-render pass of its content. That happens when state, property, or context information changes. In
this case, what we're listening for is if the host's copy of config
, the state() config
, has
changed, and if it has, set our context accordingly so all of the child objects of the AppShell get
a notification that the configuration has changed. The comparison is there to short-circuit any
potential infinite loops caused by the mirroring feature triggering a never-ending cycle of updates.
While this is more code than the original, it is a lot cleaner. It centralizes the responsibility for a context object's lifecycle in one place, and provides the correct, narrow API needed by an AppShell to use that context object. This is also a pattern, and we can write linting code to ensure we follow the pattern correctly.
Consume!
Of course, now that we have a context, we want to consume it. We could just stick it in a parent
class and make every object in the system subscribe to the configuration object, but that seems
like a waste. Not every object needs it. We could make a parent class just for "objects that need
configuration data," but what if they also need brand, tenant, licensing, user, and so forth
contexts as well? Or we could just make people write the multiple lines needed, importing
@lit/consume
and the authentikConfigContext
objects, but where's the fun in that?
Instead, we created a mixin that decorates our LitElement objects with a field automatically:
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
import { consume } from "@lit/context";
import type { LitElement } from "lit";
import type { Config } from "@goauthentik/api";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Constructor<T = object> = abstract new (...args: any[]) => T;
export function WithConfigContext<T extends Constructor<LitElement>>(
superclass: T,
subscribe = true,
) {
abstract class WithConfigProvider extends superclass {
@consume({ context: authentikConfigContext, subscribe })
public config!: Config;
}
return WithConfigProvider;
}
This creates a function that "mixes in" a new field, extending it into an existing class that conforms to the LitElement Interface definition, according to TypeScript. We're also saying that, by default, we want any users of this class to be notified ("subscribed") when the value updates.
One piece of configuration information we have is CanUploadMedia
, which allows our users to, well,
to upload icons for the various applications and authentication methods and users they've enabled on
our product. It hangs off our config object. Not everyone needs that, but the "Add a new
application" or "Add a new user" methods do.
So for those, we can just declare them:
import { WithConfigContext } from "@goauthentik/elements/contexts/configProvider";
import { WithBrandContext } from "@goauthentik/elements/contexts/brandProvider";
const UserListBaseClass = WithBrandContext(WithConfigContext(LitElement));
@customElement("ak-user-list")
export class UserListPage extends UserListBaseClass {
...
}
Now the UserListPage has access to two fields, config
and brand
, which contain our configuration
and branding information for this instance, without the user having to risk mis-typing, picking the
wrong context key, or any number of risks that come with writing verbose code.
Conclusion
I set out to show you a neat way to provide Lit's context providers as reactive controllers responsible for the context and listening for events that request update to the context, passing those updates to the host LitElement. I chose Reactive Controllers as a way of isolating that responsibility, and the result turns out to have been elegant and performant.
I also showed you how to create a mixin for the consumption end of the context configuration, so that only the objects that need access to a context will have it, and in a way that minimizes the long-term maintenance headaches of updating the context by putting the consumption code in one place.
As a bonus, I showed you how to elegantly define a base class if you have multiple mixins, and I
showed you how to create truly private fields by using JavaScript Symbol
objects as private
fieldnames.
References
Aside from the links inside this document, I learned about using Symbols as field names by reading the source code to the Elix Web Component library.
I learned about creating a highly-visible mixin field name from reading the source code to The Material Web Components.