Oct 19, 2022

Persistent Islands in Astro with Turbo Drive

Over the past couple of months, I’ve been exploring different methods for bringing SPA-like features to Astro websites. I covered one of these methods in my previous post, which I used as a way to enable page transitions using the experimental Shared Element Transition API.

While my previous article focuses mainly on page transitions, it also talks about the underlying client-side router that makes it possible to use the Shared Element Transition API with Astro: an implementation using the Navigation API and HTML fragments. This method works well for simple demos, but comes with a few important tradeoffs:

  • The Navigation API is experimental, and while it’s enabled by default on Chrome, it doesn’t work on any other browser at the moment.
  • Requesting HTML fragments (partial templates) from the server requires you to structure your site in a very specific way. This might be OK for small sites, but can become cumbersome on projects with many pages.
  • The method we used to update the DOM (essentially containerDiv.innerHTML = newHTML) handles HTML and CSS updates correctly, but does not execute any JavaScript. This is of course a big problem for the vast majority of websites that do need JavaScript.

The Astro team is currently looking into bringing this type of functionality out-of-the-box with the framework, and one of the methods they’re exploring is something called Persistent Islands. This feature was proposed fairly recently and it’s in the very early stages of design, but it’s possible to implement something similar in ⛰user-land🏔 with the help of a third-party library like Turbo Drive. This article explores how that works.

Demo Time

I talked about this method with Jason Lengstorf on Learn with Jason a few weeks ago. The starting point for the demo we built was a regular Astro site (using server-side rendering) with two pages: a home page with a grid of music albums, and an album details page. We then installed Turbo on the site, marked a few parts of the page as persistent, and hooked into some lifecycle methods to implement some nice page transitions. Here’s what the end result looks like: Maybe you’re looking at the demo and thinking “wait a minute… an MPA seems like a terrible choice for an Apple Music clone”, to which I say… that’s a good point. If I were building a full-blown music player application, I would probably choose a more traditional SPA framework instead.

Music Player SPA built with Astro.

This article covers most of the things we talked about with Jason on the stream, and also dives a bit deeper into how Turbo works. If you want to jump directly to the code, here are the links that you’re looking for:

Let’s do this!

PJAX and Turbo Drive

No, that’s not a typo. If you haven’t heard of PJAX before, don’t worry—it’s not a shiny new pattern, it’s a term from the early 2010s The earliest implementation of PJAX (and likely the one that coined the term) that I could find is the jquery-pjax library released in 2011 by Github’s co-founder and former CEO Chris Wanstrath. that hasn’t seen much usage in recent years. PJAX (short for pushState + AJAX) is a technique that makes traditional websites (i.e. MPAs) feel more like Single Page Applications by handling page navigations on the client without a full page load.

The most popular open-source implementation of this pattern is a library called Turbolinks, which was developed in 2012 by the Rails team. Turbolinks was renamed to Turbo Drive a couple of years ago, and it’s now part of the Turbo package, which is itself part of the Hotwire technique developed by the Basecamp team. And while it is now much more integrated with the rest of the Hotwire ecosystem, Turbo can still be used as a standalone library.

Getting started with Turbo is as simple as it should be:

import * as Turbo from '@hotwired/turbo'

Turbo.start()

Once Turbo starts running on your site, it will intercept form submissions and clicks on anchor tags and will handle the navigation purely client-side. Instead of letting the browser request the next page with a full page load, it will update the browser’s URL using the History API, and request the contents of the next page with a fetch call.

When it renders the next page, Turbo replaces the current <body> element and merges the contents of the <head> element with the new content. This way, the <html> element as well as the window and document objects are preserved, making the navigation feel much faster and opening the door for exciting new possibilities✨

Turbo Drive is not the only modern library that implements PJAX, but it is one of the most flexible and battle-tested alternatives around. All that power is not for free, though: Turbo ships an extra ~17KB of compressed JavaScript to client devices, so if bundle size is a concern, you might want to look into alternatives such as flamethrower or Taxi.js.

