Defer Loading Using Permanent Turbo Frames In Hotwire And Lucee CFML
In a Hotwire application, you can use Turbo Frames to "decompose your page" into islands of information. These islands can be replaced dynamically and loaded asynchronously from the parent page. This reduces the amount of information that blocks the main page render. But, this async loading can also cause a lot of "content flashing" during navigation (as the Turbo Frame content loads / reloads). In order to defer the initial loading of the Turbo Frame content while removing the subsequent reloading, we can mark the Turbo Frame as permanent. This will persist the Turbo Frame DOM (Document Object Model) element across page requests. Let's explore this concept in Lucee CFML.
View this code in my ColdFusion + Hotwire Demos project on GitHub.
Out of the box, Hotwire Turbo provides a super simple way to load content asynchronously. All you have to do is define a <turbo-frame>
block within your page and then assign it a src
attribute. Upon load, Turbo Drive will then make a fetch()
request for the src
URL and swap the frame content with the fetch-response:
<p>
Content loaded in main page...
</p>
<turbo-frame id="async-frame" src="async-content.htm">
<p>
Turbo Frame content will be loaded shortly...
</p>
</turbo-frame>
Here, we've defined a Turbo Frame with ID async-frame
. When this page loads, Turbo Drive will make a request to the relative URL, async-content.htm
. Any static content defined in the initial body of <turbo-frame>
will soon be replaced by the response for the src
request.
ASIDE: In this example, the Turbo Frame is being loaded eagerly. Meaning, the frame content will be fetched right after the parent page loads. You can add
loading="lazy"
to the frame element in order to have Hotwire defer loading of the frame content until the Turbo Frame is scrolled into view (presumably using theIntersectionObservr
API).
This is awesome! However, this asynchronous loading and subsequent swapping-out of the static content happens every time you visit the given page. If this frame is "above the fold" (in the browser), it can become a visual distraction, pulling the user's attention as the DOM content flickers and changes.
To remove the distraction, we can add the attribute data-turbo-permanent
. This Boolean attribute is not specific to Turbo Frames - it can be applied to any DOM element. It tells Turbo Drive to create a special cache for the specific DOM element; and then, to inject the cached element back into the page whenever the corresponding id
shows up. And, since the src
attribute of the cached Turbo Frame isn't changing, simply adding the element back into the DOM doesn't trigger a new fetch
.
To see this in action, I've created a simple ColdFusion application that loads a main page with an embedded permanent Turbo Frame:
<cfmodule template="./tags/page.cfm" section="home">
<cfoutput>
<h2>
Welcome to Our Site!
</h2>
<p>
Copy, copy, copy....
</p>
<h2>
Framed Content
</h2>
<turbo-frame
id="home-frame"
src="frame.htm"
data-turbo-permanent
data-controller="perm-frame">
<p>
Frame content is eagerly-loading....
</p>
</turbo-frame>
</cfoutput>
</cfmodule>
Here, we have a permanent Turbo Frame that will asynchronously fetch frame.htm
from the ColdFusion server. I've also attached a Controller, perm-frame
, to the frame so that we can log the life-cycle events as well as programmatically reload the frame content.
Here's the ColdFusion template for our Turbo Frame - note that I'm including a sleep()
command in order to exaggerate the visual artifacts of rendering and then subsequently swapping out the static content in the frame body:
<cfscript>
// Adding latency so that we can see the place-holder text and tell more clearly that
// the frame content is being loaded asynchronously.
sleep( 1000 );
</cfscript>
<turbo-frame id="home-frame">
<cfoutput>
<p>
Frame loaded at #timeFormat( now(), "HH:mm:ss" )#.
</p>
<p>
<button data-action="perm-frame##reload">
Reload Frame
</button>
</p>
</cfoutput>
</turbo-frame>
With our main page and our frame template in place, let's load up our Hotwire and ColdFusion application:
As you can see, upon page load, we see the static content of the <turbo-frame>
element. However, behind the scenes, Turbo Drive is making a fetch()
request to grab the frame content; and, a second later, the new content is merged into the current DOM.
Now, that's on the initial load of the application. If we then navigate to another page (within the application) and then navigate back to the main page, we get a different behavior:
Once the Turbo Frame is loaded, if we navigate away from and then back to the main page, the content of the Turbo Frame is immediately available! In fact, there is no subsequent fetch()
to the server to get the data - Hotwire just uses the cached state / rendering of the DOM element.
At this point, we're getting the performance benefits of asynchronously loading secondary data on our page and we've removed the poor user experience (UX) of constantly seeing that "flash of unloaded content" every time we return to the main page.
But, of course, we probably want to reload the Turbo Frame data at some point - we don't necessarily want it to be cached forever. As I mentioned above, I'm attaching a Stimulus Controller to this Turbo rame. This controller both logs the life-cycle events and provides a method - reload()
- which we can call from our DOM. The controller method is just turning around and calling the native .reload()
method on the Turbo Frame itself:
// Import core modules.
import { Application } from "@hotwired/stimulus";
import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
class PermFrameController extends Controller {
// ---
// LIFE-CYCLE METHODS.
// ---
/**
* I run once when the controller has been instantiated, but before it has been
* connected to the host DOM element. A single instantiated controller may be connected
* to the host element multiples times during the life-time of a page.
*/
initialize() {
console.log( "Controller initialized." );
}
/**
* I run once after the component instance has been bound to the host DOM element. At
* this point, all of the classes, targets, and values have already been bound.
*/
connect() {
console.info( "Controller connected." );
}
/**
* I get called once after the component instance has been unbound from the host DOM
* element. At this point, all of the targets have already been disconnected as well.
*/
disconnect() {
console.info( "Controller disconnected." );
}
// ---
// PUBLIC METHODS.
// ---
/**
* I reload the host element's frame content, getting Turbo Drive to re-fetch it from
* the ColdFusion server.
*/
reload() {
console.log( "Triggering frame reload." );
this.element.reload();
// Let's toggle a CSS class on the host element so that we can see how the runtime
// DOM change are persisted across pages with the permanent frame.
this.element.classList.toggle( "snazzy" );
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
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( "perm-frame", PermFrameController );
In addition to reloading the Turbo Frame content, this reload()
controller method also toggles the CSS class .snazzy
. I added this to the exploration to help showcase the fact that the <turbo-frame>
DOM element is being cached across page requests, along with any classes that have been applied to it.
If we now load this ColdFusion application and manually refresh the frame, we can see that timestamp in the frame content changes; and, the snazzy CSS class persists across navigation events:
As you can see from the embedded timestamp, clicking the reload button caused the contents of the Turbo Frame to be re-fetched from the ColdFusion server. And, it also added the .snazzy
CSS class to be added to the DOM. This CSS class is then persisted across page requests right along with the Turbo Frame content.
With our Stimulus Controller in place, we can also see which life-cycle events get triggered on the Turbo Frame. Of particular interest is the navigation of:
About → Home
When we navigate back to the home page with the cached permanent Turbo Frame, here's what we get in the Chrome Dev Tools:
Controller initialized.
- Our JavaScript class is instantiated by Stimulus.Controller connected.
- The instantiated controller is attached to the Turbo Frame in the Preview of the page.Controller disconnected.
- The controller is detached from the DOM as the Preview of the page is being replaced by the live content.Controller connected.
- The controller is re-attached to the DOM after the live content has been updated.
There's a couple of very interesting pieces of information that we can deduce from this output:
While DOM elements can be cached across page navigation events, Stimulus Controllers are not. When a page is loaded, new controllers are instantiated and attached to the DOM (and whatever permanent elements is contains).
During the life-cycle of a single page, a Stimulus Controller will be reused (in this case for the Preview and Live renderings).
The DOM is the place to cache state. Since we see that Stimulus Controllers are not cached, even for permanent elements, it becomes clear that the place to cache state is in the DOM. This is in alignment with the overall Hotwire philosophy that seems to use the DOM as the source of truth.
As I dig into Hotwire, I keep getting frustrated when trying to apply my Angular mindset to a fundamentally different philosophical approach. But, once I get past my frustration, I keep uncovering exciting features, like this ability to both asynchronously load and then cache client-side content using Turbo Frames! Baby steps!
Want to use code from this post? Check out the license.
Reader Comments
I thought that
data-turbo-permanent
would be a cool use-case for Toast Messages. But, this begs the question: how do we add new messages to the persisted element. For that, I'm using a Stimulus controller that manages both transient and persistent element targets:www.bennadel.com/blog/4410-updating-permanent-elements-on-page-navigation-in-hotwire-turbo-and-lucee-cfml.htm
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →