Custom Elements Reacting to Changes
October 01, 2024 📬 Get My Weekly Newsletter ☞
In the end
notes
of my post on creating a sorting and filter table using custom
elements,
I mentioned that my solution would not work if the <table>
inside <fancy-table>
was modified. This post
outlines how to address that using MutationObserver
, and it’s kinda gnarly.
The Problem - Your DOM Changes out From Under You
The contract of the <fancy-table>
as that if sort-column
was set, the table’s rows would be sorted, and if
filter-terms
was set, only rows matching the filter would be shown. That contract breaks if the inside of the
<table>
is modified.
Ideally, whatever behavior an HTML Web Component bestows upon the DOM it wraps is bestowed to whatever is in there, no matter when or how it got there. The most acute version of this problem is how the elements are initialized when the DOM is loaded.
Element Initialization And DOMContentLoaded
In my previous solution you’ll note that I had to call customElements.define
inside a
DOMContentLoaded
event, or the custom elements wouldn’t have access to the DOM they wrap in order to do what
they need to do.
To explain this secondary point more, here is my understanding of the order of operations.
- In the
<script>
tag,customElements.define("fancy-table",FancyTable)
is called, which tells the browser that<fancy-table>
is a custom element implemented byFancyTable
. - As the DOM is loaded, when
<fancy-table>
is parsed, aFancyTable
is created andconnectedCallback()
is called. At this point,this.innerHTML
is empty, since the browser has not examined anything beyond the<fancy-table>
element it’s currently parsing. - The rest of the DOM is parsed. There is no callback for this as part of the custom element spec, so the custom elements essentially don’t do anything at all.
By moving customElements.define
into DOMContentLoaded
, things are reversed:
- The DOM is parsed and loaded.
customElements.define(...)
is called.- This causes the browser to call
connectedCallback
on all the custom elements. At this point each elements’innerHTML
is present, sothis.querySelector
and friends work.
Both this startup issue and the need to handle changes made external to the element can be solved by using a MutationObserver
.
MutationObserver
Tells You When the DOM Changes
If you recall my implementation, each custom element class
implemented a method called #update()
that did whatever the element was supposed to do. #update()
was called
from connectedCallback
as well as attributeChangedCallback
. This provided centralization of the custom
elements’ core behavior.
To address both the startup problem and the “someone changed our DOM” problem, we need to call #update()
whenever the DOM changes. We can arrange that with MutationObserver
.
It works like so:
// 1. Create a callback called when something changes
// mutationRecords is an array of MutationRecord instances
// describing the changes
const mutated = (mutationRecords) => {
}
// 2. Create the observer
// NOTE: nothing is being observed yet
const observer = new MutationObserver(mutated)
// 3. Start observing changes to `element`, based on
// the contents of `options` (see below)
observer.observe(element,options)
For a custom element to observe changes to itself, it will need to call observe
like so:
observer.observer(
this,
{
subtree: true, // observe `this` and all children
childList: true, // get notified about additions/removals
}
)
You can also observe attribute and CDATA
changes if you like, but for my purposes here, it’s just enough to
know that any element inside the subtree was added or removed. And, it’s not necessary to know what happened. I just need to call #update()
whenever there is a change, so the element can re-sort and re-filter the table’s rows.
The problem is that #update()
creates changes that are observed that trigger the observer that then call
#update()
, thus creating an infinite loop.
Pausing Observation While Changing Things
The way I addressed this was to stop observing changes before calling #update()
, then resuming observation
again after that. I’m assuming no DOM changes are happening during #update()
because I think all changes to
the DOM must happen in a single, synchronous thread (though I’m not 100% sure).
First, I created two method, #observeMutations
and #stopObservingMutations
:
// Inside FancyTable
#mutationObserver = null;
#mutated = () => {
this.#stopObservingMutations();
this.#update();
this.#observeMutations();
};
#observeMutations() {
this.#stopObservingMutations();
if (!this.#mutationObserver) {
this.#mutationObserver = new MutationObserver(this.#mutated);
}
this.#mutationObserver.observe(this, { subtree: true, childList: true });
}
Next, observation has to start when the element is connected, thus it’s called in connectedCallback
:
connectedCallback() {
this.#observeMutations()
this.update();
}
Lastly, the callback will stop observation, call #update()
and then resume observation:
#mutated = (records,observer) => {
if (this.update) {
this.#stopObservingMutations()
this.#update();
this.#observeMutations()
}
}
In general, this worked, however it needed to be added to all my custom elements. Since it’s so complicated, I created a base class to hold this and slightly changed the code from the previous post. You can see it all on CodePen. Notice that the elements are no longer defined inside a DOMContentLoaded
event handler.
When you add as new row to the table, the form will essentially add a <tr><td>…</td><td>…</td></tr>
to the end of the table’s <tbody>
. If you first sort or filter the table, you’ll notice that the row ends up sorted in the right place. Here is how things happen:
- The form is submitted.
- The form’s submit handler creates a new
<tr>
and puts two<td>
elements in it. - The form’s submit handler locates the
<fancy-table>
’s<tbody>
and callsappendChild(tr)
. - The new
<tr>
is added as the last row of the table. - This triggers the mutation observer, so
FancyTable
’s#mutate
is called, which pauses observation and calls#update()
#update()
then sorts the table based on itssort-column
andsort-direction
attributes.#update()
completes and mutation observation is resumed.
This all happens in the blink of an eye, so it appears that the new row is inserted into the right place.
The Browser is Powerful, but Could Do More
I’m glad MutationObserver
exists so that these issues could be solved. I do wish the custom elements API had
richer callbacks so all of this could be avoided. Much like attributeChangedCallback
is called when observed
attributes are changed, it would be nice to have, say, domChangedCallback
to be invoked whenever the DOM was
changed, and have it not result in an infinite loop if the DOM was changed from that callback.
Still, the existence of MutationObserver
demonstrates the web’s power and flexibility. And while
MutationObserver
can be complex, our degenerate case didn’t end up requiring too much code. And it all seems
to be working pretty well.
Other Notes
While working on this, I realized that using nth-child(odd)
and hidden
doesn’t work properly. There is no
way I could determine to properly select even and odd tr
elements that were not hidden
using only CSS. It seems nth-child
just doesn’t work that way.
So, what I did was added a #stripe()
helper that goes through each non-hidden <tr>
and adds
data-stripe="odd"
or data-stripe="even"
, which CSS then uses. I hate to have to use a data-
element, but
again, this is the depth of the platform. If you can’t do something at a high level, you can do it at a low
level.