Persistent Islands

The default behavior of Turbo is to replace the entire <body> of the current page with the contents of the next one. In a lot of cases, this is exactly what we want, but there are times when we might want to persist a particular UI element across navigations.

In the Music Player demo site, there is a <Player /> Preact island And “island” is essentially a component. The difference being that in Astro, the rest of the page (the non-island parts) are static, non-interactive HTML. You can read more about Astro Islands in Astro’s docs. that we would like to persist as the user navigates between the home and album pages. Doing this with Turbo is straightforward:

<div data-turbo-permanent id="audio-player">
  <Player client:load />
</div>

By giving an element on the page the data-turbo-permanent attribute and an id, Turbo will now treat it as a permanent element and will persist it across page loads. When a navigation event happens, Turbo matches all permanent elements by ID and transfers them from one page to the next, preserving the component’s state and event handlers.

Since the player is in the Layout.astro component, it exists in both the home and album pages, so Turbo will not replace it when a navigation happens.

Other Customizations

Turbo comes with a powerful API to customize your website’s behavior. Here are some of the methods that might come handy when building an Astro SPA with it:

Hooks

Turbo dispatches global events during the navigation and rendering phases that we can hook into for custom behavior. For instance, we could hook into the 'turbo:before-render' event (which allow us to pause/resume the rendering of the next page) and use the Shared Element Transition API to transition between the two pages with a nice custom animation.

function onBeforeRender(event) {
  // Pause Rendering
  event.preventDefault()

  const transition = document.createDocumentTransition()
  transition.start(() => {
    // Resume Rendering
    event.detail.resume()
  })
}

document.addEventListener('turbo:load', (event) => {
  document.addEventListener('turbo:before-render', onBeforeRender, {
    once: true,
  })
})

This will apply the default fade in/out transition to the entire page, but you can customize the animation in a number of different ways (including animating different elements independently, like in the Music Player example.)

For much more on this API, make sure you check out my previous blog post and the developer guide on the Google Developers blog.

Caching

Turbo maintains a cache with the most recently viewed pages during your navigation session. One of the ways in which the cache is used is to display the cached version of a page immediately (as a preview) while a fresh copy of the page is loaded in the background. This is similar to how stale-while-revalidate works.

The cache truly makes the navigation feel a lot snappier, and it’s ideal for static content. But when the contents of a page are likely to be affected by interactions on other pages, showing the cached version as a preview could result in an undesired flash of outdated data.

To help with this, Turbo provides an API to interact with the cache, and one of the things we could do with it is opting out of caching on a page-by-page basis, by adding a meta tag to the page’s header: Here, the no-cache value actually means “Don’t cache”, unlike the no-cache directive of the Cache-Control header which means “Do cache”…

<head>
  ...
  <meta name="turbo-cache-control" content="no-cache" />
</head>

Forcing Full-Page Loads

By default, Turbo will try to resolve all page navigations client-side, but sometimes we might want to out-out of this behavior and navigate with a traditional full-page load. One way to do this is by adding a data-turbo="false" attribute to a link or form:

<a href="/" data-turbo="false">Go Home</a>

For much more on customization, check out Turbo’s documentation. The Navigation and Building guides are particularly useful.

Conclusion

In his JamStack Conf 2021 talk, Rich Harris coined the term Transitional Apps to refer to these SPA-MPA hybrids that give you the best of both worlds. I’m a big fan of the concept because it acknowledges the fact that not every site fits nicely into the Pure SPA or Pure MPA buckets. A lot of websites sit somewhere in the middle, and you should be able to move along the SPA-MPA spectrum without having to rebuild your site with a different framework.

If Astro’s Persistent Islands RFC ever becomes a reality (and I really hope it does!), these SPA-like features will come built into the framework. The implementation will likely be much more optimized than anything you could do with third-party scripts today, but until that moment comes, using a library like Turbo to enhance your Astro site is an excellent replacement.

Thank you for reading~