Incrementally Applying Hotwire To An Existing ColdFusion Application
Over the past few months, I've been exploring the Hotwire framework from Basecamp. Hotwire includes the use of Turbo for enhanced performance and Stimulus for dynamic interactions. There is something very enticing about the Hotwire philosophy and the way it drives progressive enhancement. But, dropping Hotwire into a ColdFusion application isn't seamless. File extension limitations and form processing redirects, for example, mandate at least some changes to the way you architect your Controller layer. I'd like to start using Hotwire on my ColdFusion blog; but, I know my code isn't "Hotwire ready". As such, I wanted to look at how I can incrementally apply Hotwire to my existing ColdFusion application.
View this code in my ColdFusion + Hotwire Demos project on GitHub.
As I mentioned above, Hotwire isn't "one thing", it's an umbrella of technologies that work together to try and create SPA (Single-Page Application)-like experiences on top of MPAs (Multi-Page Application). Turbo includes "Turbo Drive", "Turbo Frames", and "Turbo Streams"; and, works to enhance page navigation, partial page updates, and lazy-loaded content. Stimulus is the explicit JavaScript layer that adds dynamic interactivity to a given part of the DOM (Document Object Model).
Incrementally Applying Hotwire Turbo
Turbo - and, more specifically, Turbo Drive - is the feature that we really want to focus on when it comes to upgrading a ColdFusion application. Since Turbo Drive takes over page navigation, it imposes the most constraints; and, is the most likely aspect of Hotwire to provide roadblocks.
Luckily, we can turn Turbo Drive off by default:
// Import core modules.
import * as Turbo from "@hotwired/turbo";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// By default, we don't want Turbo Drive to take over the navigation unless explicitly
// enabled within the ColdFusion markup. This way, we can baby-step our way towards a
// compatible Turbo Drive application.
Turbo.session.drive = false;
By setting .drive
, Turbo won't intercept link clicks and form submissions that target top-level navigation. Essentially, this makes Turbo Drive inert. And, when a part of the brownfield ColdFusion application has been made "Turbo Drive compatible", we can explicitly enable Drive for a given branch of the DOM tree using the [data-turbo="true"]
attribute.
For example, imagine that only my "About" page is ready to be loaded via Turbo Drive. In that case, I can alter the nav item to include data-turbo
:
<!---
As various pages are updated to be Hotwire / Turbo Drive compatible, we
can add the "data-turbo" attribute to their nav links. This way, even if
most of the app is still powered by "old school" full page-refresh
navigation, Turbo Drive can still kick-in where it will be a value-add.
--
Note that only the ABOUT page here is Turbo-enabled.
--->
<nav>
<a href="index.htm">Home</a>
<a href="about.htm" data-turbo="true">About</a>
<a href="contact.htm">Contact</a>
</nav>
At this point, if I navigate to "Home" or "Contact", Hotwire won't do anything - it will allow a full-page navigation to take place. But, if I navigate to "About", Hotwire still step in, intercept the link click, fetch()
the content, and then hot-swap it into the current DOM tree:
As you can see from the network activity, the "Home" and "Contact" pages are document-level navigation events. And, "About" - which has been enhanced with [data-turbo="true"]
- is intercepted by Turbo Drive.
As more parts of your ColdFusion application become Turbo Drive compatible, you can just keep annotating the relevant links. And - hopefully - one day everything is ready and you can remove all of the annotations and remove the Turbo.session.drive=false
setting.
Turbo Frames "Just Work"
So far, we've been looking at Turbo Drive actions that apply to top-level page navigations. Turbo Drive also affects navigation and form submissions located within a Turbo Frame. These frame-based navigations are not disabled using the aforementioned approach. When you wrap a <turbo-frame>
element around a portion of the DOM tree, Hotwire goes to work! You can view the existence of the <turbo-frame>
as the "enabling" of the feature.
For example, imagine that I have a small Quotation rotation widget that works by refreshing the current page with a new quoteIndex
:
<cfscript>
param name="url.quoteIndex" type="numeric" default=0;
quote = application.quoteService.getQuote( url.quoteIndex );
</cfscript>
<cfmodule template="./tags/page.cfm" section="home">
<cfoutput>
<h2>
Welcome to My Site
</h2>
<figure>
<blockquote>
#encodeForHtml( quote.text )#
</blockquote>
<figcaption>
←
<a href="index.htm?quoteIndex=#encodeForUrl( quote.prevID )#">Prev quote</a>
—
<a href="index.htm?quoteIndex=#encodeForUrl( quote.nextID )#">Next quote</a>
→
</figcaption>
</figure>
</cfoutput>
</cfmodule>
If I want to "incrementally apply" Turbo Drive to just that widget, all I have to do is wrap the widget in a Turbo Frame:
<!---
Even with Turbo Drive disabled by default, Turbo Frames still work. As such,
for navigation events that refresh the page, we can very easily wrap those
navigation elements in a Turbo Frame that advances the history.
--->
<turbo-frame id="quote-frame" data-turbo-action="advance">
<figure>
<blockquote>
#encodeForHtml( quote.text )#
</blockquote>
<figcaption>
←
<a href="index.htm?quoteIndex=#encodeForUrl( quote.prevID )#">Prev quote</a>
—
<a href="index.htm?quoteIndex=#encodeForUrl( quote.nextID )#">Next quote</a>
→
</figcaption>
</figure>
</turbo-frame>
Now, when I click on the Prev / Next links, we can see that Turbo Drive is intercepting the navigation events:
As you can see, the Prev / Next links are being executed via the fetch()
API and the content of the Turbo Frame is being dynamically updated by Turbo Drive.
Asynchronously loaded Turbo Frames also "just work". If I want to dynamically transclude some content from another ColdFusion template, all I have to do is create a <turbo-frame>
with a [src]
attribute:
<!---
Even when Turbo Drive is disable by default, lazy-loaded Turbo Frames still
work. Which means, we can VERY EASILY lazy-load content into the current page.
--->
<turbo-frame id="lazy-frame" src="lazy.htm" loading="lazy">
<!--- Placeholder content (or if script hasn't loaded). --->
<a href="lazy.htm">Go to lazy content</a> →
</turbo-frame>
When asynchronously loading a Turbo Frame, the static content of the frame will be rendered while the remote content is being fetched. This content can also act as a "graceful degradation". So, if our JavaScript bundle fails to load, at the very least, the user will be presented with a link to manually visit the content that was going to be loaded asynchronously.
.cfm
File Extensions
Turbo Drive Won't Intercept I just want to reiterate that in the current build of Hotwire, only "static" file extensions get intercepted. As such, if you point to a .cfm
page, Hotwire won't intercept the navigation event or the form submission. As such, you have to use URL rewriting to point to .htm
URLs and then map those to ColdFusion behind the scenes.
In the future, Hotwire may allow other file extensions to be allow-listed. But for now, this is an open issue for the framework
Incrementally Applying Hotwire Stimulus
Hotwire Stimulus is the "JavaScript sprinkles" that adds interactivity to your static DOM content. Stimulus works by instantiating Controllers (ie, JavaScript class instances) and then binds them to host elements on the page. As the content of the page changes (ideally via Hotwire Turbo), Stimulus takes care of the controller life-cycle events such as connecting and then disconnecting the controller to and from the DOM, respectively.
On its own, the Stimulus library doesn't do anything. It only snaps into action when a [data-controller]
attribute is detected on the DOM. As such, it should be easier to incrementally apply Stimulus (when compared to incrementally applying Turbo Drive).
The complexity of incremental adoption really starts to show up when you combine Hotwire Turbo Drive and Stimulus. Because Turbo Drive takes transient pages and creates a long running process, the overall life-cycle of the page changes. You can no longer depend on the page unloading to clean-up your JavaScript. Nor can you (necessarily) depend on page loading to initialize your JavaScript.
To be honest, the best path forward here is still a bit fuzzy for me. The approach that I think I'll use is to create an "App Controller", attach it to the <body>
tag, and then use its connect()
life-cycle hook to initialize the rest of my "old school" JavaScript bindings.
<body data-controller="app">
This Stimulus controller should be instantiated and bound to the DOM regardless of how my ColdFusion page loads, whether it be as a full-page load or a Turbo Drive enhanced page load. Then, in my JavaScript bundle, I can use this class to wire-up the rest of the not-yet-updated JavaScript.
To explore this, I've included a "counter" widget in my ColdFusion page:
<!---
This represents a "pre-Stimulus" widget. It will be bootstrapped by a page-
level Stimulus controller until it can be migrated to use Stimulus on its own.
--->
<p class="old-school">
<button>
Clicked <span>0</span> Times
</button>
</p>
When you click the <button>
, all this does is increment the value in the <span>
. I'm going to wire this up in my root Stimulus controller:
// Import core modules.
import { Application } from "@hotwired/stimulus";
import { Controller } from "@hotwired/stimulus";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
class AppController extends Controller {
/**
* I get called whenever the controller instance is bound to a host element.
*/
connect() {
console.log( "App connected!" );
// Wire-up all the features that have not yet been ported to their own Stimulus
// controllers. Keep in mind that these methods will be called on every page load.
setupOldSchoolThing();
}
}
window.Stimulus = Application.start();
// When not using the Ruby On Rails asset pipeline / build system, Stimulus doesn't know
// how to map controller classes to data-controller attributes. As such, we have to
// explicitly register the Controllers on Stimulus startup.
Stimulus.register( "app", AppController );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
function setupOldSchoolThing() {
// This setup method will be called on every page load. As such, it will only be
// applicable some of the time. If this page doesn't contain an old-school widget,
// exit out.
if ( ! document.querySelector( ".old-school" ) ) {
console.warn( "No old-school widget detected." );
return;
}
var dom = Object.create( null );
dom.container = document.querySelector( ".old-school" );
dom.button = dom.container.querySelector( "button" );
dom.counter = dom.button.querySelector( "span" );
// Parse initial counter from the DOM state.
var clickCount = +dom.counter.textContent;
dom.button.addEventListener(
"click",
function handleClick() {
dom.counter.textContent = ++clickCount;
}
);
}
As you can see, my AppController
's connect()
method turns around and calls setupOldSchoolThing()
to wire-up non-Stimulus functionality. And, when we load the ColdFusion page, we can see that the AppController
logs the connect()
hook and that our button works:
As you can see, my "old school" JavaScript was successfully wired-up by my intermediary Stimulus controller.
As I continue to upgrade my application, porting more of the interactivity over to Stimulus, I can then remove those initialization calls from the AppController
.
Idempotent Old School JavaScript Initialization
Because the life-cycle of the page starts to change as Hotwire Turbo Drive is introduced, we can no longer assume that JavaScript will only be invoked once. Instead, we have to start making sure that our initialization code is idempotent, which just means that it's safe to call multiple times.
In this case, I have two attempts at making the code more idempotent:
If there is no element on the page that matches the CSS selector,
.old-school
, I just short-circuit out of the setup. This way, as ourAppController
is connected to every page the user visits, we can safely skip initialization of code that doesn't apply to the current page.I'm using the DOM as the source of the of truth for my initial counter value. This way, as pages are pulled out of the browser's history, and Stimulus re-initializes the widgets, our counter can pick-up right where it left off instead of skipping back to zero.
It's hard to talk about idempotentcy from a general standpoint since all widgets are unique and special and will likely have their own constraints. The most important thing you need to do is just make sure that your event-handlers are only bound once.
ASIDE: The binding of event handlers is likely going to be trickiest part to manage. You may need to start tapping in the
disconnect()
life-cycle method of theAppController
as a means to teardown event-handlers. However, the browser will automatically remove event handlers when the associated DOM nodes are destroyed. So, you might get lucky in a lot of cases.
I'll Report Back
Hopefully, this plan is fruitful. I'd like to start converting my ColdFusion blog over to using Hotwire in the next few weeks. Of course, the conversion is more than just incrementally applying the various Hotwire technologies, it will also require re-thinking some of the Controller layer architecture. I'll report back as I (almost certainly) run into hurdles.
UPDATE: 2023-03-17 - Adding the Libraries
This morning, I took the first steps towards integrating Hotwire into this ColdFusion blog. All I did was install the @hotwired/turbo
and @hotwired/stimulus
modules and disable Turbo Drive (as I outlined earlier in this post). My uncompressed JavaScript bundle shot up from ~ 20KB to ~ 200KB (an order-of-magnitude increase). But, it is being served up through the Cloudflare CDN (Content Delivery Network); and, the applied GZip compression reduces the transferred bundle size to 49Kb.
This is more JavaScript than I'd like to have for a "blog"; but, I have to remember that the JavaScript loading is all deferred and non-blocking. And, when I look at my Chrome Lighthouse score after the change, so far I have not been penalized by the larger bundle:
I know that the Lighthouse score is somewhat superficial. But, at least it gives me a baseline against which I can measure some incremental changes over time.
UPDATE: 2023-03-20 - Handling Inversion of Control (IoC)
In my hand-crafted JavaScript code, I was manually wiring all of my functionality together in a main.js
file. This gave me the ability to instantiate shared instances and then provide them to other blocks of code. This is known as Inversion of Control (IoC), and is a staple of clean coding practices.
ASIDE: Dependency Injection (DI) is a form of Inversion of Control. The two terms are often used somewhat interchangeably.
In Hotwire, since Stimulus.js creates an abstraction for instantiating my controllers and then binding them to the DOM, I don't have an opportunity to pass anything into the controller constructors. As such, I don't really have any kind of injection or inversion hooks.
To get around this, I've created a file - injector.js
- that acts as a point of indirection. This file manages the construction of my shared classes. In my case, it's just a single class, ApiClient
:
// Import application modules.
import { ApiClient } from "./api-client.js";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export var apiClient = new ApiClient();
My Stimulus controllers can then import
the instantiated and cached apiClient
instance:
// Import vendor modules.
import { Controller } from "@hotwired/stimulus";
// Import application modules.
import { apiClient } from "./injector.js";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export class CommentFormController extends Controller {
// ... truncated ...
apiClient.makeRequest();
// ... truncated ...
}
The goal here is to decouple the Stimulus controller from the construction of the shared classes (ApiClient
in this case). It still depends on the ApiClient
, but it doesn't have to worry about where the class comes from or how it was wired together.
And - just as important - the api-client.js
file, which contains the ApiClient
class definition, does not instantiate its own instance either! One of the great sins from the Node.js world is having modules instantiate themselves. This should never never never be done. That's known as the Singleton pattern and must be avoided.
UPDATE: 2023-03-20 - Turbo Drive Enabled (For Non-Forms)
I just enabled Turbo Drive for all non-Form, internal links (except for ones that are included in persisted blog content, ex posts, comments). Since my forms aren't setup to render properly yet, I'll have to address them next. For the time being, all forms have data-turbo="false"
on them.
Also, the preview feature of Turbo Drive doesn't play very nicely with my site-photos header. As such, I've added a no-preview
meta tag to my various layouts:
<meta name="turbo-cache-control" content="no-preview" />
This will allow the Back/Forward history navigation to use caching, but not clicking on links.
UPDATE: 2023-03-21 - Conflicting CSS Classes
When my blog is operating in a truly "Multi-Page Application" (MPA) mode, each page navigation performs a full page refresh. Which means, each page starts with a clean slate when it comes to CSS. Because of this, I've traditionally used some generic class names when it comes to layout. For example, each type of layout might have a .l-site-layout
class. And, each layout loads its own CSS file which has its own version of what .l-site-layout
means.
When Hotwire Turbo Drive takes over the page navigation, it does two things (from the documentation):
During rendering, Turbo Drive replaces the current
<body>
element outright and merges the contents of the<head>
element. The JavaScript window and document objects, and the<html>
element, persist from one rendering to the next.
If an application navigation takes me from one layout to another layout, the CSS file from one layout ends up colliding with the CSS file from the previous layout (since the head-tag contents are merged and I end up with both CSS files loaded at the same time). This leads to unexpected page rendering results.
What I think I need to do is create a single, unified CSS file that can handle every type of layout (much like I've done with the JavaScript file). And, if CSS class names aren't globally unique, I have to make them unique for the purposes of Hotwire.
UPDATE: 2023-03-22 - Adding a Root Layout
While this blog centers around a List + Detail view for article content, I actually have several different layouts for different types of pages. For example, the error page has its own layout and the photos page has its own layout. And, what I've had to do in the time being is add the relevant Hotwire script tag to each layout.
Piggy-backing on my update from yesterday (on a unified CSS model), I think I need to do the same for layouts. While I still want to have different layouts for each type of page, I want each of those layouts to "roll-up" into a "root layout". Essentially, I want to create a base template where I have a single place to include the Hotwire script (as well as things like custom Fonts and the aforementioned unified CSS file).
This is a little trickier than it sounds, strictly from an organizational standpoint - not a technical one. Technically, wrapping content in other content is straightforward; but, it's figuring out which template is responsible for which data and where data gets parameterized - that's the harder part. I'm still working on it.
UPDATE: 2024-03-24 - Bypassing Turbo Drive For Old Demos
I realized that my articles have loads of embedded links that point to very old demos. Since Turbo Drive is enabled site-wide (less some forms, at the moment), it will happily intercept and manage the navigation to these old demos. This is a problem. These demos are not Hotwire compatible; and, by allowing Turbo Drive to load them, I will inevitable corrupt the long-running process that Hotwire puts in place.
To see this in action, take a look at my post: Disabling Turbo Drive In A Subdirectory.
To prevent Turbo Drive from managing these links, I've added a global turbo:click
event handler that gets called right before Turbo Drive kicks into action on a given link. If the links point to a /resources/
subdirectory (which is where my demos reside), I'm going to tell Turbo Drive to let the link fall through to the browser as a native feature:
document.documentElement.addEventListener(
"turbo:click",
function handleClick( event ) {
// If the user is clicking through to an old demo, don't let Turbo Drive manage
// the application visit - the demo will NOT BE Hotwire-compatible. Instead, let's
// prevent the default Turbo Drive behavior, allowing the event to fall through to
// the browser as a normal link navigation.
if ( event.detail.url.includes( "/resources/" ) ) {
event.preventDefault();
}
}
);
This way, I don't have to go an progammatically add [data-turbo="false"]
to all of these links.
UPDATE: 2023-03-26 - Losing URL Fragments on Redirect
Hotwire Turbo Drive uses the fetch()
API to implement navigation events. And, at the moment, if you redirect to a URL that contains a hash / fragment, Turbo Drive will strip-out the fragment. The team is working on pull-request to fix this; but, from what I've read on a few different posts, there are some technical limitations that make handling fragments harder when using fetch()
.
To help bridge this gap, I'm adding a global event-handler that will listen for the turbo:load
event, inspect the URL for a scrollTo
parameter, and then scroll down to that parameter programmatically:
// Scroll down to the desired element (via ID selector).
document.documentElement.addEventListener(
"turbo:load",
function handleLoad( event ) {
var searchParams = new URLSearchParams( location.search );
var scrollTo = searchParams.get( "scrollTo" );
if ( ! scrollTo ) {
return;
}
document.querySelector( `#${ scrollTo }` )
?.scrollIntoView()
;
}
);
This selects the given element using an ID-based CSS selector; and, if it can be found, scrolls-down to it. It's not a perfect solution; and, it will likely end up scrolling more often than I want it to (such as when the user hits the back-button). But, for the moment, it gives me a path forward.
UPDATE: 2023-04-16 - Nested Turbo Frames Causing Issues
I currently have the comment form as a linked form; meaning, it's no longer inline with the blog detail. And now, I'm working to transclude the comment form back into the blog detail using a <turbo-frame>
. This works pretty well locally until I try to render a preview of the comment inside a nested <turbo-frame>
element. It seems that Turbo 7.3.0 gets confused about which Turbo frame I'm targeting if the response has a non-200 status code.
I've opened an issue (Turbo Issue #907) that outlines this problem. I believe this to be a bug since the code works if you only have an inner frame.
UPDATE: 2023-04-23 - Adding a Babel Plugin
Hotwire claims to only support ever-green browsers. So, I'm definitely expecting to run into some errors for older browsers. However, I've been seeing an issue that feels easy enough to fix with some transpilation. Both Hotwire and my custom code make use of the safe navigation operator (also called the the optional chaining operator):
object?.a?.b?.c
With little effort, this code can be compiled down to something that is ES5 compatible. And, to that end, I created a Babel configuration file to include the optional chaining compiler in my Parcel bundler:
{
"plugins": [
"@babel/plugin-proposal-optional-chaining"
]
}
One thing to be cautious about when adding plugins is that they can lead to larger bundle sizes. However, if I diff the versions with-and-without the optional chaining transpilation, the difference is just a few characters. So, an effort with a great return on investment.
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 →