Nov 27, 2022

Revisiting PJAX

Personal sites are great venues for exploring those half-baked and high-contrast ideas that are illuminating, but impractical for commercial work. No funnels, no customers; just a blank canvas and the freedom to explore it.

Lately, the tension between functional attributes (performance, accessibility, DX) and experiential attributes has been the focus of my own exploration. On one side of the spectrum, you have bare HTML. Pure functionality (debatable!), a big zero experience-wise. Like a car stripped down to bare frame, this is “too far”. On the other side, you have pure js apps and sites. Rich, expressive, and capable, but complex and comparatively slow. Also “too far”. If these are both “too far”, what does “just right” look like? How far can you push on one without sacrificing the other? Is it even really a tradeoff? Can you have it all?

Most of my energy has been focused on pushing towards performance and simplicity, but this time around I thought I’d switch things up and focus on a marquee experiential features of SPA frameworks; app-like, animated page transitions. Frameworks and libraries are out. Too big, too complex, or both. So what’s left? Do-it-yourself PJAX.

PJAX as a technique isn’t remotely new, but as it turns out, modern javascript offerings like custom elements make it a heck of a lot easier to implement without help from a library. For example, one of the problems you have to solve with PJAX is that page events only fire on the first page visit, which causes all kinds of problems for scripts you might be running on various window events. Custom elements sidestep the problem completely thanks to baked-in lifecycle events like connectedCallback. They Just Work with the PJAX pattern, no extra code required.

Custom elements are a biggie, but the fetch API and host of other improvements all contribute to then end result of a lot less code. Here’s the final PJAX custom element I cooked up, that’s running on this website right now:

export default class PageTransition extends HTMLElement {
  async getPage(url) {
    try {
      const response = await fetch(url);
      const responseText = await response.text();
      const parser = new DOMParser();
      const doc = parser.parseFromString(responseText, 'text/html');
      window.history.pushState(null, null, url);
      this.transitionPage(doc);
    } catch (e) {
      window.location.href = url;
    }
  }

  transitionPage(doc) {
    const { head } = document;
    const newElements = doc.querySelectorAll('head > [data-swap]');
    const oldElements = document.querySelectorAll('head > [data-swap]');
    const newContent = doc.querySelector('page-transition').innerHTML;
    const containerStyles = getComputedStyle(this);
    const delay = parseFloat(containerStyles.transitionDuration.slice(0, -1));
    // transition out
    this.classList.add('transitioning');
    setTimeout(() => {
      // replace page elements
      this.innerHTML = newContent;
      oldElements.forEach((element) => element.remove());
      newElements.forEach((element) => head.appendChild(element));
      // transition in
      this.transitionable();
      window.scrollTo(0, 0);
      this.classList.remove('transitioning');
    }, delay * 1000);
  }

  transitionable() {
    // only act on relative urls
    const links = document.querySelectorAll('a[href^="/"]');
    links.forEach((link) => {
      if (link.getAttribute('listener') !== 'true') {
        link.setAttribute('listener', 'true');
        link.addEventListener('click', (e) => {
          if (!e.metaKey) {
            e.preventDefault();
            this.getPage(link.getAttribute('href'));
          }
        });
      }
    });
  }

  connectedCallback() {
    this.transitionable();
    // reload the page when history controls are used
    window.addEventListener('popstate', (event) => {
      window.location.reload();
    });
  }
}

window.customElements.define('page-transition', PageTransition);

56 lines of straightforward javascript that covers the basics and then some. It handles swapping all the <head> content that needs swapping and the exit portion of page transitions. The intro portion is handled by a separate, pre-existing custom element that handles scroll triggered animations of all types. This approach allows for more flexibility, and lets me skip handling back/forward controls without losing the entire transition.

Definitely a worthwhile experiment, and one that I’ll keep running for a while, but my big takeaway is that it’ll be a good day if and when the View Transition API makes all of this unnecessary.

Read more notes