Skip to main content
Ben Nadel at the jQuery Conference 2009 (Cambridge, MA) with: Rebecca Murphey
Ben Nadel at the jQuery Conference 2009 (Cambridge, MA) with: Rebecca Murphey ( @rmurphey )

Updating Permanent Elements On Page Navigation In Hotwire Turbo And Lucee CFML

By on

In a Hotwire Turbo application, when you add the data-turbo-permanent attribute to an element (accompanied by an id attribute), this element will be cached and then replaced into subsequent pages that contain an element with the same id. Element permanence is awesome when you want to, for example, lazy-load a Turbo-Frame once and then have it persist across pages. But, it means that updating the content of said element gets tricky. I wanted to explore this idea in the context of "Toast Messages" in Lucee CFML.

View this code in my ColdFusion + Hotwire Demos project on GitHub.

A "Toast Message" is a general User Interface (UI) pattern wherein a confirmation message is rendered to a corner of the user's screen. These messages indicate that some action has completed successfully. Sometimes these toast messages disappear on their own (after a brief period); and, sometimes they persist until the user explicitly removes them.

In this exploration, since we're looking at the data-turbo-permanent attribute behavior, I want those toast messages to persist across page navigations until the user explicitly clears them. And, to give us something to react to, I've created a trivial ColdFusion application in which a user can create and delete notes.

In reaction to creating or deleting a note, I'm going to add a Toast message to the UI. There are many ways in which to implement the mechanics of a "toast" message or "flash" message depending on how your ColdFusion application is architected. But, in order to keep things as simple as possible, I'm going to use two URL-flags on the index page:

  • ?flashAddSuccess=true - Render a toast message that the user's note has been successfully added.

  • ?flashDeleteSuccess=true - Render a toast message that the user's note has been successfully deleted.

The ColdFusion pages that implement the add and delete actions will then adjust the notes collection before redirecting back to the index page with the appropriate URL-flag. For example, here's my Add page:

<cfscript>

	param name="form.note" type="string" default="";
	param name="form.submitted" type="boolean" default=false;

	if ( form.submitted ) {

		if ( form.note.len() ) {

			application.notes.prepend( form.note );

			location(
				url = "index.htm?flashAddSuccess=true",
				addToken = false
			);

		}

		// To keep things simple, if the user tries to add a note, but provides no actual
		// content, I'm just going to take them back to the index page. This way, I don't
		// have to deal with form validation, which is beyond the scope of this demo.
		location( url = "index.htm", addToken = false );

	}

</cfscript>
<cfmodule template="./tags/page.cfm">
	<cfoutput>

		<h2>
			Add Note
		</h2>

		<form method="post" action="add.htm">
			<input type="hidden" name="submitted" value="true" />

			<input type="text" name="note" autofocus size="30" />
			<button type="submit">
				Save
			</button>
			<a href="index.htm">
				Cancel
			</a>
		</form>

	</cfoutput>
</cfmodule>

As you can see, after the application.notes collection is updated, the ColdFusion page redirects to:

index.htm?flashAddSuccess=true

Again, there are many ways in which to wire-up toast / flash messages; but, the goal of this demo is not to look at those mechanics - the goal of this demo is to look at persistent elements in Hotwire Turbo. As such, I'm trying to keep things as simple as possible.

The index page then looks for those URL-flags and uses them to update a request.newToasts array:

<cfscript>

	param name="url.flashAddSuccess" type="boolean" default=false;
	param name="url.flashDeleteSuccess" type="boolean" default=false;

	if ( url.flashAddSuccess ) {

		request.newToasts.append( "Your note has been added! &##x1f4aa;" );

	}

	if ( url.flashDeleteSuccess ) {

		request.newToasts.append( "Your note has been deleted! &##x1f525;" );

	}

</cfscript>
<cfmodule template="./tags/page.cfm">
	<cfoutput>

		<h2>
			Notes
		</h2>

		<p>
			<a href="add.htm">Add note</a>
		</p>

		<cfif application.notes.len()>

			<ul>
				<cfloop index="i" item="note" array="#application.notes#">
					<li>
						#encodeForHtml( note )#
						&mdash;
						<a href="delete.htm?index=#i#">Delete</a>
					</li>
				</cfloop>
			</ul>

		</cfif>

	</cfoutput>
</cfmodule>

The request.newToasts collection is then rendered into a toast message inside our application layout, page.cfm. But, before we look at that ColdFusion code, let's step back and think about how Hotwire Turbo implements caching.

As we saw in the Hotwire back-button demo, Turbo Drive caches the state of the DOM just prior to unload. Which means, we can manipulate the DOM (Document Object Model) at runtime, and our runtime changes will be included in the page / element cache. Therefore, if we have an element that is persisted via the data-turbo-permanent attribute, any changes that we make to said attribute will become permanent by way of location within the DOM tree.

ASIDE: If an element is inside a persisted element but also has data-turbo-cache="false", it will be stripped out of the permanent element before caching. In this way, even permanent elements can contain transient children.

In order to update our permanent element on page navigation, we're going to move a non-persistent portion of the DOM into the persistent portion thereby transcluding it into the page cache (for all intents and purposes). And, to do this, we're going to create a Stimulus controller that manages two "slots": one for the transient data and one for the persistent data:

Illustration of a Toast message component in which an element has two slots.

NOTE: A single Stimulus controller can manage both transient and persistent targets. However, the timing around the persistent target is tricky - it does not appear to be available inside the connect() life-cycle event handler. But, it does appear to be available in the turbo:load event.

A truncated version of the HTML markup for this Stimulus-based component looks like this:

