mount-observer
v0.1.12
Published
Observe and act on css matches.
Downloads
1,576
Readme
Note that much of what is described below has not yet been polyfilled.
Implementation Status
The following features have been implemented and tested:
Core Functionality
- ✅ matching: CSS selector-based element matching
- ✅ whereInstanceOf: Constructor-based element filtering (single or array)
- ✅ withMediaMatching: Media query-based conditional mounting (string or MediaQueryList)
- ✅ whereObservedRootSizeMatches: Container query-based conditional mounting (observes root element size)
- ✅ whereElementIntersectsWith: Intersection observer-based conditional mounting (observes element visibility)
- ✅ whereConnectionHas: Network connection-based conditional mounting (observes connection speed/type)
- ✅ withScopePerimeter: Donut hole scoping (exclude elements inside matching ancestors)
Lifecycle & Events
- ✅ mount/dismount/disconnect events: Element lifecycle tracking
- ✅ mediamatch/mediaunmatch events: Media query state change notifications (with
getPlayByPlayoption) - ✅ load event: Import completion notification
Advanced Features
- ✅ Dynamic imports: Lazy loading of JavaScript modules
- ✅ assignOnMount: Property assignment when elements mount
- ✅ assignOnDismount: Property assignment when elements dismount
- ✅ stageOnMount: Reversible property assignment (auto-restores on dismount)
- ✅ do callbacks: Mount/dismount/disconnect/reconnect lifecycle hooks
- ✅ Element mount extension: element.mount() method for scoped registry observation
- ✅ Shared MutationObserver: Efficient observer sharing across instances
- ✅ Code splitting: Conditional features loaded on-demand
- ✅ Memory management: WeakRef usage for DOM node references
Not Yet Implemented
- ❌ Reconnect event handling
The MountObserver API
Author: Bruce B. Anderson (with valuable feedback from @doeixd)
Issues / PRs / polyfill: mount-observer
Last Update: Feb 23, 2026
Benefits of this API
What follows is a far more ambitious alternative to the lazy custom element proposal. The goals of the MountObserver API are more encompassing and less focused on registering custom elements. In fact, this proposal addresses numerous use cases in one API. It basically maps common filtering conditions in the DOM to mounting a "campaign" of some sort, like importing a resource, and/or progressively enhancing an element, and/or "binding from a distance".
"Binding from a distance" refers to empowering the developer to essentially manage their own "stylesheets" -- but rather than for purposes of styling, using these rules to attach behaviors, set property values, etc, to the HTML as it streams in. Libraries that take this approach include Corset and trans-render, selector-observer, pure, weld, bess. The concept has been promoted by a number of prominent voices in the community.
The underlying theme is that this API is meant to make it easy for developers to do the right thing by encouraging lazy loading and smaller footprints. It rolls up most of the other observer APIs into one, including, potentially, a selector observer, which may be a similar duplicate to the match-media counterpart proposal.
Finite Element Analysis
Most every web application can be recursively broken down into logical regions, building blocks which are assembled together to form the whole site.
At the most micro level, utilizing highly reusable, generic custom elements -- elements that can extend the HTML vocabulary, elements that could be incorporated into the browser, even -- form a great foundation to build on.
But as one zooms out from the micro to the macro, the nature of the components changes in significant ways.
At the micro level, components will have few, if any, dependencies, and those dependencies will tend to be quite stable, and likely all be used. The dependencies will skew more towards tightly coupled utility libraries.
"Macro" level components will tend to be heavy on business-domain specific data, heavy on gluing / orchestrating smaller components, light on difficult, esoteric JavaScript. They aren't confined to static JS files, and likely will include dynamic content as well. They will also be heavy on conditional sections of the application only loading if requested by the user.
ES module based web components may or may not be the best fit for these application macro "modules". A better fit might be a server-centric solution, like Rails, just to take an example.
A significant pain point has to do with downloading all the third-party web components and/or (progressive) enhancements that these macro components / compositions require, and loading them into memory only when needed.
Does this API make the impossible possible?
There is quite a bit of functionality this proposal would open up that is exceedingly difficult to polyfill reliably:
It is unclear how to use mutation observers to observe changes to custom state. The closest thing might be a solution like this, but that falls short for elements that aren't visible or during template instantiation, and requires carefully constructed "negating" queries if needing to know when the CSS selector is no longer matching.
For simple CSS matches, like "my-element" or "[name='hello']", it is enough to use a mutation observer and only observe the elements within the specified DOM region (more on that below). But as CSS has evolved, it is quite easy to think of numerous CSS selectors that would require us to expand our mutation observer to scan the entire Shadow DOM realm, or the entire DOM tree outside any Shadow DOM, for any and all mutations (including attribute changes), and re-evaluate every single element within the specified DOM region for new matches or old matches that no longer match. Things like child selectors, :has, and so on. All this is done miraculously by the browser in a performant way. Reproducing this in userland using JavaScript alone while matching the same performance seems impossible.
Knowing when an element previously being monitored passes totally "out-of-scope" so that no more hard references to the element remain. This would allow for cleanup of no longer needed weak references without requiring polling.
Some CSS selectors, such as the scope donut hole range, aren't supported by oEl.querySelectorAll(...) or oEl.matches(...).
Most significant use cases
The amount of code necessary to accomplish these common tasks designed to improve the user experience is significant. Building it into the platform would potentially:
- Give developers a strong signal to do the right thing by:
- Making lazy loading of resource dependencies easy, to the benefit of users with expensive networks.
- Supporting "binding from a distance" that can set property values of elements in bulk as the HTML streams in. For example, say a web page is streaming in HTML with thousands of input elements (say a long tax form). We want to have some indication in the head tag of the HTML (for example) to make all the input elements read-only as they stream through the page. With CSS, we could do similar things, for example set the background to red of all input elements. Why can't we do something similar with setting properties like readOnly, disabled, etc? With this API, giving developers the "keys" to CSS filtering so they can "mount a campaign" to apply common settings on them all feels like something that almost every web developer has mentally screamed to themselves "why can't I do that?", doesn't it?
- Supporting "progressive enhancement" more effectively.
- Potentially allow the platform to do more work in low-level (C/C++/Rust?) code without as much context switching into the JavaScript memory space, which may reduce CPU cycles as well. This is done by passing a substantial number of conditions into the API, which can all be evaluated at a lower level before the API needs to surface up to the developer "found one!".
- As discussed earlier, to do the job right, polyfills really need to reexamine all the elements within the observed node for matches anytime any element within the Shadow Root so much as sneezes (has an attribute modified, changes custom state, etc), due to modern selectors such as the :has selector. Surely the platform has found ways to do this more efficiently?
The extra flexibility this new primitive would provide could be quite useful to things other than lazy loading of custom elements, such as implementing custom enhancements as well as binding from a distance in userland.
Quick Examples of the Most Common Use Cases
Before getting into the weeds, let's demonstrate the two most prominent use cases:
Use Case 1: Custom Attribute Enhancement
<body>
<div log-to-console="clicked on a div">hello</div>
<script type=module>
document.body.mount({
matching: '[log-to-console]',
do: (el) => {
el.addEventListener('click', e => {
console.log(e.target.getAttribute('log-to-console'));
});
}
})
</script>
</body>Use Case 2: Lazy Global Custom Element Definition
To specify the equivalent of what the alternative proposal linked to above would do, we can do the following:
// MyElement.js
export default class MyElement extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello!';
}
}
// main.js
import 'mount-observer/ElementMountExtension.js';
document.mount({
matching: 'my-element',
import: './MyElement.js',
do: 'builtIns.defineCustomElement'
});
// HTML - elements will be upgraded when discovered
// by the mount observer
<my-element></my-element>
This registers custom elements with the global customElements registry.
See this extending package that provides for a more declarative approach.
Scoped
To register the class in the same custom element registry as the element which calls the "mount" method (element in this case), use "builtIns.defineScopedCustomElement":
element.mount({
matching: 'my-element',
import: './MyElement.js',
do: 'builtIns.defineScopedCustomElement'
});Enhancing Elements with assign-gingerly
The builtIns.enhanceMountedElement handler automatically enhances mounted elements using the assign-gingerly enhancement system. This allows you to attach behavior and state to elements without subclassing.
// MyEnhancement.js
class ButtonEnhancement {
constructor(element, ctx, initVals) {
this.element = new WeakRef(element);
this.ctx = ctx;
this.clickCount = 0;
element.addEventListener('click', ({target}) => {
this.clickCount++;
target.setAttribute('data-clicks', this.clickCount);
});
}
}
export default {
spawn: ButtonEnhancement,
enhKey: 'buttonEnh'
};
document.mount({
matching: '.enhance-me',
import: './MyEnhancement.js',
do: 'builtIns.enhanceMountedElement'
});
// HTML
<button class="enhance-me">Click me</button>
// Access the enhancement
const button = document.querySelector('.enhance-me');
console.log(button.enh.buttonEnh.clickCount); // 0
button.click();
console.log(button.enh.buttonEnh.clickCount); // 1The handler:
- Searches the imported module for an export with a
spawnproperty (the enhancement class) - Calls
element.enh.get(registryItem, context)to spawn the enhancement - Stores the enhancement instance on
element.enh[enhKey]if anenhKeyis provided
This works with browsers that don't support scoped custom element registries by polyfilling the customElementRegistry property on elements.
Thorough Exposition Begins Here
Okay, let's get into the weeds. First, we strongly recommend studying the core package that mount-observer extends, assign-gingerly.
First use case -- lazy loading custom elements without sugar coating
This registers the custom element in the global registry.
const observer = new MountObserver({
select:'my-element', //not supported by this polyfill
import: './my-element.js',
do: ({localName}, {modules, observer, MountConfig, rootNode}) => {
if(!customElements.get(localName)) {
customElements.define(localName, modules[0].MyElement);
}
observer.disconnectedSignal.abort();
}
}, {disconnectedSignal: new AbortController().signal});
observer.observe(document);The do function will only be called once per matching element -- i.e. if the element stops matching the "select" criteria, then matches again, the do function won't be called again. It will be called for all elements when they match within the scope passed in to the observe method. However, the events discussed below, will continue to be called repeatedly.
The constructor argument can also be an array of objects that fit the pattern shown above.
In fact, as we will see, where it makes sense, where we see examples that are strings, we will also allow for arrays of such strings. For example, the "select" key can point to an array of CSS selectors (and in this case the mount/dismount callbacks would need to provide an index of which one matched). I only recommend adding this complexity if what I suspect is true -- providing this support can reduce "context switching" between threads / memory spaces (c++ vs JavaScript), and thus improve performance. If multiple "on" selectors are provided, and multiple ones match, I think it makes sense to indicate the one with the highest specifier that matches. It would probably be helpful in this case to provide a special event that allows for knowing when the matching selector with the highest specificity changes for mounted elements.
If no imports are specified, it would go straight to do (if any such callbacks are specified), and it will also dispatch events as discussed below.
This only searches for elements matching 'my-element' outside any shadow DOM.
But the observe method can accept a node within the document, or a shadowRoot, or a node inside a shadowRoot as well.
The "observer" constant above is a class instance that inherits from EventTarget, which means it can be subscribed to by outside interests.
[!Note] Reading through the historical links tied to the selector-observer proposal this proposal helped spawn, I may have painted an overly optimistic picture of what the platform is capable of. It does leave me a little puzzled why this isn't an issue when it comes to styling, and also if some of the advances that were utilized to support :has could be applied to this problem space, so that maybe the arguments raised there have weakened. Even if the concerns raised are as relevant today, I think considering the use cases this proposal envisions, that the objections could be overcome, for the following reasons: 1. For scenarios where lazy loading is the primary objective, "bunching" multiple DOM mutations together and only reevaluating when things are quite idle is perfectly reasonable. Also, for binding from a distance, most of the mutations that need responding to quickly will be when the state of the host changes, so DOM mutations play a somewhat muted role in that regard. Again, bunching multiple DOM mutations together, even if adds a bit of a delay, also seems reasonable. I also think the platform could add an "analysis" step to look at the query and categorize it as "simple" queries vs complex. Selector queries that are driven by the characteristics of the element itself (localName, attributes, etc) could be handled in a more expedited fashion. Those that the platform does expect to require more babysitting could be monitored for less vigilantly. Maybe in the latter case, a console.warning could be emitted during initialization. The other use case, for lazy loading custom elements and custom enhancements based on attributes, I think most of the time this would fit the "simple" scenario, so again there wouldn't be much of an issue.
In fact, I have encountered statements made by the browser vendors that some queries supported by css can't be evaluated simply by looking at the layout of the HTML, but have to be made after rendering and performing style calculations. This necessitates having to delay the notification, which would be unacceptable in some circumstances.
If the developer has a simple query in mind that needs no such nuance, I'm thinking it might be helpful to provide an alternative key to "select" that is used specifically for (a subset?) of queries supported by the existing "matches" method that elements support, maybe even after the browser vendors provide a selector-observer (if ever).
So the developer could use:
Polyfill Supported Mount Observer
const observer = new MountObserver({
//supported by this polyfill
matching:'my-element',
import: './my-element.js',
do: ({localName}, {modules, observer, MountConfig, rootNode}) => {
if(!customElements.get(localName)) {
customElements.define(localName, modules[0].MyElement);
}
observer.disconnectedSignal.abort();
}
}, {disconnectedSignal: new AbortController().signal});
observer.observe(document);and could perhaps expect faster binding as a result of the more limited supported expressions. Since "select" is not specified, it is assumed to be "*".
This polyfill in fact only supports this latter option ("matching"), and leaves "select" for such a time as when a selector observer is available in the platform.
The import key
This proposal has been amended to support multiple imports, including of different types:
const observer = new MountObserver({
matching:'my-element',
import: [
['./my-element-small.css', {type: 'css'}],
'./my-element.js',
],
do: ({localName}, {modules, observer, MountConfig, rootNode}) => {
...
}
});
observer.observe(document);Once again, the key can accept either a single import, but alternatively it can also support multiple imports (via an array).
The do function won't be invoked until all the imports have been successfully completed and inserted into the modules array.
Previously, this proposal called for allowing arrow functions as well, thinking that could be a good interim way to support bundlers, as well as multiple imports. But the valuable input provided by doeixd makes me think that that interim support could more effectively be done by the developer in the do methods.
This proposal would also include support for JSON and HTML module imports (really, all types).
Preemptive downloading
There are two significant steps to imports, each of which imposes a cost:
- Downloading the resource.
- Loading the resource into memory.
What if we want to download the resource ahead of time, but only load into memory when needed?
The link rel=modulepreload option (and maybe the new defer tc39 proposal) provides an already existing platform support for this, but the browser complains when no use of the resource is used within a short time span of page load. That doesn't really fit the bill for lazy loading custom elements and other resources.
So for this we add loadingEagerness:
const observer = new MountObserver({
select: 'my-element', //not supported by this polyfill
loadingEagerness: 'eager', //partially supported by this polyfill
import: './my-element.js',
do: ({localName}, {modules}) => customElements.define(localName, modules[0].MyElement),
});So what this does is only check for the presence of an element with tag name "my-element", and it starts downloading the resource, even before the element has "mounted" based on other criteria.
The polyfill just loads the module into memory right away.
[!NOTE] As a result of the google IO 2024 talks, I became aware that there is some similarity between this proposal and the speculation rules api. This motivated the change to the property from "loading" to loadingEagerness above.
Separating JS imperative code from JSON serializable config
In order to support pure 100% declarative syntax in the passed in MountConfig argument, we need to be able to import the do function. This is done as follows:
//module myActions.js
const doFunction = function({localName}, {modules, observer, MountConfig, rootNode}){
if(!customElements.get(localName)) {
// Find the first exported class constructor from the module
const ElementClass = Object.values(modules[0]).find(exp =>
typeof exp === 'function' && exp.prototype && exp.prototype.constructor === exp
);
if(ElementClass) {
customElements.define(localName, ElementClass);
}
}
observer.disconnectedSignal.abort();
}
export {doFunction as do}
// observer setup
const observer = new MountObserver({
matching:'my-element',
import: [
'./my-element.js',
['./my-element-small.css', {type: 'css'}],
'./myActions.js'
],
reference: 2
});
observer.observe(document);
Here "2" refers to the imported module index ('./myActions.js' in this case).
How the reference property works
The reference property allows us to call do functions from imported modules, enabling 100% JSON-serializable configuration. This is useful when you want to separate imperative code from declarative configuration.
Key behaviors:
- The
referenceproperty can be a single number or an array of numbers, each referring to an import index - Referenced modules must be JavaScript modules (not CSS, JSON, or HTML imports)
- If a referenced module exports a
dofunction, it will be called after the inlinedocallback (if present) - If a referenced module doesn't export a
dofunction, it's silently skipped - The inline
docallback runs first, then referenceddofunctions run in the order specified
Important: Since do is a reserved keyword in JavaScript, you must export it using the syntax:
const doFunction = function(element, context) { /* ... */ };
export { doFunction as do };Validation: The reference property is validated in the constructor:
- Throws an error if
importis not defined - Throws an error if any index is out of bounds
- Throws an error if any index points to a non-JS module (e.g., CSS or JSON import)
Multiple references can also be made.
So for example:
import: [
['./my-element-small.css', {type: 'css'}],
'./component.js',
'./actions1.js',
'./actions2.js'
],
reference: [2, 3] // Both actions1 and actions2 will have their 'do' called if present[Implemented as Requirement11]
Media / container queries / instanceOf / custom checks [TODO] out of date
Unlike traditional CSS @import, CSS Modules don't support specifying different imports based on media queries. That can be another condition we can attach (and why not throw in container queries, based on the rootNode?):
const observer = new MountObserver({
select: 'div > p + p ~ span[class$="name"]', // not supported by polyfill
withMediaMatching: '(max-width: 1250px)',
whereObservedRootSizeMatches: '(min-width: 700px)',
whereElementIntersectsWith:{
rootMargin: "0px",
threshold: 1.0,
},
whereInstanceOf: [HTMLMarqueeElement], //or 'HTMLMarqueeElement'
whereLangIn: ['en-GB'], // Cannot be implemented - see https://github.com/whatwg/html/issues/7039
whereConnectionHas:{
effectiveTypeIn: ["slow-2g"],
},
import: ['./my-element-small.css', {type: 'css'}],
do: ...
});[whereInstanceOf implemented as Requirement5] [whereObservedRootSizeMatches implemented] [whereElementIntersectsWith implemented] [whereConnectionHas implemented]
[withMediaMatching implemented as Requirement6]
InstanceOf checks in detail
Carving out the special "whereInstanceOf" check is provided based on the assumption that there's a performance benefit from doing so. If not, the developer could just add that check inside the "confirm" callback logic (discussed later). For built-in elements, we can alternatively provide the string name, as indicated in the comment above, which certainly makes it JSON serializable, thus making it easy as pie to include in the MOSE JSON payload. I don't think there would be any ambiguity in doing so, which means I believe that answers the mystery in my mind whether it could be part of the low-level checklist that could be done within the c++/rust code / thread.
The picture becomes murkier for custom elements. The best solution in that case seems to be to utilize customElements.getName(...) as a basis for the match, but at first glance, that could preclude being able to use base classes which a family of custom elements subclass, if that superclass isn't itself a custom element. I suppose the solution to this conundrum, when warranted, is simply to burden the developer with defining a custom element for the superclass, and thus assigning it a name, applicable within ShadowDOM scopes as needed, even though it isn't actually necessarily used for any live custom elements. This would require already having imported the base class, only benefitting from lazy loading the code needed for each sub class, which might not always be all that high as a percentage, compared to the base class.
However, where this support for "whereInstanceOf" would be most helpful is when it comes to custom enhancements that only wish to lazily layer some heavy lifting functionality on top of certain families of already loaded and upgraded custom elements (possibly in addition to some (specified) built in elements). Here, the lazy loading of the entire custom enhancement, based on the presence in the DOM of a member of the family of custom elements, would, if my calculations are correct, result in providing a significant benefit.
Referenced whereInstanceOf
Similar to the do function, the whereInstanceOf check can also be moved to imported modules for 100% JSON-serializable configuration:
// module mySettings.js
const doFunction = function({localName}, {modules, observer, MountConfig, rootNode}) {
if(!customElements.get(localName)) {
customElements.define(localName, modules[1].MyElement);
}
observer.disconnectedSignal.abort();
};
const whereInstanceOf = [HTMLMarqueeElement, SVGElement];
export { doFunction as do, whereInstanceOf };
// my local module
const observer = new MountObserver({
matching: 'my-element',
import: [
['./my-element-small.css', {type: 'css'}],
'./my-element.js',
'./mySettings.js'
],
reference: 2
});
observer.observe(document);Behavior:
- Combining checks: If both inline
whereInstanceOfand referencedwhereInstanceOfexist, they are AND'd together (element must match both) - Multiple references: If multiple referenced modules export
whereInstanceOf, the element must match ALL of them (AND logic) - Validation: Referenced
whereInstanceOfis validated after imports load. Throws an error if not a Constructor or array of Constructors - Optional export: If a referenced module doesn't export
whereInstanceOf, it's silently ignored - Timing:
- With lazy loading (default): Inline
whereInstanceOfis checked first (before imports), then referenced checks happen after imports load - With
loadingEagerness: 'eager': Both inline and referenced checks happen together after imports are loaded
- With lazy loading (default): Inline
This optimization ensures that with lazy loading, elements that don't match the inline whereInstanceOf won't trigger unnecessary imports.
[Implemented as Requirement12]
Element Mount Extension
For even more convenience, you can use the element.mount() method to observe elements within their scoped custom element registry context. This is particularly useful with scoped custom element registries (Chrome 146+, latest WebKit/Safari).
import 'mount-observer/ElementMountExtension.js';
// Mount with MountConfig
await document.body.mount({
matching: 'button',
do: (element) => {
element.classList.add('enhanced');
}
});The mount() method:
- Automatically finds the highest scoped container with the same
customElementRegistryas the element (default behavior) - Creates a
MountObserverwith the provided config - Observes the determined scope
- Returns the element for chaining (as a Promise)
Scope options (via options.scope):
'registry'(default): Observes the root registry container (highest element with same customElementRegistry)'self': Observes only this element'root': Observes the root node (document or shadow root)'shadow': Observes the element's shadowRoot (throws error if none exists)Element: Observes a custom element you specify
This is especially useful for web components that want to observe their own shadow DOM or scoped registry:
class MyComponent extends HTMLElement {
async connectedCallback() {
const shadow = this.attachShadow({ mode: 'open', registry: new CustomElementRegistry() });
shadow.innerHTML = `<button data-action="click">Click me</button>`;
// Default: Observe within this component's scoped registry
await shadow.mount([{
spawn: ButtonHandler,
enhKey: 'handler',
withAttrs: { action: 'data-action' }
}]);
// Or observe just the shadow root itself
await this.mount([{
spawn: ShadowHandler,
enhKey: 'shadow'
}], { scope: 'shadow' });
// Or observe the entire document
await this.mount({
matching: '.global-button',
do: (el) => console.log('Global button found')
}, { scope: 'root' });
}
}Browser support: Works in all browsers, but scoped registry features require Chrome 146+ or latest WebKit/Safari.
Implemented as CustomElementRegistryMounting requirement.
Mount Observer Script Elements (MOSEs)
Following an approach similar to the speculation api, we can add a script element anywhere in the DOM:
// myPackage/myDefiner.js
//my all powerful custom element definer
const doFunction = function({localNme}, {modules, observer}){
if(!customElements.get(localName)) {
customElements.define(localName, modules[1].MyElement);
}
observer.disconnectedSignal.abort();
}
export { doFunction as do };<script type="mountobserver" >
{
"select":"my-element",
"import": [
["./my-element-small.css", {type: "css"}],
"./my-element.js",
"myPackage/myDefiner.js
],
"reference": 2
}
</script>To keep this proposal / polyfill of reasonable size, mount observer script elements has its own repo / sub-proposal. There's much more to it, including support for inheritance across containing scoped custom element registries.
But I think it's important to think about this way of making the mount observer declarative, as it provides one significant reason why we place so much emphasis on making sure that the mount observer settings (MountConfig) is as JSON serializable as possible.
Binding from a distance
It is important to note that "matching" (and especially the non polyfillable "select") is a css query with no restrictions. So something like:
import {EvtRt} from 'mount-observer/EvtRt.js';
class MyHandler extends EvtRt {
mount(mountedElement, MountConfig, context){
mountedElement.textContent = 'hello';
}
dismount(mountedElement, MountConfig){
mountedElement.textContent = 'goodbye';
}
}
const observer = new MountObserver({
// not supported by polyfill
//select: 'div > p + p ~ span[class$="name"]'
// is supported by polyfill, and even after select is also supported:
matching: 'div > p + p ~ span[class$="name"]',
do: (mountedElement, ctx) => {
new MyHandler(mountedElement, ctx);
},
});
observer.observe(document);... would work.
EvtRt is a convenience class provided with the polyfill package, and is considered part of this proposal (see how it is used below by built in handlers).
This allows developers to create "stylesheet" like capabilities.
Registering reusable handlers with MountObserver.define
To make MountConfig configurations more JSON-serializable and encourage code reuse, you can register handler classes with string names and reference them by name:
import {EvtRt} from 'mount-observer/EvtRt.js';
class MyHandler extends EvtRt {
mount(mountedElement, MountConfig, context){
mountedElement.textContent = 'hello';
}
dismount(mountedElement, MountConfig){
mountedElement.textContent = 'bye';
}
}
// Register the handler with a string name
MountObserver.define('myHandler', MyHandler);
// Reference it by name in the configuration
const observer = new MountObserver({
matching: 'div > p + p ~ span[class$="name"]',
do: 'myHandler' // String reference instead of inline function
});
observer.observe(document);Benefits of registered handlers
- JSON serialization: Configurations using string references can be serialized to JSON
- Code reuse: Define handlers once, use them in multiple observers
- Separation of concerns: Keep handler logic separate from configuration
Using arrays with mixed types
The do property can be a string, a function, or an array mixing both:
MountObserver.define('logger', LoggerHandler);
MountObserver.define('validator', ValidatorHandler);
const observer = new MountObserver({
matching: 'input',
do: [
'logger', // Registered handler
(element, ctx) => { // Inline function
element.dataset.processed = 'true';
},
'validator' // Another registered handler
]
});Handlers execute in the order specified. If a handler constructor throws an error, execution stops and subsequent handlers won't run.
Interaction with the reference property
When both do (with string/array) and reference are specified, the execution order is:
- Inline
dofunctions and registered handlers (fromdostrings), in whatever order they appear - Referenced
dofunctions (fromreferenceproperty)
MountObserver.define('setup', SetupHandler);
const observer = new MountObserver({
matching: 'button',
import: './button-actions.js',
reference: 0,
do: ['setup', (el) => { el.dataset.ready = 'true'; }]
});
// Execution order: setup handler, inline function, then imported do functionHandler requirements
Registered handlers must be classes (constructors) that accept (mountedElement: Element, ctx: MountContext) as constructor parameters. They can be:
- ES6 classes extending
EvtRt(recommended) - ES6 classes with custom logic
- ES5-style constructor functions
// ES5-style constructor function
function SimpleHandler(element, ctx) {
element.textContent = 'Handled!';
}
MountObserver.define('simple', SimpleHandler);Error handling
Validation at construction time: If you reference an unregistered handler name, an error is thrown when creating the MountObserver:
const observer = new MountObserver({
do: 'nonexistent' // Error: No handler defined for nonexistent
});Duplicate registration: Attempting to register the same name twice throws an error:
MountObserver.define('myHandler', Handler1);
MountObserver.define('myHandler', Handler2); // Error: myHandler already in useGlobal registry
The handler registry is global and shared across all MountObserver instances, similar to the custom elements registry. Once a handler is registered, it can be used by any MountObserver instance in your application.
[Implemented as Requirement14]
Handler defaults with static properties
Registered handler classes can specify default MountConfig properties using static class properties. When you reference a handler by name, its static properties are automatically merged with your inline configuration, with inline config always taking precedence:
import {EvtRt} from 'mount-observer/EvtRt.js';
class MyHandler extends EvtRt {
static matching = 'div > p + p ~ span[class$="name"]';
static whereInstanceOf = HTMLSpanElement;
mount(mountedElement, MountConfig, context){
mountedElement.textContent = 'hello';
}
dismount(mountedElement, MountConfig){
mountedElement.textContent = 'bye';
}
}
// Register the handler
MountObserver.define('myHandler', MyHandler);
// Use with defaults - will use handler's matching and whereInstanceOf
const observer1 = new MountObserver({
do: 'myHandler'
});
observer1.observe(document);
// Override specific properties - inline config trumps handler defaults
const observer2 = new MountObserver({
matching: 'span.special', // This overrides the handler's matching
do: 'myHandler' // Still uses handler's whereInstanceOf
});
observer2.observe(document);How it works:
- When
dois a string reference to a registered handler, the handler's static properties are extracted - Static properties are merged with the inline config using object spread
- Inline config properties always override handler defaults (inline trumps)
- All MountConfig properties can be specified as static properties (matching, whereInstanceOf, withMediaMatching, etc.)
Benefits:
- DRY principle: Define common configuration once in the handler class
- Flexibility: Override any property when needed for specific use cases
- Composability: Handlers become self-contained with their own default behavior
- JSON serialization: Configurations remain JSON-serializable since only the handler name is referenced
Example with multiple properties:
class InputHandler extends EvtRt {
static matching = 'input[type="text"]';
static whereInstanceOf = HTMLInputElement;
static withMediaMatching = '(min-width: 768px)';
mount(mountedElement, MountConfig, context){
mountedElement.placeholder = 'Enter text...';
}
}
MountObserver.define('inputHandler', InputHandler);
// Uses all handler defaults
const observer = new MountObserver({
do: 'inputHandler'
});
// Partially override - keeps whereInstanceOf and withMediaMatching from handler
const observer2 = new MountObserver({
matching: 'input[type="email"]', // Override matching only
do: 'inputHandler'
});[Implemented as SupportWhereCriteriaWithRegisteredActions]
Built in handlers
This proposal advocates having the platform provide some built in handlers, that extend EvtRt, that is included with this Polyfill.
Log to console handler
const observer = new MountObserver({
// not supported by polyfill
//select: 'div > p + p ~ span[class$="name"]'
// is supported:
matching: 'div > p + p ~ span[class$="name"]',
do: 'builtIns.logToConsole'
});
observer.observe(document);This logs to console all the events (mount, dismount, disconnect)
Lazy custom element handler
// MyElement.js
export default class MyElement extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello!';
}
}
// main.js
import { MountObserver } from 'mount-observer';
const observer = new MountObserver({
matching: 'my-element',
import: './MyElement.js',
do: 'builtIns.defineCustomElement'
});
observer.observe(document);
// HTML - elements will be upgraded when discovered
// by the mount observer
<my-element></my-element>
Applying properties on mount and dismount
For the common use case of setting properties on matching elements, MountObserver provides built-in support for the assignGingerly library. This allows us to declaratively specify properties to apply to elements during their lifecycle without writing custom mount callbacks:
const observer = new MountObserver({
matching: 'input',
assignOnMount: {
disabled: true,
value: 'Default value',
title: 'This is a tooltip'
}
});
observer.observe(document);This will automatically apply the specified properties to all matching input elements, both existing ones and those added dynamically.
[Implemented as Requirement2 and Requirement16]
Assigning properties on dismount
You can also specify properties to apply when elements are removed from the DOM using assignOnDismount:
const observer = new MountObserver({
matching: '.status-indicator',
assignOnMount: {
'?.style?.color': 'green',
'?.dataset?.status': 'active'
},
assignOnDismount: {
'?.style?.color': 'red',
'?.dataset?.status': 'inactive'
}
});
observer.observe(document);This is useful for cleanup operations, visual feedback, or maintaining state on elements that may be temporarily removed from the DOM but still referenced elsewhere in your code.
Note: The assignOnDismount properties are applied before the element is removed from the mounted elements tracking, so the element still has access to its DOM context.
Practical use case: Form validation feedback
A common use case is providing visual feedback for form validation:
const observer = new MountObserver({
matching: 'input.validated',
assignOnMount: {
'?.style?.borderColor': 'green',
'?.style?.backgroundColor': '#f0fff0',
'?.setAttribute': ['aria-invalid', 'false']
},
assignOnDismount: {
'?.style?.borderColor': '',
'?.style?.backgroundColor': '',
'?.removeAttribute': 'aria-invalid'
}
});
observer.observe(document);When an input gains the validated class, it gets green styling. When the class is removed (dismount), the styling is cleaned up.
Remounting behavior
If an element is removed and then re-added to the DOM, the assignOnMount properties will be reapplied:
const input = document.querySelector('input');
input.classList.add('validated'); // assignOnMount applied
input.classList.remove('validated'); // assignOnDismount applied
input.classList.add('validated'); // assignOnMount applied againThis ensures consistent behavior across the element's lifecycle.
Nested properties with dataset
The assignGingerly library supports nested property assignment using the ?. notation. This is particularly useful for setting data attributes and style:
const observer = new MountObserver({
matching: 'button',
assignOnMount: {
disabled: false,
'?.dataset?.action': 'submit',
'?.dataset?.trackingId': '12345',
'?.style': {
color: 'white',
height: '25px',
}
}
});
observer.observe(document);The ?. prefix tells assignGingerly to create nested properties if they don't exist. In this example, ?.dataset?.action will set the data-action attribute on the button elements.
Combining with imports
You can combine assignOn* with lazy loading to both import resources and set properties:
const observer = new MountObserver({
matching: 'my-element',
import: './my-element.js',
assignOnMount: {
theme: 'dark',
'?.dataset?.initialized': 'true'
},
do: ({localName}, {modules}) => {
if(!customElements.get(localName)) {
customElements.define(localName, modules[0].MyElement);
}
}
});
observer.observe(document);The assignGingerly properties are applied after imports are loaded but before the do callback is invoked, ensuring that elements are properly configured before any custom initialization logic runs.
Performance benefits
Using assignOn* provides several benefits:
- Lazy loading: The assign-gingerly library is only loaded when needed (when the
assignGingerlyproperty is specified) - Bulk operations: Properties are applied efficiently to all matching elements
- Declarative: No need to write custom mount callbacks for simple property assignments
- Consistent: The same property values are applied uniformly across all matching elements
Dynamically updating assignGingerly configuration
The MountObserver class provides a public assignGingerly() method that allows you to merge new updates into the observer. This is useful for responding to user actions or application state changes:
const observer = new MountObserver({
matching: 'input',
assignOnMount: {
disabled: true,
value: 'Initial value'
}
});
observer.observe(document);
// Later, update the configuration
await observer.assignGingerly({
title: 'Updated tooltip',
placeholder: 'New placeholder'
});Key behaviors:
Merging: New properties are merged with existing configuration. In the example above, future elements will receive all properties:
disabled,value,title, andplaceholder.Applies to existing elements: The new properties are immediately applied to all currently mounted elements.
Applies to future elements: Future elements that mount will receive the merged configuration.
Starting without initial config: You can call the method even if no
assignGingerlywas specified in the constructor:
const observer = new MountObserver({
matching: 'input'
});
observer.observe(document);
// Set configuration later
await observer.assignGingerly({
disabled: true,
value: 'Set via method'
});- Clearing configuration: Pass
undefinedto clear the configuration for future elements (already-mounted elements keep their properties):
await observer.assignGingerly(undefined);
// Future elements will not have properties applied
// Existing elements retain their current propertiesMethod signature:
async assignGingerly(config: Record<string, any> | undefined): Promise<void>The method is async because the assign-gingerly library is loaded dynamically when needed.
[Implemented as Requirement9]
Reversible property assignment with stageOnMount
While assignOnMount and assignOnDismount provide permanent property assignments, sometimes you need temporary changes that automatically reverse when elements dismount. The stageOnMount property provides this capability using the assignTentatively function from assign-gingerly:
const observer = new MountObserver({
matching: 'button.async-action',
stageOnMount: {
disabled: true,
title: 'Processing...',
'?.dataset?.loading': 'true'
}
});
observer.observe(document);When a matching button mounts, these properties are applied. When it dismounts (e.g., loses the async-action class), the original values are automatically restored.
How it works
stageOnMount uses assignTentatively under the hood, which:
- Captures original values before making changes
- Applies the new properties when elements mount
- Automatically reverses to original values when elements dismount
This is different from assignOnMount/assignOnDismount, where you must explicitly specify both the mount and dismount values.
When to use stageOnMount vs assignOnMount
Use stageOnMount when:
- You want temporary state changes that should automatically reverse
- The original values matter and should be restored
- You're toggling states (disabled/enabled, hidden/visible)
- Setting temporary ARIA states or loading indicators
Use assignOnMount/assignOnDismount when:
- You need different values on mount vs dismount (not just reversal)
- You want permanent enhancements that shouldn't be reversed
- You need explicit control over both mount and dismount behavior
- The dismount value is not simply "restore original"
Comparison example
// With assignOnMount/assignOnDismount - explicit control
const observer1 = new MountObserver({
matching: 'input.validated',
assignOnMount: {
'?.style?.borderColor': 'green'
},
assignOnDismount: {
'?.style?.borderColor': 'red' // Different value, not restoration
}
});
// With stageOnMount - automatic reversal
const observer2 = new MountObserver({
matching: 'button.loading',
stageOnMount: {
disabled: true, // Automatically restores original disabled state on dismount
'?.dataset?.loading': 'true' // Automatically removes on dismount
}
});Combining with assignOnMount
You can use both assignOnMount and stageOnMount together. The order of operations is:
- On mount:
assignOnMountapplied first, thenstageOnMount - On dismount:
stageOnMountreversed first, thenassignOnDismountapplied
const observer = new MountObserver({
matching: 'form',
assignOnMount: {
noValidate: true // Permanent enhancement
},
stageOnMount: {
'?.dataset?.submitting': 'true' // Temporary state
}
});Nested properties
Like assignOnMount, stageOnMount supports nested property paths:
const observer = new MountObserver({
matching: '.modal',
stageOnMount: {
'?.style?.display': 'block',
'?.style?.opacity': '1',
'?.dataset?.visible': 'true',
'?.setAttribute': ['aria-hidden', 'false']
}
});Re-mounting behavior
If an element dismounts and then re-mounts, stageOnMount will:
- Capture the current values (which may have changed since last mount)
- Apply the staged properties again
- Store new reversal information for the next dismount
const button = document.querySelector('button');
button.disabled = false; // Original state
button.classList.add('loading'); // Mount: disabled becomes true
button.classList.remove('loading'); // Dismount: disabled restored to false
button.disabled = true; // Manually changed
button.classList.add('loading'); // Re-mount: disabled becomes true (staged value)
button.classList.remove('loading'); // Dismount: disabled restored to true (the value before re-mount)Performance and memory
- The assign-gingerly library is only loaded when
stageOnMountis specified - Reversal objects are stored in a WeakMap, allowing garbage collection when elements are removed
- Each element's reversal data is cleaned up when it dismounts
[Implemented as Requirement13]
Emitting events from mounted elements
MountObserver can automatically dispatch custom events from elements when they mount. This is useful for:
- Signaling readiness: Notify parent components or listeners that an element is ready
- Initialization events: Trigger workflows when elements appear in the DOM
- Decoupled communication: Allow elements to announce their presence without tight coupling
Basic event emission
const observer = new MountObserver({
matching: 'button[data-action]',
mountedElemEmits: {
event: 'Event',
args: 'custom-ready'
}
});
observer.observe(document);This dispatches a custom-ready event from each matching button element when it mounts. Events bubble by default, so you can listen at the document level:
document.addEventListener('custom-ready', (e) => {
console.log('Button ready:', e.target);
});Event constructors
You can specify any event constructor available in globalThis:
mountedElemEmits: {
event: 'CustomEvent',
args: ['element-ready', { detail: { timestamp: Date.now() } }]
}Or pass a constructor directly:
mountedElemEmits: {
event: CustomEvent,
args: ['element-ready', { detail: { timestamp: Date.now() } }]
}Magic string substitution
Use magic strings to inject dynamic values into event data:
{{mountedElement}}- The element that just mounted{{MountConfig}}- The MountConfig configuration object
const observer = new MountObserver({
matching: 'button[data-test]',
mountedElemEmits: {
event: 'CustomEvent',
args: ['element-mounted', {
detail: {
element: '{{mountedElement}}',
config: '{{MountConfig}}'
}
}]
}
});Magic strings work at any depth in nested objects and arrays:
mountedElemEmits: {
event: 'CustomEvent',
args: ['data-ready', {
detail: {
nested: {
deep: {
element: '{{mountedElement}}'
}
}
}
}]
}Multiple events
Emit multiple events in sequence by providing an array:
const observer = new MountObserver({
matching: 'my-component',
mountedElemEmits: [
{ event: 'Event', args: 'component-loading' },
{ event: 'Event', args: 'component-ready' },
{ event: 'CustomEvent', args: ['component-initialized', { detail: { version: '1.0' } }] }
]
});Events are dispatched in the order specified.
Event properties with eventProps
Apply additional properties to the event object using eventProps:
mountedElemEmits: {
event: 'CustomEvent',
args: ['ready', { detail: {} }],
eventProps: {
timestamp: Date.now(), //TODO: magic string?
source: 'mount-observer',
element: '{{mountedElement}}'
}
}Properties are applied using the assignGingerly library, which supports nested property assignment with the ?. notation.
Fire once per element
Use oncePerMountedElement to ensure an event only fires the first time an element mounts:
const observer = new MountObserver({
matching: 'button[data-once]',
mountedElemEmits: {
event: 'Event',
args: 'initialized',
oncePerMountedElement: true
}
});If the element is removed and re-added to the DOM, the event will not fire again. This is useful for initialization events that should only happen once per element instance.
Performance considerations
The event emission logic is code-split into a separate module (emitEvents.js) that is only loaded when mountedElemEmits is configured. This keeps the core MountObserver lean for users who don't need this feature.
Complete example
const observer = new MountObserver({
matching: 'my-widget',
import: './my-widget.js',
mountedElemEmits: [
{
event: 'CustomEvent',
args: ['widget-loading', {
detail: {
element: '{{mountedElement}}',
timestamp: Date.now()
}
}],
oncePerMountedElement: true
},
{
event: 'Event',
args: 'widget-ready'
}
],
do: ({localName}, {modules}) => {
if(!customElements.get(localName)) {
customElements.define(localName, modules[0].MyWidget);
}
}
});
// Listen for events
document.addEventListener('widget-loading', (e) => {
console.log('Widget loading:', e.detail.element);
});
document.addEventListener('widget-ready', (e) => {
console.log('Widget ready:', e.target);
});
observer.observe(document);[Implemented as Requirement10]
Element-specific lifecycle notifications with getNotifier
While the MountObserver dispatches lifecycle events (mount, dismount, disconnect) at the observer level, sometimes you need to listen for events specific to a single element. The getNotifier() method returns an EventTarget that dispatches filtered events for only that element.
Basic usage
const observer = new MountObserver({
matching: 'button',
do: (mountedElement, {observer}) => {
const notifier = observer.getNotifier(mountedElement);
notifier.addEventListener('mount', (e) => {
console.log('This specific button mounted', e.mountedElement);
});
notifier.addEventListener('dismount', (e) => {
console.log('This specific button dismounted', e.mountedElement, e.reason);
});
notifier.addEventListener('disconnect', (e) => {
console.log('This specific button disconnected', e.mountedElement);
});
}
});
observer.observe(document);When mount events fire on notifiers
The notifier follows a specific rule for mount events:
- First mount: If
getNotifier()is called during thedocallback (when the element is mounting), the mount event does NOT fire on the notifier - Subsequent mounts: After the element dismounts and mounts again, the mount event WILL fire on the notifier
This prevents duplicate mount notifications when setting up listeners during the initial mount.
const observer = new MountObserver({
matching: '#my-button',
do: (element, {observer}) => {
const notifier = observer.getNotifier(element);
// This listener won't fire for the current mount
// (since we're inside the do callback)
notifier.addEventListener('mount', () => {
console.log('Element re-mounted after being removed');
});
}
});Creating notifiers before mounting
You can call getNotifier() at any time, even before an element mounts:
const observer = new MountObserver({
matching: '#future-button'
});
observer.observe(document);
// Get notifier before element exists
const button = document.createElement('button');
button.id = 'future-button';
const notifier = observer.getNotifier(button);
notifier.addEventListener('mount', () => {
console.log('Button mounted!'); // This WILL fire
});
// Add to DOM later
document.body.appendChild(button);When the notifier is created before the element mounts, the mount event fires normally.
Use cases
Element-specific notifiers are useful for:
- Progressive enhancement: Attach/detach behaviors when elements mount/dismount
- Cleanup on disconnect: Remove event listeners or cancel timers when elements are removed
- Peer element coordination: React to changes in related elements
- Lifecycle-aware components: Build components that respond to their own mounting state
Performance notes
- Notifiers are cached in a WeakMap, so calling
getNotifier()multiple times for the same element returns the same EventTarget - No explicit cleanup is needed - notifiers are garbage collected when their elements are
- The notifier continues to exist even after the element disconnects, allowing it to receive mount events if the element is re-added
Method signature:
getNotifier(element: Element): EventTarget[Implemented as Requirement13]
Extra lazy loading
By default, the matches would be reported as soon as an element matching the criterion is found or added into the DOM, inside the node specified by rootNode.
However, we could make the loading even more lazy by specifying intersection options:
const observer = new MountObserver({
select: 'my-element', //not supported by polyfill
whereElementIntersectsWith:{
rootMargin: "0px",
threshold: 1.0,
},
import: './my-element.js'
});Subscribing
Subscribing can be done via:
observer.addEventListener('confirm', e => {
e.isSatisfied = true; //or false to prevent the mount event below
});
observer.addEventListener('mount', e => {
console.log({
mountedElement: e.mountedElement,
module: e.module
});
});
observer.addEventListener('dismount', e => {
...
});
observer.addEventListener('disconnect', e => {
...
});
observer.addEventListener('move', e => {
...
});
observer.addEventListener('reconnect', e => {
...
});
observer.addEventListener('reconfirm', e => {
...
});
observer.addEventListener('exit', e => {
...
});
observer.addEventListener('forget', e => {
...
});[mount, dismount, disconnect] events implemented
Explanation of all states / events
Normally, an element stays in its place in the DOM tree, but the conditions that the MountObserver instance is monitoring for can change for the element, based on modifications to the attributes of the element itself, or its custom state, or to other peer elements within the shadowRoot, if any, or window resizing, etc. As the element meets or doesn't meet all the conditions, the mountObserver will first call the corresponding mount/dismount callback, and then dispatch event "mount" or "dismount" according to whether the criteria are all met or not.
The moment a MountObserver instance's "observe" method is called (passing in a root node), it will inspect every element within its subtree (not counting ShadowRoots), and then call the "mount" callback, and dispatch event "mount" for those elements that match the criteria. It will not dispatch "dismount" for elements that don't.
If an element that is in "mounted" state according to a MountObserver instance is move

