router-dom
Version:
A lightweight router for everyone
367 lines (366 loc) • 13.9 kB
JavaScript
import { match } from "path-to-regexp";
import { render, html, hydro, $, $$ } from "hydro-js";
import { compileRoutes, getRoutePathname, resolveRoute, } from "./routes.js";
export { compileRoutes, resolveRoute } from "./routes.js";
let router;
const storageKey = "router-scroll";
const outletSelector = "[data-outlet]";
const reactivityRegex = /\{\{([^]*?)\}\}/;
const fetchCache = new WeakMap();
const scheduleIdle = window.requestIdleCallback?.bind(window) ??
((callback) => window.setTimeout(callback));
let base = $("base")?.getAttribute("href") || "";
if (base.endsWith("/")) {
base = [...base].slice(0, -1).join("");
}
addEventListener("popstate", async (e) => {
router?.doRouting(location.pathname + location.search, e);
});
// Reload -> store scrollPosition
addEventListener("beforeunload", () => sessionStorage.setItem(`${storageKey}-${location.pathname + location.search}`, `${scrollX} ${scrollY}`));
export default class Router {
options;
routes;
oldRoute;
routingVersion = 0;
constructor(routes, options = {}) {
const newRoutes = compileRoutes(routes, base).map(toRoute);
this.routes = newRoutes;
this.options = options;
router = this;
const initialUrl = location.pathname + location.search;
const initialRoute = this.getMatchingRoute(initialUrl);
const initialOutlet = $(outletSelector);
const serverRoute = initialOutlet?.getAttribute("data-router-path");
const adoptsInitialRoute = initialRoute !== undefined &&
(serverRoute === initialUrl || serverRoute === location.pathname);
if (adoptsInitialRoute) {
initialOutlet.removeAttribute("data-router-path");
}
// Prefetch resources
this.routes.forEach((route) => {
if (adoptsInitialRoute && initialRoute.chain.includes(route.route)) {
return;
}
// @ts-expect-error
if (route.templateUrl && !navigator.connection?.saveData) {
scheduleIdle(() => {
getTemplate(route.route).catch(async (err) => {
await this.options.errorHandler?.(err);
});
});
}
});
this.doRouting(initialUrl, undefined, adoptsInitialRoute);
if (adoptsInitialRoute ||
initialRoute?.chain.every((segment) => !segment.templateUrl)) {
this.oldRoute = initialUrl;
}
}
getMatchingRoute(path) {
return resolveRoute(this.routes, path);
}
finishRouting(routingVersion) {
if (routingVersion === this.routingVersion) {
dispatchEvent(new Event("afterRouting"));
}
}
async doRouting(to = location.pathname + location.search, e, adopt = false) {
const routingVersion = ++this.routingVersion;
const isCurrent = () => routingVersion === this.routingVersion;
dispatchEvent(new Event("beforeRouting"));
const from = this.oldRoute ?? to;
const route = this.getMatchingRoute(to);
if (!route) {
this.finishRouting(routingVersion);
return;
}
{
const routeStorageKey = `${storageKey}-${to}`;
// Store position
if (this.oldRoute) {
sessionStorage.setItem(`${storageKey}-${from}`, `${scrollX} ${scrollY}`);
}
try {
const { params } = match(route.pathname, {
decode: decodeURIComponent,
})(getRoutePathname(to));
const allParams = {
...Router.getParams(new URL(to, location.href).search),
...Object.fromEntries(Object.entries(params)
.map((pair) => Number.isNaN(Number(pair[0])) && pair)
.filter(Boolean)),
};
const props = {
from: from.replace(base, ""),
to: to.replace(base, ""),
...(Object.keys(allParams).length ? { params: allParams } : {}),
...(history.state && Object.keys(history.state).length
? { state: history.state }
: {}),
};
// Trigger leave
const currentRoute = this.oldRoute
? this.getMatchingRoute(this.oldRoute)
: undefined;
if (currentRoute) {
await currentRoute["leave" /* cycles.leave */]?.(props);
if (!isCurrent())
return;
}
// Trigger beforeEnter
await route["beforeEnter" /* cycles.beforeEnter */]?.(props);
if (!isCurrent())
return;
if (!adopt) {
await renderRoute(route, currentRoute, $(outletSelector), isCurrent, this.options.viewTransitions);
if (!isCurrent())
return;
}
// Trigger afterEnter
await route["afterEnter" /* cycles.afterEnter */]?.(props);
if (!isCurrent())
return;
this.oldRoute = to;
}
catch (err) {
if (this.options.errorHandler) {
await this.options.errorHandler(err, e);
}
else {
console.error(err, e);
}
}
finally {
if (!isCurrent())
return;
// Reload -> restore scroll position
if (route.restoreScroll && sessionStorage.getItem(routeStorageKey)) {
const [left, top] = sessionStorage
.getItem(routeStorageKey)
.split(" ")
.map(Number);
sessionStorage.removeItem(routeStorageKey);
scrollTo({
top,
left,
behavior: this.options.scrollBehavior || "auto",
});
}
else {
// Reset Scroll, just like Browser
if (!window.isHMR) {
scrollTo({
top: 0,
left: 0,
behavior: this.options.scrollBehavior || "auto",
});
}
}
this.finishRouting(routingVersion);
}
}
}
go(path, state = {}, params = "") {
const newPath = base + path + params;
// Only navigate when the path differs
if (newPath !== location.pathname + location.search) {
history.pushState({ ...state }, "", newPath);
this.doRouting(newPath);
}
}
removeRoute(path) {
const target = this.routes.find((route) => route.pathname === getCompiledPathname(path));
if (!target)
return;
for (let index = this.routes.length - 1; index >= 0; index--) {
if (this.routes[index].chain.includes(target.route)) {
this.routes.splice(index, 1);
}
}
}
addRoute(route) {
this.routes.push(...compileRoutes([route], base).map(toRoute));
}
modifyRoute(path, newRoute) {
const target = this.routes.find((route) => route.pathname === getCompiledPathname(path));
if (!target)
return;
const descendantIndexes = this.routes.flatMap((route, index) => route.chain.includes(target.route) ? [index] : []);
this.routes.splice(descendantIndexes[0], descendantIndexes.length, ...compileRoutes([newRoute], base).map(toRoute));
}
changeOptions(options) {
this.options = options;
}
static getParams(search = location.search) {
return Object.fromEntries(new URLSearchParams(search));
}
}
function registerAnchorEvent(anchor) {
anchor.addEventListener("click", (e) => {
const href = anchor.getAttribute("href");
const target = anchor.getAttribute("target");
if (e.defaultPrevented ||
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey ||
!href ||
anchor.hasAttribute("download") ||
(target && target !== "_self")) {
return;
}
const url = new URL(href, location.href);
if (url.origin !== location.origin ||
!["http:", "https:"].includes(url.protocol) ||
(url.hash &&
url.pathname === location.pathname &&
url.search === location.search)) {
return;
}
e.preventDefault();
const hasData = anchor.getAttribute("data");
const hydroProp = replaceBars(hasData);
const path = url.pathname.startsWith(`${base}/`)
? url.pathname.slice(base.length)
: url.pathname;
router.go(path + url.search + url.hash, hasData ? hydro[hydroProp] : void 0);
});
}
function registerFormEvent(form) {
form.addEventListener("submit", (e) => {
if (!router.options.formHandler)
return;
e.preventDefault();
const action = form.action;
const method = form.method.toUpperCase();
fetch(action, {
method,
...(!["HEAD", "GET"].includes(method)
? { body: new FormData(form) }
: {}),
...router.options.fetchOptions,
})
.then((res) => router.options.formHandler(res, e))
.catch(async (err) => {
if (router.options.errorHandler) {
await router.options.errorHandler(err, e);
}
else {
console.error(err, e);
}
});
});
}
function replaceBars(hydroTerm) {
if (hydroTerm === null || !hydroTerm.includes("{{"))
return hydroTerm;
const [_, hydroPath] = hydroTerm.match(reactivityRegex) || [];
return hydroPath;
}
function toRoute(resolved) {
return {
restoreScroll: true,
...resolved.route,
...resolved,
originalPath: resolved.pathname,
};
}
function getCompiledPathname(path) {
return compileRoutes([{ path }], base)[0].pathname;
}
// Add EventListener for every added anchor and form Element
$$("a").forEach(registerAnchorEvent);
$$("form").forEach(registerFormEvent);
new MutationObserver((entries) => {
for (const entry of entries) {
for (const node of entry.addedNodes) {
const nodes = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT, {
acceptNode(elem) {
return ["form", "a"].includes(elem.localName)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let formOrA;
while ((formOrA = nodes.nextNode())) {
if (formOrA.localName === "a") {
registerAnchorEvent(formOrA);
}
else {
registerFormEvent(formOrA);
}
}
}
}
}).observe(document.body, { childList: true, subtree: true });
async function renderRoute(route, currentRoute, where, isCurrent, viewTransitions = false) {
let sharedSegments = 0;
while (currentRoute?.chain[sharedSegments] === route.chain[sharedSegments] &&
sharedSegments < route.chain.length) {
sharedSegments++;
}
if (sharedSegments === route.chain.length)
sharedSegments--;
for (let index = 0; index < sharedSegments; index++) {
const nestedOutlet = where.querySelector(outletSelector);
if (!nestedOutlet)
break;
where = nestedOutlet;
}
const routeChain = route.chain.slice(sharedSegments);
const templates = await Promise.all(routeChain.map(async (segment) => segment.templateUrl
? window.isHMR
? (await fetch(segment.templateUrl)).text()
: getTemplate(segment)
: segment.element));
if (!isCurrent())
return;
const copy = where.cloneNode();
let outlet = copy;
for (let index = 0; index < routeChain.length; index++) {
const content = templates[index];
if (content !== undefined)
outlet.append(html `${content}`);
if (index === routeChain.length - 1)
continue;
const nestedOutlet = outlet.querySelector(outletSelector);
if (!nestedOutlet) {
throw new Error(`Route ${route.pathname} needs a nested ${outletSelector}`);
}
nestedOutlet.replaceChildren();
outlet = nestedOutlet;
}
if (!viewTransitions || !document.startViewTransition) {
render(copy, where, false);
return;
}
const transition = document.startViewTransition(() => {
if (isCurrent())
render(copy, where, false);
});
void transition.ready.catch(() => { });
await transition.updateCallbackDone;
}
function getTemplate(route) {
let cache = fetchCache.get(route);
if (cache?.html !== undefined)
return Promise.resolve(cache.html);
if (!cache) {
cache = {};
fetchCache.set(route, cache);
}
if (!cache.promise) {
cache.promise = fetch(route.templateUrl)
.then((response) => response.text())
.then((template) => {
cache.html = template;
return template;
})
.finally(() => {
Reflect.deleteProperty(cache, "promise");
});
}
return cache.promise;
}