A small, low-level utility for working with the (real) DOM in realtime!
Info This is (Early stage) documentation for
v2.x
. (Looking forv1.x
?)
- Download Options
- The Ready-State API
- The Realtime Mutations API
- The Render Scheduling API
- Issues
- License
Use as an npm package:
npm i @webqit/realdom
// Import
import init from '@webqit/realdom';
// Initialize the lib
init.call( window );
// Obtain the APIs
const { ready, realtime, schedule } = window.webqit.realdom;
Use as a script:
<script src="https://unpkg.com/@webqit/realdom/dist/main.js"></script>
// Obtain the APIs
const { ready, realtime, schedule } = window.webqit.realdom;
Know when the document is ready! This is a simplistic API for working with the document's ready state.
Know when the document is ready.
// Signature 1
ready([ callback = undefined ]);
// Signature 2
ready([ timing = 'interactive'[, callback = undefined ]]);
The ready()
function takes a callback function to be called at a certain document-ready state. This function receives the window
object.
// Binding to the document's ready state
ready( window => console.log( `Document "ready state" is now "interactive"` ) );
When no callback function is provided, a promise is returned.
// Awaiting the document's ready state
await ready();
console.log( `Document "ready state" is now "interactive"` );
--> Use the two-parameter syntax to specify the timing
- i.e. ready state - at which to be called. This can be either of two values:
-
interactive
- (The default) The point at which the document has finished loading and the document has been parsed but sub-resources such as scripts, images, stylesheets and frames are still loading. -
complete
- The point at which the document and all sub-resources have finished loading.
// Binding to the document's "complete" ready state
ready( 'complete', () => console.log( 'Document "ready state" is now "complete"' ) );
// Awaiting the document's "complete" ready state
await ready( 'complete' );
console.log( `Document "ready state" is now "complete"` );
React to realtime DOM operations! This is a set of succint and consistent methods for accessing the DOM - either on-demand (you calling the DOM... as you would using querySelectorAll()
) or in realtime (you letting the DOM call you... as you would using the MutationObserver API).
Observe the (real) DOM in realtime!
// Signature 1
realtime( context ).observe( callback[, options = {} ]);
// Signature 2
realtime( context ).observe( targets, callback[, options = {} ]);
Report all direct children additions and removals to/from the given element - the context:
// Observing all direct children mutations
realtime( document.body ).observe( handleChanges );
--> Observe entire subtree of the given element using the options.subtree
flag:
// Observing all subtree mutations
realtime( document.body ).observe( handleChanges, { subtree: true } );
Info Given that only direct children are covered without the
options.subtree
flag, you'd normally always need this flag when thedocument
object is the context.
--> Observe into Shadow Roots of the given element, however nested, using the options.subtree = 'cross-roots'
flag:
// Observing all subtree mutations
realtime( document.body ).observe( handleChanges, { subtree: 'cross-roots' } );
Pass in a CSS selector to match elements in realtime; e.g. "p" for all <p>
elements:
// Observing only "p" elements mutations
realtime( document.body ).observe( 'p', handleChanges, { subtree: true } );
...whether "p" elements added via markup:
// and whether or not it's deeply nested as par { subtree: true }:
document.body.innerHTML = '<div><p></p></div>';
...or "p" elements added programmatically:
// and whether or not it's deeply nested as par { subtree: true }:
const p = document.createElement( 'p' );
const div = document.createElement( 'div' );
div.appendChild( p );
document.body.appendChild( div );
--> Pass in an Xpath query to express what can't be expressed in a CSS selector; e.g. for when you need to match text and comment nodes in realtime. Xpath expressions must be enclosed in parentheses.
// Observing all "comment" nodes having a certain content
realtime( document.body ).observe( '(comment()[contains(., "hello world")])', handleChanges, { subtree: true } );
Info Note that Xpath expressions must not be prefixed with the direct children
/
or descendant//
qualifiers as that is controlled internally. Theoptions.subtree
parameter is how you specify the resolution context for your queries.
--> Observe element instances as targets too. (E.g. a "p" instance.)
// observing an instance plus a selector
const pElement = document.createElement( 'p' );
realtime( document.body ).observe( [ pElement, orCssSelector ], handleChanges, { subtree: true } );
...both for when they're added:
// and whether or not it's deeply nested as par { subtree: true }:
const div = document.createElement( 'div' );
div.appendChild( pElement );
document.body.appendChild( div );
...and when they're removed:
// either via an overwrite... (indirect overwrite in this case)...
document.body.innerHTML = '';
// or via some programmatic means... (indirect removal in this case)...
document.querySelector( 'div' ).remove();
Handle mutation records - each having an entrants
and an exits
array property, representing added and removed nodes respectively:
// Handling changes
function handleChanges( record ) {
for ( const addedNode of record.entrants ) {
console.log( 'added:', addedNode );
}
for ( const removedNode of record.exits ) {
console.log( 'removed:', removedNode );
}
}
--> Use the options.generation
parameter to require only either the entrants
or exits
list:
// Requiring only the "entrants" list
realtime( document.body ).observe( handleChanges, { generation: 'entrants' } );
// Handling just record.entrants
function handleChanges( record, context ) {
for ( const addedNode of record.entrants ) {
console.log( 'added:', addedNode );
}
console.log( record.exits ); // Empty array
}
--> Use the record.target
property to access the mutation target - often, the parent element under which mutation happened:
// Inspecting record.target
function handleChanges( record ) {
console.log( record.target ); // HTMLBodyElement
}
When targeting elements using attribute selectors, use the options.staticSensitivity
flag to opt in to statically matching elements based on the attributes mentioned in the selector:
// Adding the options.staticSensitivity flag
realtime( document.body ).observe( 'p[draggable="true"]', handleChanges, { staticSensitivity: true } );
Now, "p" elements are matched for [draggable="true"]
in their static state too:
// The following "p" element suddenly matches and is reported (record.entrants)
document.querySelector( 'p' ).setAttribute( 'draggable', 'true' );
// The following "p" element suddenly doesn't match and is reported (record.exits)
document.querySelector( 'p' ).setAttribute( 'draggable', 'false' );
Use the option.eventDetails
flag to require the actual DOM operation that happened under the hood:
// Requiring that event details be added
realtime( document.body ).observe( 'p[draggable="true"]', handleChanges, { eventDetails: true } );
// Inspecting record.event
function handleChanges( record ) {
console.log( record.event );
}
You get an array in the format: [ HTMLBodyElement, 'appendChild' ]
- for mutations that happen programatically:
// Running an operation
document.body.appendChild( pElement );
You get the keyword: parse
- for elements recorded directly from the HTML parser while the document loads. (This happens for mutation listeners created early in the document tree.):
<html>
<head>
<script>
realdom.realtime( document ).observe( 'meta[foo]', handleChanges, { subtree: true } );
</script>
<meta name="foo" content="bar">
<!--
At this point in the document parsing, the meta element is now reported, and record.event is: "parse"
-->
<script>
const meta2 = document.createElement( 'meta' );
meta2.name = 'foo';
meta2.content = 'baz';
document.head.appendChild( meta2 );
</script>
<!--
At this point in the document parsing, the meta2 element is now reported, and record.event is: [ HTMLHeadElement, 'appendChild' ]
-->
</head>
</html>
You get the keyword: mutation
- for mutations that happen in other ways; e.g in when the user directly alters the DOM tree from the browser console.
Pass in an Abort Signal that you can use to abort your mutation listener at any time:
// Providing an AbortSignal
const abortController = new AbortController;
realtime( document.body ).observe( 'p', handleChanges, { signal: abortController.signal } );
// Abort at any time
abortController.abort();
When dealing with nested event listeners - event handlers that themselves create event listeners, tying child listeners' lifecycle to parent's lifecycle can be cumbersome.
// Managing nested lifecycles using multiple AbortSignals
const parentAbortController = new AbortController;
let recursionAbortController;
realtime( document.body ).observe( 'p', record => {
// Abort all nested listeners in "previous" recursion
recursionAbortController?.abort();
// Create a new AbortController for listeners in "this" recursion
recursionAbortController = new AbortController;
for( const addedNode of record.entrants ) {
addedNode.addEventListener( 'click', handleClick, { signal: recursionAbortController.signal } );
}
}, { signal: parentAbortController.signal } );
// Abort parent at any time
parentAbortController.abort();
// Abort the latest instance of recursionAbortController
recursionAbortController?.abort();
--> Use the options.lifecycleSignals
parameter to opt in to receiving auto-generated signals for tying nested listeners:
// Managing nested lifecycles using automatic lifecycle signals
const parentAbortController = new AbortController;
realtime( document.body ).observe( 'p', ( record, flags ) => {
for( const addedNode of record.entrants ) {
addedNode.addEventListener( 'click', handleClick, { signal: flags.signal } );
}
}, { signal: parentAbortController.signal, lifecycleSignals: true } );
// Abort parent at any time
parentAbortController.abort();
// The latest flags.signal instance is also automatically aborted
For when timing is everything, meet the options.timing
parameter!
By default, mutation records are delivered at the "async" timing of the MutationObserver API. This means that there's a small lag between when mutations happen and when they are delivered.
// Observing with "asynchronous" timing
let deliveredElement;
realtime( document.body ).observe( 'p', record => {
deliveredElement = record.entrants[ 0 ];
} );
// Confirming the "async" delivery
const pElement = document.createElement( 'p' );
document.body.appendChild( pElement );
console.log( pElement.isConnected ); // true
console.log( deliveredElement ); // undefined
// Estimating delivery timing
setTimeout( () => {
console.log( deliveredElement ); // HTMLParagraphElement
}, 0 );
--> Use the options.timing = "sync"
parameter to observe mutations synchronously:
// Opting in to "synchronous" timing
let deliveredElement;
realtime( document.body ).observe( 'p', record => {
deliveredElement = record.entrants[ 0 ];
}, { timing: 'sync' } );
// Confirming the "sync" delivery
const pElement = document.createElement( 'p' );
document.body.appendChild( pElement );
console.log( pElement.isConnected ); // true
console.log( deliveredElement ); // HTMLParagraphElement
There is also a rare case where a tool needs to extend the DOM in more low-level ways, and this time, needs to intercept certain mutations before they actually happen. For example, you could only really rewrite <script>
elements before they're parsed and executed if you could intercept them.
--> Use the options.timing = "intercept"
parameter to observe mutations before they actually happen:
// Trying the "intercept" timing
realtime( document.body ).observe( 'script', handleScripts, { timing: 'intercept' } );
// Making the mutation
const scriptElement = document.createElement( 'script' );
document.body.appendChild( scriptElement );
// Confirming the "intercpted" delivery
function handleScripts( record ) {
const deliveredElement = record.entrants[ 0 ];
// We're receiving an element that is only just about to be added to the DOM
console.log( deliveredElement.isConnected ); // false
console.log( deliveredElement.parentNode ); // null
console.log( record.event ); // [ HTMLBodyElement, 'appendChild' ]
// We can rewrite this script
deliveredElement.text = 'alert( "Tada!" )';
}
And thanks to the MutationObserver API, interception also works at parse time for mutation listeners created early enough:
<html>
<head>
<script>
realdom.realtime( document ).observe( 'script[rewriteme]', handleScripts, { timing: 'intercept' } );
</script>
<script rewriteme>
alert( 'Hello world!' );
</script>
<!--
At this point in the document parsing, the script[rewriteme] element is now reported, and rewritten
But note that this time, element was just already in the DOM, only yet to be handled. So...
console.log( deliveredElement.isConnected ); // true
console.log( deliveredElement.parentNode ); // HTMLHeadElement
console.log( record.event ); // "parse"
-->
</head>
</html>
Work with the (real) DOM both on-demand (as you would with querySelectorAll()
), and in realtime (as you would with realtime().observe()
).
// Signature 1
realtime( context ).query([ callback = undefined[, options = {} ]]);
// Signature 2
realtime( context ).query( targets[, callback = undefined[, options = {} ]]);
// Signature 3
const records = realtime( context ).query([ options = {} ]);
Get all direct children of the given element delivered:
// Getting all children delivered to a callback
realtime( document.body ).query( handleResult );
// Retreiving all children
const records = realtime( document.body ).query();
// Deligating to handler
records.forEach( record => handleResult( record ) );
--> Use the realtime().children()
alias:
// Delivering, using the .children() alias
realtime( document.body ).children( handleResult );
// Retreiving, using the .children() alias
const records = realtime( document.body ).children();
// Deligating to handler
records.forEach( record => handleResult( record ) );
Info Using the
options.subtree
flag without specifying targets produces no result.
Pass in a CSS selector to match elements: e.g. "p" for all <p>
elements:
// Matching just "p" children
realtime( document.body ).query( 'p', handleResult );
// Using the .children() alias
realtime( document.body ).children( 'p', handleResult );
// Retreiving, using the .children() alias
const records = realtime( document.body ).children( 'p' );
// Deligating to handler
records.forEach( record => handleResult( record ) );
--> Use the options.subtree
flag to query the entire subtree.
// Using the options.subtree flag
realtime( document.body ).query( 'p', handleResult, { subtree: true } );
--> Use the realtime().subtree()
alias:
// Using the .subtree() alias
realtime( document.body ).subtree( 'p', handleResult );
// Retreiving, using the .subtree() alias
const records = realtime( document.body ).subtree( 'p' );
// Deligating to handler
records.forEach( record => handleResult( record ) );
--> Pass in an Xpath query to express what can't be expressed in a CSS selector; e.g. for when you need to match text and comment nodes. Xpath expressions must be enclosed in parentheses.
// Query all "comment" nodes having a certain content
realtime( document.body ).query( '(comment()[contains(., "hello world")])', handleResult, { subtree: true } );
Info: Note that Xpath expressions must not be prefixed with the direct children
/
or descendant//
qualifiers as that is controlled internally. Theoptions.subtree
parameter is how you specify the resolution context for your queries.
Handle query result records in the same way as mutation records:
// Handling query result
function handleResult( record ) {
// record.entrants is the list of matched nodes
for ( const matchedNode of record.entrants ) {
console.log( 'matched:', matchedNode );
}
console.log( record.exits ); // Always an empty array
console.log( record.event ); // Always the keyword: "query"
}
Info Setting the
options.generation
parameter toexits
effectively defies the logic, thus no query actually happens.
--> Use the record.target
property to access the query context for the record:
// Inspecting record.target
function handleResult( record ) {
console.log( record.target ); // HTMLBodyElement
}
Info Elements are organized into records by common parent. Thus, an expression like
realtime().subtree( 'p' )
on the below will produce 2 records, with 2 entrants per record:<html> <body> <section> <p></p> <p></p> </section> <p></p> <p></p> </body> </html>
Get both current and future elements delivered to the same handler function:
// Matching all current "p[draggable]" elements
realtime( document ).query( 'p[draggable]', handleDraggables, { subtree: true } );
// Subscribe to future matching elements
realtime( document ).observe( 'p[draggable]', handleDraggables, { subtree: true } );
--> Use the options.live
flag to achieve the same:
// Matching all current "p[draggable]" elements and staying subscribed
realtime( document ).query( 'p[draggable]', handleDraggables, { subtree: true, live: true } );
--> Use equivalent APIs the same way:
// Using the .subtree() alias
realtime( document ).subtree( 'p[draggable]', handleDraggables, { live: true } );
// Using the .children() alias
realtime( document ).children( 'p[draggable]', handleDraggables, { live: true } );
--> Use the options.live
flag in conjunction with other concepts:
// Using the options.live flag
const abortController = new AbortController;
realtime( document ).children( 'p[draggable]', handleDraggables, {
live: true,
signal: abortController.signal,
lifecycleSignals: true,
staticSensitivity: true,
timing: 'sync',
eventDetails: true,
} );
Observe DOM attributes in realtime!
// Signature 1
realtime( context, 'attr' ).observe( callback[, options = {} ]);
// Signature 2
realtime( context, 'attr' ).observe( targets, callback[, options = {} ]);
Report all attribute changes on the given element:
// Observing all attributes
realtime( document.body, 'attr' ).observe( handleChanges );
--> Observe all attribute changes across the entire subtree of the given context using the options.subtree
flag:
// Observing entire subtree
realtime( document.body, 'attr' ).observe( handleChanges, { subtree: true } );
Info Given that only direct children are covered without the
options.subtree
flag, you'd normally always need this flag when thedocument
object is the context.
--> Observe into Shadow Roots of the given element, however nested, using the options.subtree = 'cross-roots'
flag:
// Observing entire subtree
realtime( document.body, 'attr' ).observe( handleChanges, { subtree: 'cross-roots' } );
Observe specific attributes on the given context:
// Observing the "draggable" attribute
realtime( element, 'attr' ).observe( [ 'draggable' ], handleChanges );
Handle mutation records - an array of attribute-change records:
// Handling mutation records
function handleChanges( records ) {
for ( const record of records ) {
console.log( record.name );
}
}
--> Use the options.oldValue
and options.newValue
flags to require attribute's old and new values respectively:
// Requiring attribute's old and new value
realtime( element, 'attr' ).observe( [ 'draggable' ], handleChanges, { newValue: true, oldValue: true } );
// Inspecting attribute's old and new value
function handleChanges( records ) {
for ( const record of records ) {
console.log( record.name, record.value, record.oldValue );
}
}
--> Use the record.target
property to access the mutation target - the element on which mutation happened:
// Inspecting record.target
function handleChanges( records ) {
console.log( records[ 0 ].target );
}
--> Where exactly one attribute is being observed and is passed as a string instead of an array, mutation records are delivered in singular form instead of as an array:
// Passing the observed attribute as a bare string instead of an array
realtime( element, 'attr' ).observe( 'draggable', handleChanges );
// Receiving records in singular form instead of as an array
function handleChanges( record ) {
console.log( record.name );
}
Get records for multiple attributes delivered atomically - in whole - whenever any of the attributes change:
// Observing multiple attributes atomically
realtime( element, 'attr' ).observe( [ 'attr1', 'attr2', 'attr3' ], handleChanges, { atomic: true } );
// Receiving all 3 records anytime any one of them changes
function handleChanges( records ) {
const [ attr1, attr2, attr3 ] = records;
}
Use the option.eventDetails
flag to require the actual DOM operation that happened under the hood:
// Requiring that event details be added
realtime( element, 'attr' ).observe( [ 'draggable' ], handleChanges, { eventDetails: true } );
// Inspecting record.event
function handleChanges( records ) {
console.log( records[ 0 ].event );
}
You get an array in the format: [ HTMLInputElement, 'toggleAttribute' ]
- for mutations that happen programatically:
// Running an operation
element.toggleAttribute( 'required' );
You get the keyword: mutation
- for mutations that happen in other ways; e.g in when the user directly alters an element's attribute from the browser console.
Pass in an Abort Signal that you can use to abort your mutation listener at any time:
// Providing an AbortSignal
const abortController = new AbortController;
realtime( element, 'attr' ).observe( [ 'required' ], { signal: abortController.signal } );
// Abort at any time
abortController.abort();
When dealing with nested event listeners (see above), use the options.lifecycleSignals
parameter to opt in to receiving auto-generated signals for tying nested listeners:
// Managing nested lifecycles using automatic lifecycle signals
const parentAbortController = new AbortController;
realtime( element, 'attr' ).observe( [ 'draggable' ], ( records, flags ) => {
if ( records[ 0 ].value === 'true' ) {
element.addEventListener( 'drag', handleDrag, { signal: flags.signal } );
}
}, { newValue: true, signal: parentAbortController.signal, lifecycleSignals: true } );
// Abort parent at any time
parentAbortController.abort();
// The latest flags.signal instance is also automatically aborted
For when timing is critical (see above), use the options.timing = "sync"
parameter to observe mutations synchronously:
// Opting in to "synchronous" timing
realtime( element, 'attr' ).observe( [ 'draggable' ], records => {
// Handle records
}, { timing: 'sync' } );
--> Use the options.timing = "intercept"
parameter to observe attribute mutations before they actually happen:
// Opting in to "synchronous" timing
realtime( img, 'attr' ).observe( [ 'src' ], records => {
// Handle records
}, { timing: 'intercept' } );
Work with attributes both on-demand and in realtime (as you would with realtime( element, 'attr' ).observe()
).
// Signature 1
realtime( context, 'attr' ).get([ callback = undefined[, options = {} ]]);
// Signature 2
realtime( context, 'attr' ).get( targets[, callback = undefined[, options = {} ]]);
// Signature 3
const records = realtime( context, 'attr' ).get([ options = {} ]);
Get all attributes on the given element:
// Getting all attributes delivered to a handler
realtime( document.body, 'attr' ).get( handleChanges );
// Retreiving all attributes
const records = realtime( document.body, 'attr' ).get();
// Deligating to handler
handleChanges( records );
Get specific attributes on the given context:
// Getting the "draggable" attributes delivered to a handler
realtime( element, 'attr' ).get( [ 'draggable' ], handleChanges );
// Retreiving the "draggable" attributes
const records = realtime( document.body, 'attr' ).get( [ 'draggable' ] );
// Deligating to handler
handleChanges( records );
Get both current and future attributes delivered to the same handler function:
// Getting the "draggable" attributes delivered to a handler
realtime( element, 'attr' ).get( [ 'draggable' ], handleChanges );
// Observing future "draggable" attribute changes
realtime( element, 'attr' ).observe( [ 'draggable' ], handleChanges );
--> Use the options.live
flag to achieve the same:
// Getting current and future state of the "draggable" attributes delivered to a handler
realtime( element, 'attr' ).get( [ 'draggable' ], handleChanges, { live: true } );
--> Use the options.live
flag in conjunction with other concepts:
// Using the options.live flag with other flags
const abortController = new AbortController;
realtime( element, 'attr' ).get( [ 'attr1', 'attr2', 'attr3' ], handleChanges, {
newValue: true,
oldValue: true,
atomic: true,
live: true,
signal: abortController.signal,
lifecycleSignals: true,
timing: 'sync',
eventDetailsL true,
} );
An alias for realtime( context, 'attr' ).get()
.
-
The
realtime
API's unique timing capabilities is based on literal interception of DOM APIs. And here is the complete list of them:Node
:insertBefore
,replaceChild
,removeChild
,appendChild
,textContent
,nodeValue
.Element
:insertAdjacentElement
,insertAdjacentHTML
,setHTML
,replaceChildren
,replaceWith
,remove
.before
,after
,append
,prepend
,toggleAttribute
,removeAttribute
,setAttribute
.HTMLElement
:outerText
,innerText
.
You may need to consider this caveat on your specific usecase.
Eliminate layout thrashing by scheduling DOM read/write operations. (Compare fastdom)
schedule( 'read', () => {
console.log( 'reading phase of the UI' );
} );
schedule( 'write', () => {
console.log( 'writing phase of the UI' );
} );
schedule( 'read', () => {
console.log( 'reading phase of the UI' );
} );
schedule( 'write', () => {
console.log( 'writing phase of the UI' );
} );
reading phase of the UI
reading phase of the UI
writing phase of the UI
writing phase of the UI
Concept
The schedule
API works as a regulatory layer between your app/library and the DOM. It lets you think of the DOM in terms of a "reading" phase and a "writing" phase, and lets you hook into this cycle when working with the DOM: schedule( 'read', ... )
for doing "read" operations, and schedule( 'write', ... )
for doing "write" operations. Batching DOM operations this way lets us avoid unnecessary document reflows and dramatically speeds up layout performance.
Each read/write operation is added to a corresponding read/write queue. The queues are emptied (reads, then writes) at the turn of the next frame using
window.requestAnimationFrame
.
// Signature
schedule( 'read', readCallback[, inPromiseMode = false ]);
Schedules a job for the "read" phase. Can return a promise that resolves when job eventually executes; you ask for a promise by supplying true
as a second argument.
const promise = schedule( 'read', () => {
const width = element.clientWidth;
}, true/*give back a promise*/ );
// Signature
schedule( 'write', writeCallback[, inPromiseMode = false ]);
Schedules a job for the "write" phase. Can return a promise that resolves when job eventually executes; you ask for a promise by supplying true
as a second argument.
const promise = schedule( 'write', () => {
element.style.width = width + 'px';
}, true/*give back a promise*/ );
// Signature
schedule( 'cycle', readCallback, writeCallback );
Puts your read/write operations in a cycle that keeps in sync with the UI's read/write cycle.
schedule( 'cycle',
// onread
() => {
// Do a read operation
const width = element.clientWidth;
// Now if we return anything other than undefined, the "onwrite" block is executed
return width; // recieved by the "onwrite" callback on its first parameter
},
// onwrite
( width, carried ) => {
// Do a write operation
element.style.width = width + 'px';
// Now if we return anything other than undefined, the cycle repeats starting with the "onread" block
return newCarry; // recieved by the "onwrite" block again on its second parameter: "carried"
}
);
To report bugs or request features, please submit an issue.
MIT.