Skip to content

Instantly share code, notes, and snippets.

@artulloss
Last active June 30, 2023 01:14
Show Gist options
  • Save artulloss/439bdca7255411dbdaaef1148ba485d2 to your computer and use it in GitHub Desktop.
Save artulloss/439bdca7255411dbdaaef1148ba485d2 to your computer and use it in GitHub Desktop.
Make some of your links load content via AJAX instead of a full page load. Good for sections of a website without JavaScript events on them.
/**
* Partial SPA
* Make some of your links load content via AJAX instead of a full page load
*/
function partialSPA(config) {
const {
selectorWithContentAndLinks = "body", // Selector of the element containing the links and the content to replace
selectorToReplace = "body", // Selector of the element to replace
routes = [/./], // Routes to replace
reservedPaths = [], // Paths that should not be replaced
onComplete = null, // Function to run after the page is loaded
cache = false, // Set to localStorage, sessionStorage, RAMStorage to cache pages
refetchAnyways = false, // Refetch the page even if it's in the cache, but will compare the fetched page with the cached page and only replace if they're different
} = config || {};
const eventListenerMap = new WeakMap();
let currentFetch = null;
let controller = new AbortController();
const initSPA = () => {
const sectionWithContentAndLinks = document.querySelector(
selectorWithContentAndLinks
);
const linkElements = sectionWithContentAndLinks.querySelectorAll("a");
if (!linkElements) return; // No links to replace
linkElements.forEach((linkElement) => {
const url = new URL(linkElement.href);
// Only replace links to the same domain
if (url.origin === window.location.origin) {
/**
* Partial SPA
* Make some of your links load content via AJAX instead of a full page load
*/
export default function partialSPA(config) {
const {
selectorWithContentAndLinks = "body", // Selector of the element containing the links and the content to replace
selectorToReplace = "body", // Selector of the element to replace
routes = [/./], // Routes to replace
reservedPaths = [], // Paths that should not be replaced
onComplete = null, // Function to run after the page is loaded
cache = false, // Set to localStorage, sessionStorage, RAMStorage to cache pages
refetchAnyways = false, // Refetch the page even if it's in the cache, but will compare the fetched page with the cached page and only replace if they're different
} = config || {};
const eventListenerMap = new WeakMap();
let lastUrl = "";
let currentFetch = null;
let controller = new AbortController();
const initSPA = () => {
const sectionWithContentAndLinks = document.querySelector(
selectorWithContentAndLinks
);
const linkElements =
sectionWithContentAndLinks.querySelectorAll("a");
if (!linkElements) return; // No links to replace
linkElements.forEach((linkElement) => {
const url = new URL(linkElement.href);
// Only replace links to the same domain
if (url.origin === window.location.origin) {
function handleClick(event) {
if (reservedPaths.includes(url.pathname)) return; // Skip if it's a reserved path
if (routes.every((route) => !route.test(url.pathname)))
return; // Skip if it's not a route we want to replace
event.preventDefault();
if (url.pathname === window.location.pathname) return; // Skip if we're already on the page
// Cancel any previous fetches
if (currentFetch && lastUrl && lastUrl !== url.toString()) {
console.log("Aborting previous fetch for:", lastUrl);
currentFetch = null;
controller.abort();
controller = new AbortController();
}
lastUrl = url.toString();
const switchPage = (data, fetched) => {
const el = document.createElement("html");
el.innerHTML = data;
const section = el.querySelector(selectorToReplace);
if (cache) {
if (fetched) {
// Compare the fetched section with the cached section
const cachedPage = cache.getItem(url.toString());
if (cachedPage === section.outerHTML) {
console.log(
"Page is the same as the cached page. Not replacing."
);
onComplete && onComplete(event.target);
return;
} else {
// Store the cached section
const el = document.createElement("html");
el.innerHTML = data;
cache.setItem(url.toString(), section.outerHTML); // Store only the section we want to replace
}
}
document.querySelector(selectorToReplace).innerHTML =
section.innerHTML;
} else {
document.querySelector(selectorToReplace).innerHTML =
section.innerHTML;
}
window.history.pushState(
{
section: section.innerHTML,
},
"",
url
);
fetched && onComplete && onComplete(event.target);
initSPA();
};
if (cache) {
const data = cache.getItem(url.toString());
if (data) {
switchPage(data, !refetchAnyways);
if (!refetchAnyways) return;
}
}
currentFetch = fetch(url, { signal: controller.signal });
currentFetch
.then((response) => {
return response.text();
})
.then((data) => {
switchPage(data, true);
currentFetch = null;
})
.catch((error) => {
currentFetch = null;
if (error.name === "AbortError") return; // We expect this error when we abort the fetch
console.error(error);
});
}
if (eventListenerMap.has(linkElement)) {
linkElement.removeEventListener(
"click",
eventListenerMap.get(linkElement)
);
}
linkElement.addEventListener("click", handleClick);
eventListenerMap.set(linkElement, handleClick);
}
});
};
initSPA();
window.addEventListener("popstate", (event) => {
if (event.state) {
document.querySelector(selectorToReplace).innerHTML =
event.state.section;
initSPA();
onComplete && onComplete();
}
});
if (cache) {
// Cache the initial page
cache.setItem(
window.location.toString(),
document.querySelector(selectorToReplace).outerHTML
);
} else if (refetchAnyways) {
console.warn(
"partialSPA: refetchAnyways should only be true when using a cache."
);
}
}
// Not persisted
const RAMStorage = {
map: new Map(),
getItem: (key) => {
return RAMStorage.map.get(key);
},
setItem: (key, value) => {
RAMStorage.map.set(key, value);
},
};
function handleClick(event) {
if (reservedPaths.includes(url.pathname)) return; // Skip if it's a reserved path
if (routes.every((route) => !route.test(url.pathname))) return; // Skip if it's not a route we want to replace
event.preventDefault();
if (url.pathname === window.location.pathname) return; // Skip if we're already on the page
// Cancel any previous fetches
if (currentFetch && lastUrl && lastUrl !== url.toString()) {
console.log("Aborting previous fetch for:", lastUrl);
currentFetch = null;
controller.abort();
controller = new AbortController();
}
lastUrl = url.toString();
const switchPage = (data, fetched) => {
const el = document.createElement("html");
el.innerHTML = data;
const section = el.querySelector(selectorToReplace);
if (cache) {
if (fetched) {
// Compare the fetched section with the cached section
const cachedPage = cache.getItem(url.toString());
if (cachedPage === section.outerHTML) {
console.log(
"Page is the same as the cached page. Not replacing."
);
onComplete && onComplete(event.target);
return;
} else {
// Store the cached section
const el = document.createElement("html");
el.innerHTML = data;
cache.setItem(url.toString(), section.outerHTML); // Store only the section we want to replace
}
}
document.querySelector(selectorToReplace).innerHTML =
section.innerHTML;
} else {
document.querySelector(selectorToReplace).innerHTML =
section.innerHTML;
}
window.history.pushState(
{
section: section.innerHTML,
},
"",
url
);
fetched && onComplete && onComplete(event.target);
initSPA();
};
if (cache) {
const data = cache.getItem(url.toString());
if (data) {
switchPage(data, !refetchAnyways);
if (!refetchAnyways) return;
}
}
currentFetch = fetch(url, { signal: controller.signal });
currentFetch
.then((response) => {
return response.text();
})
.then((data) => {
switchPage(data, true);
currentFetch = null;
})
.catch((error) => {
currentFetch = null;
if (error.name === "AbortError") return; // We expect this error when we abort the fetch
console.error(error);
});
}
if (eventListenerMap.has(linkElement)) {
linkElement.removeEventListener(
"click",
eventListenerMap.get(linkElement)
);
}
linkElement.addEventListener("click", handleClick);
eventListenerMap.set(linkElement, handleClick);
}
});
};
initSPA();
window.addEventListener("popstate", (event) => {
if (event.state) {
document.querySelector(selectorToReplace).innerHTML = event.state.section;
initSPA();
onComplete && onComplete();
}
});
if (cache) {
// Cache the initial page
cache.setItem(
window.location.toString(),
document.querySelector(selectorToReplace).outerHTML
);
} else if (refetchAnyways) {
console.warn(
"partialSPA: refetchAnyways should only be true when using a cache."
);
}
}
// Not persisted
const RAMStorage = {
map: new Map(),
getItem: (key) => {
return RAMStorage.map.get(key);
},
setItem: (key, value) => {
RAMStorage.map.set(key, value);
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment