wip: opt-in client-side router

This commit is contained in:
Nate Moore 2021-06-19 18:09:03 -05:00
parent f04b82d47e
commit c39ea30979
4 changed files with 356 additions and 0 deletions

View file

@ -0,0 +1,23 @@
{
"name": "@astrojs/router",
"version": "0.1.0",
"main": "./dist/index.js",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/snowpackjs/astro.git",
"directory": "packages/router"
},
"exports": {
".": "./dist/index.js"
},
"scripts": {
"prepublish": "yarn build",
"build": "astro-scripts build \"src/index.ts\" && tsc -p tsconfig.json",
"dev": "astro-scripts dev \"src/index.ts\""
},
"devDependencies": {
"astro-scripts": "0.0.1",
"morphdom": "^2.6.1"
}
}

View file

@ -0,0 +1,182 @@
import diff from 'morphdom';
import { listen } from './prefetch.js';
const defineRouter = () => {
// See https://github.com/catberry/catberry/blob/8.0.3/browser/DocumentRenderer.js#L760-L791
function isTagImmutable(element: Element) {
// these 3 kinds of tags once loaded can not be removed
// otherwise it will cause style or script reloading
return element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || (element.nodeName === 'LINK' && element.getAttribute('rel') === 'stylesheet');
}
/**
* Gets an unique element key using element's attributes and its content.
* @param {Element} element HTML element.
* @returns {string} Unique key for the element.
* @private
*/
function getElementKey(element: Element) {
// some immutable elements have several valuable attributes
// these attributes define the element identity
const attributes = [];
switch (element.nodeName) {
case 'LINK':
attributes.push(`href=${element.getAttribute('href')}`);
break;
case 'SCRIPT':
attributes.push(`src=${element.getAttribute('src')}`);
break;
}
return `<${element.nodeName} ${attributes.sort().join(' ')}>${element.textContent}</${element.nodeName}>`;
}
class AstroRouter extends HTMLElement {
domParser = new DOMParser();
cache = new Map();
isNavigating = false;
constructor() {
super();
this.onClick = this.onClick.bind(this);
this.transition = this.transition.bind(this);
this.navigate = this.navigate.bind(this);
}
async connectedCallback() {
window.addEventListener('click', this.onClick);
window.addEventListener('popstate', this.navigate);
listen();
}
disconnectedCallback() {
window.removeEventListener('click', this.onClick);
}
navigate(event: PopStateEvent) {
this.transition(window.location.toString());
}
/**
* Merges new and existed head elements and applies only difference.
* The problem here is that we can't re-create or change script and style tags,
* because it causes blinking and JavaScript re-initialization. Therefore such
* element must be immutable in the HEAD.
* @param {Element} head HEAD DOM element.
* @param {Element} newHead New HEAD element.
* @private
*/
mergeHead(head: HTMLHeadElement, newHead: HTMLHeadElement) {
if (!newHead) {
return;
}
const headSet = new Set<string>();
// remove all nodes from the current HEAD except immutable ones
for (let i = 0; i < head.childNodes.length; i++) {
const current = head.childNodes[i] as Element;
if (!isTagImmutable(current)) {
head.removeChild(current);
i--;
continue;
}
// we need to collect keys for immutable elements to handle
// attributes reordering
headSet.add(getElementKey(current));
}
for (let i = 0; i < newHead.childNodes.length; i++) {
const current = newHead.childNodes[i] as Element;
if (current.nodeType !== current.ELEMENT_NODE || headSet.has(getElementKey(current))) {
continue;
}
head.appendChild(current);
// when we append existing child to another parent it removes
// the node from a previous parent
i--;
}
}
async transition(href: string | URL, action?: () => void) {
if (typeof href !== 'string') href = href.toString();
let html: string;
if (this.cache.has(href)) {
html = this.cache.get(href);
} else {
html = await fetch(href).then((res) => res.text());
this.cache.set(href, html);
}
const doc = this.domParser.parseFromString(html, 'text/html');
const root = doc.querySelector('astro-router');
if (!root) {
window.location.assign(href);
return;
}
this.isNavigating = true;
// await exit(this);
this.mergeHead(document.head, doc.head);
if (action) action();
diff(document.body, doc.body, {
childrenOnly: true,
onNodeAdded: (node) => {
if ((node as any).tagName === 'SCRIPT') {
// Manually recreate the `script` in order to re-execute it
const newScript = document.createElement('script');
newScript.type = 'module';
let text = node.textContent ? document.createTextNode(node.textContent) : null;
if (text) {
newScript.appendChild(text);
}
node.parentNode?.replaceChild(newScript, node);
return (false as any);
}
return node;
},
onBeforeElUpdated: (fromEl, toEl) => {
if (toEl.tagName === 'script' && fromEl.textContent === toEl.textContent) {
return false;
}
if (toEl.tagName === 'ASTRO-ROOT' && fromEl.getAttribute('uid') === toEl.getAttribute('uid')) {
return false;
}
return !fromEl.isEqualNode(toEl);
}
});
// await enter(this);
this.isNavigating = false;
}
async onClick(event: Event) {
if (this.isNavigating) {
event.preventDefault();
return;
}
if ((event.target as HTMLElement).tagName !== 'A') return;
const a = event.target as HTMLAnchorElement;
const href = new URL(a.href);
if (href.origin !== location.origin) return;
event.preventDefault();
this.transition(href, () => {
try {
history.pushState({ url: a.href }, '', a.href);
window.scrollTo({ top: 0, left: 0 });
} catch (error) {
window.location.assign(a.href);
}
});
}
}
customElements.define('astro-router', AstroRouter);
};
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(defineRouter);
} else {
setTimeout(defineRouter, 200);
}

View file

@ -0,0 +1,141 @@
/** Observe link visiblity and add listeners to prefetch the URL as needed */
export function listen() {
if (typeof window === "undefined") return;
// Cache of URLs we've already prefetched
const cache = new Set();
const listeners = new Map<HTMLAnchorElement, (...args: any) => any>();
const events = ["focus", "pointerenter"];
// RIC and shim for browsers setTimeout() without it
const requestIdleCallback =
(window as any).requestIdleCallback ||
function (cb: (...args: any) => any) {
const start = Date.now();
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
};
/** prefecth a given URL */
function prefetch(url: string) {
const conn = (window.navigator as any).connection;
if (conn) {
// Don't prefetch if using 2G or if Save-Data is enabled.
if (conn.saveData || /2g/.test(conn.effectiveType)) {
return;
}
}
if (!cache.has(url)) {
cache.add(url);
return addToHead(new URL(url, window.location.href).toString());
}
}
/**
* Checks if a Node is an HTMLElement
* @param node DOM node to check
*/
function isElement(node: Node): node is HTMLElement {
return node.nodeType === node.ELEMENT_NODE;
}
/**
* Fetches a given URL using `<link rel=prefetch>`
* @param {string} url - the URL to fetch
* @return {Promise<Event>} a Promise
*/
function addToHead(url: string): Promise<Event> {
let link: HTMLLinkElement;
return new Promise((res, rej) => {
link = document.createElement(`link`);
link.setAttribute("astro-prefetch", "");
link.rel = `prefetch`;
link.href = url;
link.onload = res;
link.onerror = rej;
document.head.appendChild(link);
});
}
let usesPointer = false;
const hoverMedia = matchMedia("(hover: hover)");
usesPointer = hoverMedia.matches;
hoverMedia.addEventListener("change", ({ matches }) => {
usesPointer = matches;
});
const io = new IntersectionObserver((entries) => {
entries.forEach((_entry) => {
if (_entry.isIntersecting) {
const entry = _entry.target as HTMLAnchorElement;
if (cache.has(entry.href)) return;
const cleanup = () => {
if (!listeners.has(entry)) return;
for (const event of events) {
entry.removeEventListener(event, cb);
}
listeners.delete(entry);
};
const cb = () => {
prefetch(entry.href)?.finally(() => cleanup());
};
if (!usesPointer) {
cb();
} else {
listeners.set(entry, cleanup);
for (const event of events) {
entry.addEventListener(event, cb, { once: true });
}
}
}
});
});
const mo = new MutationObserver((entries) => {
entries.forEach((entry) => {
if (entry.addedNodes.length === 0 && entry.removedNodes.length === 0) {
return;
}
// Listen for any new links
const links: HTMLAnchorElement[] = [];
Array.from(entry.addedNodes).forEach((el) => {
if (isElement(el)) {
links.push(...Array.from(el.querySelectorAll("a")).filter(a => new URL(a.href).origin === location.origin));
}
});
if (links.length === 0) return;
links.forEach((link) => {
io.observe(link);
});
// Cleanup any old links
Array.from(entry.removedNodes).forEach((el) => {
if (isElement(el)) {
Array.from(el.querySelectorAll("a")).filter(a => new URL(a.href).origin === location.origin).forEach((a) => {
if (listeners.has(a)) {
listeners.get(a)?.();
}
});
}
});
});
});
requestIdleCallback(() => {
mo.observe(document.body, { childList: true, subtree: true });
document.querySelectorAll("a").forEach((link) => {
io.observe(link);
});
});
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"allowJs": true,
"target": "ES2020",
"module": "ES2020",
"outDir": "./dist"
}
}