diff --git a/packages/router/package.json b/packages/router/package.json new file mode 100644 index 000000000..f8658b653 --- /dev/null +++ b/packages/router/package.json @@ -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" + } +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts new file mode 100644 index 000000000..db078c874 --- /dev/null +++ b/packages/router/src/index.ts @@ -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}`; + } + + 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(); + + // 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); +} diff --git a/packages/router/src/prefetch.ts b/packages/router/src/prefetch.ts new file mode 100644 index 000000000..f71e7343f --- /dev/null +++ b/packages/router/src/prefetch.ts @@ -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 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 `` + * @param {string} url - the URL to fetch + * @return {Promise} a Promise + */ + function addToHead(url: string): Promise { + 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); + }); + }); +} diff --git a/packages/router/tsconfig.json b/packages/router/tsconfig.json new file mode 100644 index 000000000..c56abb57e --- /dev/null +++ b/packages/router/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "target": "ES2020", + "module": "ES2020", + "outDir": "./dist" + } +}