<div data-controller="toaster">

	<div data-toaster-target="new">
		<!--- NEW toasts to be rendered here on each page load. --->
	</div>

	<div
		id="toaster__old"
		data-toaster-target="old"
		data-turbo-permanent>
		<!--- PERSISTED toasts to be rendered here on each page load. --->
	</div>

</div>

As you can see, this toaster controller has a transient newTarget element and a persisted oldTarget element. Any changes that we make to the oldTarget element will be persisted by Turbo Drive across visits (both back button and application).

When our ColdFusion layout is rendered on each page, any items in the request.newToasts collection will be rendered inside the newTarget element. Then just before the page unloads, and the before-cache event is fired, our toaster controller will move the DOM nodes from the newTarget element into the oldTarget element.

Here's the full(er) markup for our Toaster component. In addition to the logic above, I'm also including a "click to remove" feature and some In/Out animation:

<!--- BEGIN: Toaster. --->
<div
	data-controller="toaster"
	data-action="turbo:before-cache@document->toaster##handleBeforeCache"
	data-toaster-animate-in-class="in"
	data-toaster-animate-out-class="out"
	class="toaster">

	<!---
		NEW TOAST: All new toast items in the current request are rendered
		into this container. These toasts will then be moved (on before-cache)
		into the OLD container for cross-navigation persistence.
	--->
	<div data-toaster-target="new" class="toaster__new">
		<cfloop item="newToast" array="#request.newToasts#">

			<div
				data-action="
					click->toaster##removeToast
					animationend->toaster##handleAnimationEnd
				"
				class="toaster__toast in"><!-- Note the "in" class. -->
				#newToast#
			</div>

		</cfloop>
	</div>

	<!---
		OLD TOAST: By marking this element as PERMANENT, it will be cached
		(in its runtime rendered state) across navigation events (both for
		restoration and application visits). This container will therefore
		allow the toasts to persist (until a hard-refresh).
	--->
	<div
		id="toaster__old"
		data-toaster-target="old"
		data-turbo-permanent
		class="toaster__old">
		<!--- To be populated by the Stimulus controller. --->
	</div>
</div>
<!--- END: Toaster. --->

And, here's my Stimulus controller for the toaster:

// Import core modules.
import { Application } from "@hotwired/stimulus";
import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

class ToasterController extends Controller {

	static classes = [ "animateIn", "animateOut" ];
	static targets = [
		"new",
		// CAUTION: Because the "old" target is using "turbo-data-persistent", it will NOT
		// be available into the connect() callback. It will only be available after the
		// page has been fully loaded (I think).
		"old"
	];

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I handle the completed animation on the given target.
	*/
	handleAnimationEnd( event ) {

		var toastNode = event.currentTarget;

		// If the toast is currently exiting stage left, remove it from the DOM.
		if ( toastNode.classList.contains( this.animateOutClass ) ) {

			toastNode.remove();

		}

	}


	/**
	* I handle the moment just before the current page is put into the cache. This event
	* gives us a chance to manipulate the DOM before the cache snapshot is taken. In this
	* case, we're going to move all of the NEW toast elements in to the OLD toast
	* container so that they will be part of the "data-turbo-persistent" element.
	*/
	handleBeforeCache( event ) {

		// We need to loop over the NEW toasts BACKWARDS so that we can prepend them, in
		// order, to the OLD container. If we loop over it forward, the toasts will be
		// reversed in order as they are moved.
		[ ...this.newTarget.children ].reverse().forEach(
			( toastNode ) => {

				// Prevent any ENTER animation from firing. Otherwise, we'll see the
				// animation every time the OLD, persistent container is rendered from the
				// page cache.
				toastNode.classList.remove( this.animateInClass );
				// Move the toast from the NEW container into the OLD container.
				this.oldTarget.prepend( toastNode );

			}
		);

	}


	/**
	* I remove the associated toast from the toaster.
	*/
	removeToast( event ) {

		var toastNode = event.currentTarget;

		// By opting-out of the cache on the given node, we will prevent it from being
		// cached EVEN if it is inside the data-turbo-permanent element.
		toastNode.setAttribute( "data-turbo-cache", "false" );
		// Start animating the element out of view.
		toastNode.classList.add( this.animateOutClass );

	}

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

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( "toaster", ToasterController );

There's no much code here; and, half of it has to do with animating toast messages into and out of existence - the facet that gives this demo some jazz hands! Really, the bulk of the logic can be boiled down to this method:

handleBeforeCache( event ) {

	// !! CAUTION !!: I've rewritten this for the sake of readability. This is
	// not the actual code that I have above.

	for ( var node of this.newTarget.children ) {

		this.oldTarget.prepend( toastNode );

	}

}

Just before the contents of the page are cached, I take all the new toasts and move them into the old toasts, persisted container.

And now, if we load this ColdFusion application and interact with our notes, we can see toast messages show up and get persisted across subsequent page requests:

Toast messages slide in from left side of screen as user adds and removes notes from ColdFusion application.

How cool is that! And, if JavaScript fails to load for some reason, the ColdFusion application still functions properly; only, the old toasts disappear on each page request because they are no longer being persisted by Hotwire Turbo.

I love the idea of using persistent elements to give the ColdFusion application a more SPA (Single Page Application) like user experience (UX). But, persistent elements can't be updated by ColdFusion. Fortunately, Stimulus can give us a way to get the best of both worlds.

Using turbo:before-cache Instead of turbo:load for DOM Manipulation

In this demo, I'm using the turbo:before-cache event as the point in which I move the new toasts into the old toasts persisted container. I could have also used the turbo:load event. The problem with doing it on load, however is that I wanted to the messages to animate into place. And, if I tried do that while the toasts were in the old toasts container, it means that they would have animated on every page navigation as well. So, this was more an animation-oriented decision than a technical one.

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