Case
SPA-style navigation in WordPress themes
Pages that open without reloading
Context
Regular WordPress themes, where every click loads a brand new page. I wrote a JavaScript layer, part of a PHP module that adjusts WordPress, that gives these themes SPA-style navigation without changing the theme's architecture.
The problem
On every click, the browser reloaded and reprocessed the header, footer, stylesheets and scripts, even when only the main content changed.
What changes on each click
The screens above are navigable: click the menu, the cards and the buttons in each one, and compare.
The code
Decides whether to take over the click
// Decides whether the layer takes over the click. Any doubt returns false and the browser follows its normal behavior.
shouldHandle(link) {
const href = link.getAttribute("href");
// Anchors, links to another destination and downloads are left out.
if (!href || href.startsWith("#")) return false;
if (link.target === "_blank" || link.hasAttribute("download")) return false;
if (link.hasAttribute("data-no-spa") || link.rel.includes("external")) return false;
// Only same-site links, outside the blocked areas (dashboard, login, API) and not a file.
const url = new URL(link.href, window.location.href);
if (!/^https?:$/.test(url.protocol) || url.origin !== window.location.origin) return false;
if (BLOCKED_PATHS.some((path) => url.pathname.startsWith(path))) return false;
if (FILE_PATTERN.test(url.pathname)) return false;
// A link to an anchor on the current page does not need to fetch anything either.
return !(this.withoutHash(url.href) === this.currentUrl && url.hash);
} Fetches, checks and swaps
// Main flow: uses the cache if there is one; otherwise fetches the page, checks it is safe and swaps only the content.
async navigate(url, push) {
const cached = this.cachedFor(url);
if (cached) return this.openCached(url, cached, push);
// If the person clicks another link, cancels the previous fetch.
if (this.controller) this.controller.abort();
const controller = new AbortController();
this.controller = controller;
this.setBusy(true);
try {
// Fetches the HTML of the next page, with the same session cookies.
const response = await fetch(url, {
credentials: "same-origin",
signal: controller.signal,
});
const contentType = response.headers.get("content-type") || "";
if ((!response.ok && response.status !== 404) || !contentType.includes("text/html")) {
// Any unexpected response: normal load, without risking a broken page.
return this.reload(url);
}
const html = await response.text();
// Parses the HTML without showing it yet.
const doc = this.parse(html);
if (!this.canSwap(doc, response.url)) {
return this.reload(response.url);
}
// Swaps the main content and updates the browser history.
await this.swap(doc, this.keepHash(response.url, url), push);
this.remember(url, response.url, html);
} catch (error) {
if (error.name !== "AbortError") this.reload(url);
} finally {
if (this.controller === controller) this.setBusy(false);
}
}
parse(html) {
return new DOMParser().parseFromString(html, "text/html");
} Cache limited to 30 pages
// Returns the page from the cache and marks it as the most recent (the most used is the last to go).
cachedFor(url) {
const key = this.keyOf(url);
if (key === this.currentUrl || !this.cache.has(key)) return null;
const entry = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, entry);
return entry;
}
// Stores the page HTML so it opens instantly on the next visit.
remember(requestedUrl, finalUrl, html) {
const entry = { html: html, url: finalUrl };
[requestedUrl, finalUrl].forEach((url) => {
const key = this.keyOf(url);
this.cache.delete(key);
this.cache.set(key, entry);
});
// Over the limit (30 pages): discards the oldest.
while (this.cache.size > CACHE_LIMIT) {
this.cache.delete(this.cache.keys().next().value);
}
} Result
The navigation now swaps only the main content, and pages already visited open instantly.
Technologies
- JavaScript
- PHP
- WordPress
- Fetch API
- History API
- DOMParser
Contact
Shall we talk?
A role, an opportunity or just a technical question. Tell me what you have in mind and I'll reply by email.