Overview of stimulus.js implementation?

I’m looking to dig in to the stimulus.js codebase a bit, and see how it does what it does. After some sleuthing, I think I’ll have to read most the project to develop an overview of how it uses MutationObverver. For example, I see that when I call Application#start(), it waits for the dom to load, then calls this.router.start(), which calls this.scopeObserver.start(), which calls this.valueListObserver.start(), which calls this.tokenListObserver.start(), which calls this.attributeObserver.start(), which calls this.elementObserver.start(), which finally calls this.mutationObserver.observe(this.element, { attributes: true, childList: true, subtree: true }). I stopped execution on that line in ElementObserver, and I see that there’s an observer on HTML, and each controller element.

I think if someone could give an implementation overview, for myself and others, just going over the overall strategy it would help all the pieces make more sense. Based on this sleuthing, obviously the whole tree is watched for some reason, as well as each controller, for obvious reasons. I’d like to see if maybe I can hook in to whatever is watching the tree, and/or the controllers, or maybe propose a change that would allow me to do such. Or maybe know this is a dead end, that would be nice too.

Given enough spare time, I can figure it all out, or I might run out of time and just have to move on.

2 Likes

I’ve been meaning to document this for a while now, so here goes:

@stimulus/mutation-observers

The @stimulus/mutation-observers package provides a tower of classes for handling mutation events at various levels of abstraction. Each class in the tower wraps another observer class, acts as that observer’s delegate, performs some kind of filtering or indexing, and finally exposes the results through its own delegate interface.

The ElementObserver class sits at the base of the tower. Construct it with a root DOM element and an object which implements the ElementObserverDelegate interface. Each instance has its own DOM MutationObserver configured to observe the root and its child elements. The methods of the delegate interface control which elements are matched (matchElement(), matchElementsInTree()) and what to do in response (elementMatched(), elementUnmatched(), elementAttributeChanged()).

ElementObserver instances have stop() and start() methods which let you pause and resume matching (during performance-critical code, for example). After a call to start(), the ElementObserver notifies its delegate of all matching elements which have changed since the last call to stop().

On top of ElementObserver sits AttributeObserver, which monitors a DOM tree for mutations to attributes with a given name. The AttributeObserverDelegate interface allows delegates to respond when an attribute is matched or unmatched on an element, or when an already matched attribute’s value has changed.

TokenListObserver specializes AttributeObserver to monitor a DOM tree for changes to token list attributes. (A token list is an attribute whose value is a space-separated list of zero or more string tokens, such as the HTML class attribute.) The TokenListObserverDelegate interface exposes Token objects through tokenMatched() and tokenUnmatched() methods. Each token represents a unique string token belonging to an element and attribute at a particular index.

At the top of the tower is ValueListObserver, whose ValueListObserverDelegate interface allows delegates to specify a parseValueForToken() method which converts a token to an application-defined value type. Then the elementMatchedValue() and elementUnmatchedValue() methods notify the delegate when a value appears in or disappears from the document.

The @stimulus/core package has two observer classes of its own, each which sits atop ValueListObserver from @stimulus/mutation-observers. The first, BindingObserver, watches data-action attributes and creates bindings from action descriptor tokens. The second, ScopeObserver, watches data-controller attributes and creates scopes from identifier tokens. (More on the role of those classes below.)

Why do we organize the mutation observer classes this way? It turns out that loosely coupled filter classes are easy to conceive, rearrange, and discard as needs change. Parts of Stimulus were originally designed around a SelectorObserver class, which is no longer present; an in-development branch adds a new StringMapObserver class for watching changes to prefixed data attributes.

@stimulus/core

The @stimulus/core package provides all the classes used to implement the Stimulus runtime environment.

At the top of the hierarchy is the Application class, constructed with a root DOM element and a Schema. The application creates and manages a router and a dispatcher. It also has methods for associating controller classes with identifiers, and for starting and stopping observation.

The Router class creates a ScopeObserver and acts as its delegate. The role of the router is to connect scopes to modules. A Scope represents an element-identifier pair; a Module represents a Stimulus controller class definition and all of its contexts, indexed by scope.

You can think of a Context as the private backing store of a Controller. There is a 1:1 mapping between instances of the two classes. Each context has a BindingObserver, which watches the context’s scope for bindings that match the scope’s identifier. A Binding represents a context-action pair.

The application’s shared Dispatcher instance acts as the delegate for every binding observer in the application. The dispatcher installs and uninstalls DOM event listeners for bindings’ actions, and is responsible for invoking those actions in the order they appear in the data-action attribute.

The purpose of the Scope class is to query and filter the tree of elements relevant to a controller. As with contexts, there is a 1:1 mapping between controller and scope instances. Scopes know how to filter out subtrees of elements belonging to other scopes with the same identifier. They also provide target set and data map objects to the controller.

The TargetSet class provides a Set-like interface to a scope’s target elements. The targets property on a controller points to its scope’s target set. Target properties declared in a controller’s static targets array delegate to the target set’s find() and findAll() methods. Similarly, the DataMap class provides a Map-like interface to a scope’s namespaced data attributes.


I think this should cover most of the important classes in Stimulus as of version 1.1.1. Thanks for digging in, and please let me know if something above isn’t clear, or if you’re curious about anything else!

13 Likes