Jekyll Webpack Part Three - Diffing for partial reloads
This article is Part 3 in a 3-Part series.
- Part 1 - Jekyll Webpack
- Part 2 - Jekyll Webpack Part Two - Tailwind
- Part 3 - This Article
Updated with a loading spinner function and a more performant diffing library. I wanted the Soundcloud player in the rightbar (or above on mobile) of this site to persist it’s playing state across link clicks, this is the technique I used to do it
Jekyll is by nature a static site generator, so the idea of AJAXy loading seems mostly unnecessary. Sometimes though you might want to add a persistent feature such as a media player that retains it’s state as a visitor browses through the site.
Building on the existing use of webpack in previous parts of this article series I will build this using HTML5’s history api, the fetch API, a diffing library - nanomorph
and nested layouts.
Edit the templates
Layout
Firstly, any element that you want to persist across link clicks should be in the root layout, this way the diffing algorithm will only replace the stuff you want. Say you have a default layout which is the root of the page and post layouts:-
<!-- _layouts/default.html -->
<!DOCTYPE html>
<html lang="{{ page.lang | default: site.lang | default: "en" }}">
{%- include head.html -%}
<body class='flex flex-col min-h-screen'>
{%- include header.html -%}
<main class="flex-grow page-content pt-4 mb-4" aria-label="Content">
<div class="container mx-auto">
{{ content }}
</div>
</main>
{%- include footer.html -%}
</body>
</html>
<!-- _layouts/post.html -->
---
layout: default
...
etc…
Modify it with say a right bar section aligned with flex, note the html template element at the end, this is so we can inject a loading spinner into the page in cases where page load times are poor, you can find some nice corresponding pure CSS spinners here:-
diff --git a/_layouts/default.html b/_layouts/default.html
index 28404e3..7bac4c9 100644
--- a/_layouts/default.html
+++ b/_layouts/default.html
@@ -8,13 +8,22 @@
{%- include header.html -%}
<main class="flex-grow page-content pt-4 mb-4" aria-label="Content">
- <div class="container mx-auto">
- {{ content }}
+ <div class="container mx-auto md:flex md:flex-row-reverse">
+ <section id='rightbar' class='pl-2 pr-2 md:pl-4 md:pr-0'>
+ {% include sidebar.html %}
+ </section>
+ <section id='content' class='pl-2 pr-2 md:pr-4 md:pl-0 md:flex-shrink-0"'>
+ {{ content }}
+ </section>
</div>
</main>
{%- include footer.html -%}
+ <template id=spinnerTemplate'>
+ <div id='loadingSpinner'>
+ <div class="lds-dual-ring"><div></div></div>
+ </div>
+ </template>
</body>
</html>
Rightbar include
Now create an _includes/rightbar.html
with the elements for persistence, in this case, the soundcloud media player you see on this site:-
<small>
Hidden Sheep. Upsidedown Goat!
</small>
<br/>
<br/>
<iframe width="100%" height="300" scrolling="no" frameborder="no" allow="autoplay" src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/637757217&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true"></iframe><div style="font-size: 10px; color: #cccccc;line-break: anywhere;word-break: normal;overflow: hidden;white-space: nowrap;text-overflow: ellipsis; font-family: Interstate,Lucida Grande,Lucida Sans Unicode,Lucida Sans,Garuda,Verdana,Tahoma,sans-serif;font-weight: 100;"><a href="https://soundcloud.com/the-sheep-that-hid" title="The Sheep That Hid" target="_blank" style="color: #cccccc; text-decoration: none;">The Sheep That Hid</a> · <a href="https://soundcloud.com/the-sheep-that-hid/stand-up-song" title="Stand Up Song" target="_blank" style="color: #cccccc; text-decoration: none;">Stand Up Song</a></div>
<br/>
SPAification
Now the templates are out of the way, lets get to the core of this technique.
First yarn add nanomorph
in the root of your Jekyll project. This awesome package will do most of the gruntwork for this solution. Then add the following javascript to ./src/index.js
.
NOTE: there are many diffing libraries out there with varying performance, I use nanomorph
here as an example but you should do your research to find the most performant one for your needs.
import morph from 'nanomorph'
// every time a link is clicked this function is called to push the history state onto the stack
function historyHandler(e) {
e.preventDefault();
window.history.pushState({ url: this.pathname }, "", this.pathname);
}
// as soon as the click has been initiated, show the spinner
function showSpinner (template) {
const clone = template.content.cloneNode(true)
document.body.appendChild(clone)
}
// this function wraps nanomorph and is called after the ajax request fetches your page and does the morphing
function updatePage(html) {
const parser = new DOMParser();
const page = parser.parseFromString(html, 'text/html');
morph(document.body, page.body)
}
// the hideSpinner function is called as the last part of the success callback of the fetch
function hideSpinner () {
const spinner = document.getElementById('loadingSpinner')
spinner.remove()
}
// patch historys pushState with a new callback
(function(history){
var pushState = history.pushState;
window.history.pushState = function(state, name, url) {
if (typeof history.onpushstate == "function") {
window.history.onpushstate(state, name, url);
}
return pushState.apply(history, arguments);
};
})(window.history);
// since we are making the site a single page app - the DOMContentLoaded event listener only needs to happen once. All subsequent reloads will be handled with fetch
document.addEventListener("DOMContentLoaded", function(event) {
var siteUrl = '//'+(document.location.hostname||document.location.host);
const template = document.getElementsByTagName('template')[0]
console.log(template)
// set up the history handling
document.addEventListener("click", function(e) {
for (var target=e.target; target && target!=this; target=target.parentNode) {
if (target.matches('a[href^="/"], a[href^="'+siteUrl+'"]')) {
historyHandler.call(target, e);
break;
}
}
}, false);
// do the reload in the callbacks of history events
window.onpopstate = function(e) {
var state = window.history.state;
showSpinner(template)
// now fetch the static html page from the backend
fetch(state.url)
.then(response => response.text())
.then(html => updatePage(html))
}
window.history.onpushstate = function(state, name, url) {
showSpinner(template)
fetch(url)
.then(response => response.text())
.then(html => updatePage(html))
}
});
This script does a couple of things. It patches the history API with an onpushstate callback. Now, whenever a link is clicked, or the back button pressed, the event will be intercepted, the url loaded asyncronously using fetch
and then the document body is diffed and updated accordingly. It’s inspired by code from the now defunct https://github.com/joelhans/Jekyll-AJAX and also some great answers on stack overflow regarding push events with HTML’s history API
And that’s it!
This is the technique I used to make the player you see in the rightbar continue to work as the visitor browses through any other content on the site.