Updating Permanent Elements On Page Navigation In Hotwire Turbo And Lucee CFML
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 )#
—
<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:
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 theturbo: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:
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.
turbo:before-cache
Instead of turbo:load
for DOM Manipulation
Using 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 →