Using Alpine.js To AJAX'ify HTML Fragments Served From ColdFusion
Over on Chris Ferdinandi's blog, I've been reading about how he "AJAX'ifies" his form submissions by wrapping his <form>
elements in a custom HTML web component: <ajax-form>
. He's a huge fan of web components because they are "the web platform"; but, I don't see why I can't do the same thing using an Alpine.js directive. As such, I wanted to start exploring some AJAX'ification of my own by swapping out ColdFusion generated content within a given branch of the Document Object Model (DOM).
Aside: There are many established libraries that already do this sort of thing. I use Hotwire Turbo, for example, to AJAX'ify this blog. And, HTMX is having quite a moment in the spotlight. The point of this post isn't to demonstrate anything new, it's simply allowing me to start exploring the concept in more depth.
As with Chris Ferdinandi's approach, I'm going to explore this concept by defining an Alpine.js directive (x-ajax-fragment
) that creates a boundary around some area of the DOM tree. This directive will intercept anchor link (<a>
) clicks initiated from within the DOM tree boundary; it will update the URL programmatically to reflect the given anchor link href
; and then, it will perform the HTTP GET
in the background using the fetch()
API before finally hot-swapping the HTML fragment using .innerHTML
.
In order to have something meaningful to swap into the rendered page, I've created a simple ColdFusion application that hard-codes a collection of quotes in the onRequestStart()
event-handler:
component {
// Define the application settings.
this.name = "AlpineJsQuoteDemo";
this.sessionManagement = false;
this.setClientCookies = false;
// ---
// LIFE-CYCLE METHODS.
// ---
/**
* I initialize the request.
*/
public void function onRequestStart() {
request.quotes = [
{ id: 1, text: "Quote 1 is a very fine quote, indeed!", author: "Author 1" },
{ id: 2, text: "Quote 2 is a very fine quote, indeed!", author: "Author 2" },
{ id: 3, text: "Quote 3 is a very fine quote, indeed!", author: "Author 3" },
{ id: 4, text: "Quote 4 is a very fine quote, indeed!", author: "Author 4" },
{ id: 5, text: "Quote 5 is a very fine quote, indeed!", author: "Author 5" },
{ id: 6, text: "Quote 6 is a very fine quote, indeed!", author: "Author 6" },
{ id: 7, text: "Quote 7 is a very fine quote, indeed!", author: "Author 7" },
{ id: 8, text: "Quote 8 is a very fine quote, indeed!", author: "Author 8" },
{ id: 9, text: "Quote 9 is a very fine quote, indeed!", author: "Author 9" },
{ id: 10, text: "Quote 10 is a very fine quote, indeed!", author: "Author 10" }
];
}
}
Each one of these quotes has a unique id
. The goal of the index.cfm
page (ie, this demo) is to either render a specific quote if the URL contains a quoteIndex
query string parameter; or, to render a random quote if no quoteIndex
has been provided. The rendered page will then provide "Prev" and "Next" links to cycle through the quotes from the given starting point.
If JavaScript is disabled (unlikely) or if the JavaScript file should fail to load or has a critical bug, this ColdFusion application will continue to work using the full request-response page request life-cycle. Notice that the Prev and Next links are simply making a request back to the same page with the n-1
and n+1
quoteIndex
query string parameters, respectively.
<cfscript>
param name="url.quoteIndex" type="numeric" default=0;
quoteCount = request.quotes.len();
// If the quote index is provided, use THAT quote; otherwise, use a random quote.
quoteIndex = ( url.quoteIndex )
? val( url.quoteIndex )
: randRange( 1, quoteCount, "sha1prng" )
;
quote = request.quotes[ quoteIndex ];
// Calculate the PREV index (for linking).
if ( ( prevQuoteIndex = ( quoteIndex - 1 ) ) < 1 ) {
prevQuoteIndex = quoteCount;
}
// Calculate the NEXT index (for linking).
if ( ( nextQuoteIndex = ( quoteIndex + 1 ) ) > quoteCount ) {
nextQuoteIndex = 1;
}
</cfscript>
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body x-data>
<cfoutput>
<h1>
Quotes (Rendered: #now().timeFormat( "hh:mm:ss" )#)
</h1>
<!---
The "x-ajax-fragment" directive hijacks the click events contained within it
and loads the content using AJAX in order to hot-swap the innerHTML with the
matching ID attribute.
--->
<article x-ajax-fragment id="hot-swap">
<!---
NOTE: I'm logging the init/destroy life-cycle events of the inner content,
which will be getting replaced as part of the hot-swapping operation.
--->
<blockquote x-data="lifecycle">
<p>
#encodeForHtml( quote.text )#
</p>
<p>
— #encodeForHtml( quote.author )#
</p>
</blockquote>
<nav>
<a href="./index.cfm?quoteIndex=#encodeForUrl( prevQuoteIndex )#">
Prev Quote
</a>
—
<a href="./index.cfm?quoteIndex=#encodeForUrl( nextQuoteIndex )#">
Next Quote
</a>
</nav>
</article>
</cfoutput>
<p>
<!---
An external URL to see what happens when we destroy the current page context
and then have to deal with the "popstate" event if the user starts hitting the
back button.
--->
<a href="https://google.com">Google</a>
</p>
<script type="text/javascript" src="./alpine.ajax-fragment.js" defer></script>
<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
<script type="text/javascript">
document.addEventListener(
"alpine:init",
function setupAlpineBindings() {
Alpine.data( "lifecycle", LifecycleController );
Alpine.directive( "ajax-fragment", AjaxFragmentDirective );
}
);
/**
* I'm just here to log the mounting and unmounting of the dynamic HTML to see that
* it is actually working.
*/
function LifecycleController() {
return {
init: console.info.bind( this, "Life-cycle: init" ),
destroy: console.info.bind( this, "Life-cycle: destroy" )
};
}
</script>
</body>
</html>
In this code, notice that the Prev and Next links are located inside an <article>
element that contains the x-ajax-fragment
directive:
<article x-ajax-fragment id="hot-swap">
<!--- ... truncated ... --->
<a href=""> Prev Quote </a>
<a href=""> Next Quote </a>
</article>
Normally, these Prev / Next links would trigger a full page reload. However, the x-ajax-fragment
directive is going to bind a click
handler to the host element. This click
handler will intercept the click events, prevent the default browser behavior (navigation), and update the page content programmatically. It does this by fetch()
ing the remote page and swapping out the portion of the tree with the matching id
attribute (hot-swap
).
The code for this is fairly imperative and procedural. Let's step through it bit by bit using truncated code snippets. The entirety of the code will be presented at the end.
First, the Alpine.js directive constructor. This is where we setup our initialization and teardown logic for the event bindings:
/**
* I allow part of page to be managed by AJAX requests instead of pull page loads.
*/
function AjaxFragmentDirective( element, metadata, framework ) {
var xHandler = "x-ajax-fragment";
var xID = element.id;
// Internally, we're going to keep a cache of URL=>Content. This way, if we've already
// requested a given page, we can render it directly from the cache instead of loading
// it again.
var cache = Object.create( null );
// Only one pending AJAX request will be allowed at a time. If one request is
// initiated while another request is still pending, the pending one will be aborted.
var abortController = null;
framework.cleanup( handleDestroy );
element.addEventListener( "click", handleClick );
window.addEventListener( "popstate", handlePopstate );
// ---
// PRIVATE METHODS.
// ---
/**
* I clean up directive bindings when the host is unmounted.
*/
function handleDestroy() {
element.removeEventListener( "click", handleClick );
window.removeEventListener( "popstate", handlePopstate );
}
}
In Alpine.js, the directive teardown logic is enabled through the cleanup()
callback. This callback is called when the host element is removed from the active DOM tree. In my example, I'm using the handleDestroy()
function to remove the event-handlers that are being bound in the directive constructor.
Aside: I call the
AjaxFragmentDirective()
function a "constructor". But, I mean this in a loose abstract sense. I don't believe that Alpine.js is technically invoking this method using thenew
operator, which is how constructors are truly consumed.
This Alpine.js directive binds two event listeners: one for the click
event in order to intercept anchor link navigations; and, the popstate
event in order to apply Back Button navigation operations. When we start mutating the DOM programmatically, we have to start "unmutating" it programmatically as well.
In this constructor logic, the cache
will be used to cache page response content so that we don't fetch it more than once. And, the abortController
will allow us to cancel pending fetch()
operations.
Next, let's look at the handleClick()
event listener. This is the function that overrides the normal anchor link navigation:
/**
* I handle a click event from within the host.
*/
function handleClick( event ) {
if ( isEventModified( event ) ) {
return;
}
var anchor = getAnchorFromEvent( event );
if ( ! anchor || isAnchorExternal( anchor ) ) {
return;
}
event.preventDefault();
// Update the URL to reflect the anchor HREF; but, don't navigate the page.
window.history.pushState(
{
xHandler: xHandler,
xID: xID,
href: anchor.href
},
null,
anchor.href
);
// Implement the "navigation" content swap using AJAX.
processHref( anchor.href );
}
Our click handler examines the target element and determines whether or not it represents an anchor link to be intercepted. If so, it calls .preventDefault()
to halt the normal browser navigation; and proceeds to implement the navigation behavior internally.
It calls .pushState()
to update the URL and the browser history. And, it provides some data (xHandler
and xID
) that works to associate the history entry with the directive instance. Remember, any aspect of the page might call .pushState()
. As such, when the user hits the Back/Forward browser buttons and triggers a popstate
event, we need to be able to determine if the associated history state relates to the current x-ajax-fragment
instance.
Once the anchor link behavior has been prevented and the URL has been updated, the click handler calls processHref()
. This async
method loads the remote page content and renders it to the page:
/**
* I processing the loading and rendering of the given href request.
*/
async function processHref( href ) {
try {
renderHrefContent( await resolveHref( href ) );
} catch ( error ) {
if ( isErrorFetchAbort( error ) ) {
console.warn( "Href resolution aborted." );
return;
}
// If something went wrong with the resolution and / or rendering of the HREF
// content, then force the page to reload. Since the current location should
// hold the same HREF (due to .pushState()), reloading should be roughly the
// same as the fetch-based request.
console.error( error );
window.location.reload( true /* Force reload in Firefox. */ );
}
}
This method either successfully applies the given href
to the DOM; or, it errors. In this case, if an error occurs, I'm asking the browser to reload. Remember that the targeted anchor href
has already been applied to the URL (via .pushState()
). As such, when we ask the browser to reload, we're asking it to make a full page request to get the same URL. And, if that request fails, the browser will naturally render the failure for the user.
In the happy path, processing the given href
is a two part process: resolving the href
and rendering the resultant content to the page. Resolving the href
means turning the given URL into an HTML string. This might be done through a fetch()
request; or, if the given URL has been visited before, it might be done by pulling the HTML string out of the cache
object.
Here's the resolveHref()
. It's a fairly naive consumption of the fetch()
API. By "naive", I mean that I haven't really given much of any consideration to failure cases. Aside from the use of the AbortController
(and the subsequent AbortSignal
), I'm assuming that everything just works out.
/**
* I resolve the given HREF, returning the remote page content string. If the HREF has
* been cached, the cached string is returned and no remote request is initiated.
*/
async function resolveHref( href ) {
if ( abortController ) {
abortController.abort();
abortController = null;
}
if ( cache[ href ] ) {
traceCacheHit( href );
return cache[ href ];
}
traceCacheMiss( href );
try {
abortController = new AbortController();
var fetchResponse = await fetch(
href,
{
signal: abortController.signal
}
);
// TODO: I am not sure how to best manage error handling around a fetch()
// request that might return a non-2xx status code AND result in HTML to be
// rendered. For now, I'm just assuming the best case scenario.
var pageContent = await fetchResponse.text();
// TODO: Should the DOMParser be constructed at this point? This would mean
// the parsing operation only takes place once (even for popstate events).
// But, it may also mean more that memory is used by the cache.
return ( cache[ href ] = pageContent );
} finally {
abortController = null;
}
}
Notice that I'm calling traceCacheHit()
and traceCacheMiss()
. This will log information to the console, which you'll be able to see later on in the GIF (or the video).
Once the resolveHref()
successfully maps the href
onto an HTML string (the outcome of the fetch()
response), the content is passed to the renderHrefContent()
function. This function looks for the matching subtree of the remote content and swaps it into the active DOM:
/**
* I render the fragment portion of the given page content into the host element.
*/
function renderHrefContent( pageContent ) {
var pageDom = new DOMParser()
.parseFromString( pageContent, "text/html" )
;
var target = pageDom.getElementById( element.id );
if ( ! target ) {
throw new Error( "x-ajax-fragment target could not be found." );
}
// Swap the server-rendered content into the client.
element.innerHTML = target.innerHTML;
}
As you can see, this function parses the HTML of the remote page content, queries for the element with the matching id
, and then completely replaces the host content with the corresponding remote content using .innerHTML
.
At this point, we've seen how the remote content is fetched and merged into the page. But, this only happens when the user clicks on a link within the DOM boundary of the directive. If the user navigates using the Back/Forward buttons in the browser, there is no click
event to intercept. Instead, we listen for the popstate
event on the history
object and pull the href
out of the event state
:
/**
* I handle the BACK/FORWARD browser history button.
*/
function handlePopstate( event ) {
if ( ! event.state ) {
return;
}
// Different controllers and directives can be setting up their own history
// entries. We only want to act on the history entries that this directive created
// explicitly for the host element with this ID.
if (
( event.state.xHandler !== xHandler ) ||
( event.state.xID !== xID )
) {
return;
}
processHref( event.state.href );
}
Remember, any arbitrary part of the application may interact with the history
API. As such, we use the xHandler
and xID
to make sure that this (pop)state is relevant to this directive instance. And, if it is, we call processHref()
, just as we did in our click
handler. Now, if the given href
is cached, it will be rendered from the cache
object. And, if it isn't cached (such as after a full page unload), it will make a fetch()
call to get the corresponding content.
If we now run this ColdFusion and Alpine.js application, click through some Prev/Next links, and then hit the back button a few times, we get the following page output:
As you can see, when we're clicking Next, all the requests are cache misses. Which means, the click triggers an AJAX request. Then, when we hit the Back button (or when we click Next again), those URLs have already been cached and the content can be rendered to the fragment immediately.
I do a better job of demoing this in the video at the top.
I think there's a lot more that would have to go into this in order to make it production ready. I am not quite sure how to best address failure cases. But, at least this helps me better understand how to progressively enhance a ColdFusion-rendered page with some Alpine.js magic.
For completeness, here's the full code for my apline.ajax-fragment.js
file:
/**
* I allow part of page to be managed by AJAX requests instead of pull page loads.
*/
function AjaxFragmentDirective( element, metadata, framework ) {
var xHandler = "x-ajax-fragment";
var xID = element.id;
// Internally, we're going to keep a cache of URL=>Content. This way, if we've already
// requested a given page, we can render it directly from the cache instead of loading
// it again.
var cache = Object.create( null );
// Only one pending AJAX request will be allowed at a time. If one request is
// initiated while another request is still pending, the pending one will be aborted.
var abortController = null;
framework.cleanup( handleDestroy );
element.addEventListener( "click", handleClick );
window.addEventListener( "popstate", handlePopstate );
// ---
// PRIVATE METHODS.
// ---
/**
* I get the anchor link that triggered the given event.
*/
function getAnchorFromEvent( event ) {
return event.target.closest( "a[href]" );
}
/**
* I handle a click event from within the host.
*/
function handleClick( event ) {
if ( isEventModified( event ) ) {
return;
}
var anchor = getAnchorFromEvent( event );
if ( ! anchor || isAnchorExternal( anchor ) ) {
return;
}
event.preventDefault();
// Update the URL to reflect the anchor HREF; but, don't navigate the page.
window.history.pushState(
{
xHandler: xHandler,
xID: xID,
href: anchor.href
},
null,
anchor.href
);
// Implement the "navigation" content swap using AJAX.
processHref( anchor.href );
}
/**
* I clean up directive bindings when the host is unmounted.
*/
function handleDestroy() {
element.removeEventListener( "click", handleClick );
window.removeEventListener( "popstate", handlePopstate );
}
/**
* I handle the BACK/FORWARD browser history button.
*/
function handlePopstate( event ) {
if ( ! event.state ) {
return;
}
// Different controllers and directives can be setting up their own history
// entries. We only want to act on the history entries that this directive created
// explicitly for the host element with this ID.
if (
( event.state.xHandler !== xHandler ) ||
( event.state.xID !== xID )
) {
return;
}
processHref( event.state.href );
}
/**
* I determine if the given anchor has a URL with a different origin.
*/
function isAnchorExternal( anchor ) {
return ! isAnchorInternal( anchor );
}
/**
* I determine if the given anchor has a URL with the same origin.
*/
function isAnchorInternal( anchor ) {
var url = new URL( anchor.href );
return ( url.origin === window.location.origin );
}
/**
* I determine if the given error is the result of a fetch() request being aborted by
* our internal abort controller.
*/
function isErrorFetchAbort( error ) {
return (
( error instanceof DOMException ) &&
( error.name === "AbortError" )
);
}
/**
* I determine if the given keyboard event has been modified.
*/
function isEventModified( event ) {
return (
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
);
}
/**
* I processing the loading and rendering of the given href request.
*/
async function processHref( href ) {
try {
renderHrefContent( await resolveHref( href ) );
} catch ( error ) {
if ( isErrorFetchAbort( error ) ) {
console.warn( "Href resolution aborted." );
return;
}
// If something went wrong with the resolution and / or rendering of the HREF
// content, then force the page to reload. Since the current location should
// hold the same HREF (due to .pushState()), reloading should be roughly the
// same as the fetch-based request.
console.error( error );
window.location.reload( true /* Force reload in Firefox. */ );
}
}
/**
* I render the fragment portion of the given page content into the host element.
*/
function renderHrefContent( pageContent ) {
var pageDom = new DOMParser()
.parseFromString( pageContent, "text/html" )
;
var target = pageDom.getElementById( element.id );
if ( ! target ) {
throw new Error( "x-ajax-fragment target could not be found." );
}
// Swap the server-rendered content into the client.
element.innerHTML = target.innerHTML;
}
/**
* I resolve the given HREF, returning the remote page content string. If the HREF has
* been cached, the cached string is returned and no remote request is initiated.
*/
async function resolveHref( href ) {
if ( abortController ) {
abortController.abort();
abortController = null;
}
if ( cache[ href ] ) {
traceCacheHit( href );
return cache[ href ];
}
traceCacheMiss( href );
try {
abortController = new AbortController();
var fetchResponse = await fetch(
href,
{
signal: abortController.signal
}
);
// TODO: I am not sure how to best manage error handling around a fetch()
// request that might return a non-2xx status code AND result in HTML to be
// rendered. For now, I'm just assuming the best case scenario.
var pageContent = await fetchResponse.text();
// TODO: Should the DOMParser be constructed at this point? This would mean
// the parsing operation only takes place once (even for popstate events).
// But, it may also mean more that memory is used by the cache.
return ( cache[ href ] = pageContent );
} finally {
abortController = null;
}
}
/**
* I log a cache HIT for the given HREF resolution.
*/
function traceCacheHit( href ) {
console.info(
"Cache %cHIT%c: %s",
// HIT css.
"display: inline-block ; border-radius: 2px ; padding: 1px 4px ; background-color: green ; color: white ; font-weight: bold ;",
// Reset CSS.
"",
href
);
}
/**
* I log a cache MISS for the given HREF resolution.
*/
function traceCacheMiss( href ) {
console.info(
"Cache %cMISS%c: %s",
// MISS css.
"display: inline-block ; border-radius: 2px ; padding: 1px 4px ; background-color: red ; color: white ; font-weight: bold ;",
// Reset css.
"",
href
);
}
}
Yay for JavaScript! Yay for ColdFusion!
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →