Skip to main content
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Irina Feeney
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Irina Feeney

Defer Loading Using Permanent Turbo Frames In Hotwire And Lucee CFML

By
Published in , Comments (1)

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 the IntersectionObservr 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:

When the page loads, the static content of the Turbo Frame is being shown. A second later, the contents of the remote template are swapped into the Turbo Frame body.

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:

When the page is first loaded, the Turbo Frame content loads asynchronously. However, when subsequently navigating back to the main page, the Turbo Frame content is immediately available - no flickering of content.

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:

Clicking the reload button refreshed the Turbo Frame content and added a CSS class. This CSS class is then persisted across page requests along with the permanent Turbo Frame element.

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:

AboutHome

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

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel