Skip to content

untemps/dom-observer

Repository files navigation

@untemps/dom-observer

Class to observe DOM mutations of a specific element in one-shot or continuous mode.

The class is a wrapper around the MutationObserver API to target an element in particular.
That means you can observe an element to be added to the DOM and access to its properties, an attribute from that element to be changed and get the old and the new values, the element to be removed from the DOM and destroy all its dependencies.

npm GitHub Workflow Status Codecov

Installation

yarn add @untemps/dom-observer

Usage

Import DOMObserver:

import { DOMObserver } from '@untemps/dom-observer'

Create an instance of DOMObserver:

const observer = new DOMObserver()

Watch for recurring mutations

Use the watch method when you want to be notified every time a mutation occurs — for instance, tracking all successive attribute changes on an element or reacting to every matching node added to the DOM.

import { DOMObserver } from '@untemps/dom-observer'

// Track every attribute change on an element
const observer = new DOMObserver()
observer.watch('#foo', (node, event, { attributeName, oldValue } = {}) => {
	console.log(`${attributeName} changed from ${oldValue} to ${node.getAttribute(attributeName)}`)
}, { events: [DOMObserver.CHANGE] })

// React to every matching node added or removed
const listObserver = new DOMObserver()
listObserver.watch('.list-item', (node, event) => {
	if (event === DOMObserver.ADD) console.log(`Item added: ${node.textContent}`)
	if (event === DOMObserver.REMOVE) console.log(`Item removed: ${node.textContent}`)
}, { events: [DOMObserver.ADD, DOMObserver.REMOVE] })

Unlike wait, watch does not return a Promise. It returns this, allowing method chaining. Call clear() to stop the observation.

Pass once: true to stop the observation automatically after the first matching event, without needing to call clear() manually:

observer.watch('#foo', (node, event) => {
	doSomething(node)  // called exactly once
}, { events: [DOMObserver.ADD], once: true })

Pass debounce to delay the callback until mutations have stopped for a given number of milliseconds — useful when you only care about the final state after a burst of rapid changes:

observer.watch('#progress', (node) => {
	console.log('final value:', node.getAttribute('data-value'))
}, {
	events: [DOMObserver.CHANGE],
	attributeFilter: ['data-value'],
	debounce: 100,
})

Pass a timeout to automatically stop the observation if no matching mutation occurs within the allotted time:

const observer = new DOMObserver()
observer.watch('#foo', (node, event) => {
	console.log(`Event: ${event}`)
}, {
	events: [DOMObserver.ADD],
	timeout: 3000,
	onError: (err) => console.error(err.message),
})

watch method arguments

Props Type Description
target Element or String DOM element or selector of the DOM element to observe. See querySelector spec
onEvent Function Callback triggered each time an event occurs on the observed element
options Object Options object:
- events Array List of events to observe (All events are observed by default)
- attributeFilter Array List of attribute names to observe (DOMObserver.CHANGE event only)
- timeout Number Duration (in ms) after which observation stops if no matching mutation occurred. Triggers onError when elapsed. Must be 0 or a positive finite number — throws [TIMEOUT] otherwise.
- onError Function Callback triggered when timeout elapses with no matching mutation
- signal AbortSignal An AbortSignal to stop the observation. If already aborted, watch() returns immediately without observing.
- once Boolean When true, automatically calls clear() after the first matching event. Defaults to false.
- debounce Number Milliseconds to wait after the last mutation before invoking the callback. The callback receives the last mutation's arguments. 0 disables debouncing.
- root Element or String DOM element or CSS selector to use as the observation root. Only mutations within this subtree are observed. Defaults to document.documentElement.
- filter Function (node, event, options?) => boolean. Called before invoking the callback. Return false to skip the event and keep observing.

onEvent callback arguments

Props Type Description
node Element Observed element node
event String Event that triggered the callback
options Object Present only for CHANGE events:
- attributeName String Name of the attribute that changed
- oldValue String or null Value of the attribute before the mutation

onError callback arguments

Props Type Description
error Error Error thrown

Wait for a one-shot mutation

Use the wait method to get a Promise that resolves on the first matching mutation.

import { DOMObserver } from '@untemps/dom-observer'

const observer = new DOMObserver()
const { node, event, options: { attributeName } = {} } = await observer.wait('#foo', { events: [DOMObserver.REMOVE, DOMObserver.CHANGE] })
switch (event) {
	case DOMObserver.REMOVE: {
		console.log('Element ' + node.id + ' has been removed')
		break
	}
	case DOMObserver.CHANGE: {
		console.log('Element ' + node.id + ' has been changed (' + attributeName + ')')
		break
	}
}

Pass an array of targets to resolve as soon as any one of them fires a matching event. The resolved value includes a target field identifying which entry won:

const { node, target } = await observer.wait(['#success', '#error'], {
	events: [DOMObserver.ADD],
})
console.log(`Matched: ${target}`)

Once the first matching mutation occurs, the Promise resolves and the observation stops automatically. If a timeout is set and elapses before any matching mutation, the Promise rejects with a [TIMEOUT] error.

wait method arguments

Props Type Description
target Element, String, or Array DOM element, selector, or array of either. When an array is passed, resolves on the first match across all entries.
options Object Options object:
- events Array List of events to observe (All events are observed by default)
- timeout Number Duration (in ms) before rejecting the Promise with a [TIMEOUT] error. 0 disables the timeout. Must be 0 or a positive finite number — rejects with [TIMEOUT] otherwise.
- attributeFilter Array List of attribute names to observe (DOMObserver.CHANGE event only)
- signal AbortSignal An AbortSignal to cancel the observation. If already aborted, the Promise rejects immediately with an AbortError.
- root Element or String DOM element or CSS selector to use as the observation root. Only mutations within this subtree are observed. Defaults to document.documentElement.
- filter Function (node, event, options?) => boolean. Called before resolving the Promise. Return false to skip the event and keep waiting.

Resolved value

Props Type Description
node Element The matching DOM element
event String The event type that caused the Promise to settle
target Element, String, or undefined The entry from the targets array that matched. undefined when a single target was passed.
options Object Present only for CHANGE events:
- attributeName String Name of the attribute that changed
- oldValue String or null Value of the attribute before the mutation

Events

DOMObserver static properties list all observable events.

Props Description
DOMObserver.EXIST Observe whether the element is already present in the DOM at observation start
DOMObserver.ADD Observe when the element is added to the DOM
DOMObserver.REMOVE Observe when the element is removed from the DOM
DOMObserver.CHANGE Observe when an attribute has changed on the element
DOMObserver.EVENTS Array of all four events

One or more events can be passed to the events option of wait or watch. By default, all events are observed.

{ events: [DOMObserver.ADD, DOMObserver.REMOVE] }
{ events: DOMObserver.EVENTS }

Check observation state

The isObserving getter returns true when an observation is currently active:

const observer = new DOMObserver()
observer.watch('#foo', (node, event) => { /* ... */ })
console.log(observer.isObserving) // true
observer.clear()
console.log(observer.isObserving) // false

Discard observation

Call the clear method to discard observation. It returns this, allowing method chaining:

observer.clear()

// Stop and immediately restart with a different target
observer.clear().watch('#bar', onEvent)

Note: Calling wait() or watch() on an instance that already has a pending wait() Promise will automatically reject that Promise with an [ABORT] error before starting the new observation. Handle this rejection if necessary:

import { DOMObserver, DOMObserverErrors } from '@untemps/dom-observer'

const observer = new DOMObserver()
observer.wait('#foo').catch((err) => {
    if (err.message.startsWith(DOMObserverErrors.ABORT)) return // replaced by a new observation
    throw err
})
observer.wait('#bar')  // previous promise is rejected with [ABORT]
observer.watch('#baz', onEvent)  // also rejects a pending wait() with [ABORT]

Error constants

The library exports a DOMObserverErrors object and a DOMObserverErrorCode type for reliable error handling without fragile string matching:

import { DOMObserver, DOMObserverErrors } from '@untemps/dom-observer'

try {
    await observer.wait('#foo', { timeout: 500 })
} catch (e) {
    const message = (e as Error).message
    if (message.startsWith(DOMObserverErrors.TIMEOUT)) {
        // handle timeout
    } else if (message.startsWith(DOMObserverErrors.ABORT)) {
        // replaced by another observation
    }
}
Constant Value Thrown by
DOMObserverErrors.TIMEOUT '[TIMEOUT]' wait(), watch() when timeout elapses; also when timeout is an invalid value (-1, NaN, Infinity)
DOMObserverErrors.ABORT '[ABORT]' wait() when replaced by a new call
DOMObserverErrors.EVENTS '[EVENTS]' wait(), watch() when events array is empty
DOMObserverErrors.TARGET '[TARGET]' wait(), watch() when target is an invalid CSS selector

Example

import { DOMObserver } from '@untemps/dom-observer'

// Continuous observation with timeout
const onError = (err) => console.error(err.message)
const observer = new DOMObserver()
observer.watch(
    '.foo',
    (node, event, { attributeName } = {}) => {
        switch (event) {
            case DOMObserver.EXIST: {
                console.log('Element ' + node.id + ' exists already')
                break
            }
            case DOMObserver.ADD: {
                console.log('Element ' + node.id + ' has been added')
                break
            }
            case DOMObserver.REMOVE: {
                console.log('Element ' + node.id + ' has been removed')
                break
            }
            case DOMObserver.CHANGE: {
                console.log('Element ' + node.id + ' has been changed (' + attributeName + ')')
                break
            }
        }
    },
    {
        events: [DOMObserver.EXIST, DOMObserver.ADD, DOMObserver.REMOVE, DOMObserver.CHANGE],
        timeout: 2000,
        onError,
        attributeFilter: ['class'],
    }
)

Development

A demo can be served for development purpose on http://localhost:5173/ running:

yarn dev