wip: opt-in client-side router
This commit is contained in:
parent
f04b82d47e
commit
c39ea30979
4 changed files with 356 additions and 0 deletions
23
packages/router/package.json
Normal file
23
packages/router/package.json
Normal 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"
|
||||
}
|
||||
}
|
182
packages/router/src/index.ts
Normal file
182
packages/router/src/index.ts
Normal 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);
|
||||
}
|
141
packages/router/src/prefetch.ts
Normal file
141
packages/router/src/prefetch.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
10
packages/router/tsconfig.json
Normal file
10
packages/router/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue