Skip to main content
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Alec Irwin
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Alec Irwin

Using Stimulus To Preload Links On Hover In Hotwire And Lucee CFML

By on

From what I've been reading, when building a Stimulus controller in Hotwire, the key to success is thinking in terms of small, composable behaviors. So, instead of creating a controller that manages an entire View, we should seek to extract aspects of said view that might make sense in a wider variety of use-cases. To practice this mindset, I wanted to try building a Stimulus controller that will preload a given link target if the user hovers over an element for some period of time. And, of course, we'll be doing this in Lucee CFML.

CAUTION: This particular exploration uses an undocumented property of the Turbo Drive session, Turbo.session.preloader. At least, I think it's undocumented - the API docs on the core objects are essentially non-existent. So, I just poked around in the GitHub repository and found this.

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

Turbo Drive already comes with a mechanism for preloading links - if you add the data-turbo-preload attribute to an anchor tag, Turbo Drive will look for said anchors on page load and then immediately fetch the associated HREFs / URLs. This is super easy; but, it can also lead to a lot of unnecessary pre-fetching.

To tie more intent to the preload operation, I wanted to see if I could trigger it with a timed hover event. This way, a user would have to mouse into an element, and then leave their mouse over said element for some period of time before the preload would occur. The hope being that the "hover" action telegraphs the user's intent to eventually click on said link.

Since Hotwire drives the source of truth into the DOM (Document Object Model), we want the Stimulus controller to be configured via DOM attributes. This will include the identification of the host element / hover container, the hover duration threshold, and the anchor links that should get preloaded. Here's the DOM API that I've come up with:

<div
	data-controller="hover-preload"
	data-hover-preload-delay-value="500">

	<a href="my-link.htm" data-hover-preload-target="link">
		My link....
	</a>
</li>

This ColdFusion view contains the following attributes:

  • data-controller - this is how Stimulus maps DOM elements to controller classes. In this case, we're using hover-preload as the identifier.

  • data-hover-preload-delay-value - this tells the Stimulus controller how long (in milliseconds) the user should hover-over the host element before the preload operation is executed.

  • data-hover-preload-target="link" - this identifies the anchor tags within the host element that should be preloaded (once the hover threshold has been met). Each view can contain a single link; or, multiple links.

This is fairly generic; there's nothing here that tells you what kind of view this is - it's just a small, potentially reusable behavior. This is the Stimulus way.

To try this out, I put together a small ColdFusion page that lists out (and links to) products. As the user hovers over a given product, I want the product detail page to be preloaded into the Turbo Drive page cache:

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

		<h2>
			Welcome to Our Site!
		</h2>

		<p>
			Copy, copy, copy....
		</p>

		<h3>
			Check Out Our Products
		</h3>

		<ul class="products">
			<cfloop index="productID" from="101" to="110">
				<li
					data-controller="hover-preload"
					data-hover-preload-delay-value="500"
					class="products__item">

					<a
						href="product.htm?id=#productID#"
						data-hover-preload-target="link"
						class="products__link">
						Product #ucase( productID )#
					</a>
				</li>
			</cfloop>
		</ul>

	</cfoutput>
</cfmodule>

As you can see, each product is receiving its own instance of the hover-preload controller. And, if the user hovers for more than 500ms, we want the product link to be preloaded.

To help illustrate the benefits of preloading, I've included a small sleep() command in my product detail page:

<cfscript>

	param name="url.id" type="string";

	// Adding a sleep to exaggerate the benefits of preloading the content.
	sleep( 500 );

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

		<h2>
			Product #encodeForHtml( url.id )#
		</h2>

		<p>
			Copy, copy, copy....
		</p>

		<script type="text/javascript">

			// Logging the render time so that we can see CACHED vs LIVE rendering.
			console.log(
				"Product #encodeForJavaScript( url.id )# rendered at",
				new Date().toLocaleTimeString()
			);

		</script>

	</cfoutput>
</cfmodule>

Notice that I'm also logging the render time of the page. If Turbo Drive pulls this page out of the page cache, we should see two console logs: one for the cached rendering and then one for the live rendering. And, if we open up this ColdFusion application, hover over some links, and the click through to one that has been preloaded, we should see those two logs:

As the user hovers over product links, we can see in the network activity that the links are being preloaded into the Hotwire Turbo Drive page cache.

As you can see from the Chrome Dev Tools network activity, as we slowly hover over the product links, they are individually preloaded into the Turbo Drive page cache. Which is why, when we click into product 103, we see this in the logs:

Product 103 rendered at 7:44:00 AM
Product 103 rendered at 7:44:01 AM

The first line is the instantaneous rendering of the cached page. Then, a second (or so) later, another rendering of the live page (delayed due to the sleep() command). Very exciting!

Let's take a look at the Stimulus controller that is powering this preloading behavior. The concept is fairly straightforward - a timer is started on mouseenter and canceled on mouseleave. If the timer has a chance to invoke its callback, we trigger a preload of the embedded link targets:

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

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

class HoverPreloadController extends Controller {

	// Every link target contained within the controller's scope will be preloaded when
	// the hover timer has completed.
	static targets = [ "link" ];
	// The hover threshold (ie, duration the user has to hover before the link preloading
	// kicks-in) can be configured.
	static values = {
		delay: {
			type: Number,
			default: 300
		}
	};

	// ---
	// 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() {

		this.hoverTimer = 0;

	}


	/**
	* 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() {

		if ( this.hasLinkTarget ) {

			this.setupEvents();

		}

	}


	/**
	* 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() {

		this.teardownEvents();

	}

	// ---
	// PRIVATE METHODS.
	// ---

	/**
	* I handle the mouseenter event on the host element.
	*/
	handleMouseenter = ( event ) => {

		this.hoverTimer = window.setTimeout( this.handleTimeout, this.delayValue );

	};


	/**
	* I handle the mouseleave event on the host element.
	*/
	handleMouseleave = ( event ) => {

		window.clearTimeout( this.hoverTimer );
		this.hoverTimer = 0;

	};


	/**
	* I handle the timeout event triggered from the hover interactions.
	*/
	handleTimeout = () => {

		this.teardownEvents();
		this.preloadLinks();

	};


	/**
	* I ask the Turbo session to preload all of the link targets embedded within this
	* controller.
	*/
	preloadLinks() {

		for ( var target of this.linkTargets ) {

			console.log( "Preloading links for:", target.href.split( "/" ).at( -1 ) );
			// CAUTION: The "preloader" is an UNDOCUMENTED PROPERTY of the session (at
			// least as far as I can tell) - I only found it by looking at the source code
			// for the Turbo project. As such, use this technique at your own risk!
			Turbo.session.preloader.preloadURL( target );

		}

	}


	/**
	* I setup the hover events on the host element.
	*/
	setupEvents() {

		this.element.addEventListener( "mouseenter", this.handleMouseenter );
		this.element.addEventListener( "mouseleave", this.handleMouseleave );

	}


	/**
	* I teardown the hover events on the host element. Any pending timer will be cleared.
	*/
	teardownEvents() {

		window.clearTimeout( this.hoverTimer );
		this.hoverTimer = 0;

		this.element.removeEventListener( "mouseenter", this.handleMouseenter );
		this.element.removeEventListener( "mouseleave", this.handleMouseleave );

	}

}

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

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( "hover-preload", HoverPreloadController );

As is part of the Hotwire philosophy, this Stimulus controller seeks to make no assumptions about the HTML in which it is being consumed. All the configuration is done in the HTML and made available to the Stimulus controller via Value and Target attributes.

As an Angular developer of many years, my mindset has been geared towards controlling Views. Stimulus' "JavaScript sprinkles" philosophy seeks to pick apart Views and decompose them into as many small behaviors as possible. This new outlook is going to take some getting used to. But, I think it makes a lot of sense when used in conjunction with Turbo Drive's page cache.

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