import { ReactIsland } from "@ethicdevs/react-monolith";
import React, { useEffect, useRef } from "react";
export const AppRouterEventPrefix = `app_router_`;
export const AppRouterEvent = {
LOADING: `${AppRouterEventPrefix}loading`,
LOADED: `${AppRouterEventPrefix}loaded`,
LOAD_ERROR: `${AppRouterEventPrefix}load_error`,
NAVIGATING: `${AppRouterEventPrefix}navigating`,
NAVIGATED: `${AppRouterEventPrefix}navigated`,
NAVIGATION_ERROR: `${AppRouterEventPrefix}navigation_error`,
};
function evalPageScripts() {
const scripts = document.body.querySelectorAll("script");
scripts.forEach((script) => {
if (script.type == "module") {
const newScript = document.createElement("script");
newScript.type = script.type;
if (script.src != null && script.src.trim() !== "") {
newScript.src = script.src;
}
if (script.textContent != null && script.textContent.trim() !== "") {
newScript.textContent = script.textContent;
}
const parentNode = script.parentNode;
if (parentNode) {
script.parentNode.removeChild(script);
parentNode.appendChild(newScript);
}
}
});
}
const AppRouter: ReactIsland = () => {
const domParserRef = useRef(
typeof DOMParser !== "undefined" ? new DOMParser() : null
);
const domParser = domParserRef.current!;
const loadUrlRef = useRef<string | null>(null);
useEffect(() => {
function start() {
const { pushState, replaceState } = window.history;
window.history.pushState = function (...args) {
pushState.apply(window.history, args);
window.dispatchEvent(new Event("pushState"));
};
window.history.replaceState = function (...args) {
replaceState.apply(window.history, args);
window.dispatchEvent(new Event("replaceState"));
};
async function navigate(
url: URL,
pushState: boolean = true,
prefetchHtml?: string,
prefetchTargetUrl?: string
): Promise<void> {
if (document.location.origin == url.origin) {
if (loadUrlRef.current != null && url.href === loadUrlRef.current) {
return;
}
loadUrlRef.current = url.href;
try {
document.dispatchEvent(
new CustomEvent(AppRouterEvent.LOADING, {
detail: { request: { url: url.href } },
})
);
console.log(
`[${new Date().getTime()}][AppRouter] Navigate: ${url.href}`
);
const currentUrl = new URL(document.location.href);
window.history.replaceState(
{
href: currentUrl.href,
scrollTop: document.documentElement.scrollTop,
},
"",
currentUrl
);
let html;
let targetUrl: URL;
if (prefetchTargetUrl != null) {
targetUrl = new URL(prefetchTargetUrl);
}
if (prefetchHtml != null) {
html = prefetchHtml;
} else {
const res = await fetch(url.href, {
headers: {
accept: "text/html",
"accept-charset": "utf8",
"x-requested-with": "XMLHttpRequest",
},
});
html = await res.text();
targetUrl = new URL(res.url);
}
targetUrl = targetUrl!;
loadUrlRef.current = targetUrl.href;
document.dispatchEvent(
new CustomEvent(AppRouterEvent.LOADED, {
detail: {
request: { url: targetUrl.href },
response: { html },
},
})
);
document.dispatchEvent(
new CustomEvent(AppRouterEvent.NAVIGATING, {
detail: {
request: { url: targetUrl.href },
response: { html },
},
})
);
const targetDoc = domParser.parseFromString(html, "text/html");
const {
body: targetBody,
head: targetHead,
title: targetTitle,
} = targetDoc;
document.title = targetTitle;
document.head.innerHTML =
targetHead.innerHTML || document.head.innerHTML;
document.body.innerHTML =
targetBody.innerHTML || document.body.innerHTML;
if (pushState) {
if (
document.scrollingElement != null &&
targetUrl.href !== currentUrl.href
) {
document.scrollingElement.scrollTo({
behavior: "auto",
top: 0,
});
}
window.history.pushState(
{ href: targetUrl.href, scrollTop: 0 },
"",
targetUrl
);
}
evalPageScripts;
document.dispatchEvent(
new CustomEvent(AppRouterEvent.NAVIGATED, {
detail: {
request: { url: targetUrl.href },
response: { html },
},
})
);
} catch (err) {
const error = err as Error;
document.dispatchEvent(
new CustomEvent(AppRouterEvent.LOAD_ERROR, {
detail: {
request: { url: url.href },
response: { error },
},
})
);
} finally {
loadUrlRef.current = null;
}
}
}
async function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return false;
const target = e.target as HTMLElement;
if (["a"].includes(target.tagName.toLowerCase())) {
const anchor = target as HTMLAnchorElement;
const url = new URL(anchor.href);
const targetHash = url.hash;
const isHashChange = !!(
targetHash != null &&
targetHash.trim() !== "" &&
targetHash.startsWith("#")
);
if (isHashChange) {
const hashEl = document.querySelector(targetHash);
if (hashEl != null) {
e.preventDefault();
hashEl.scrollIntoView({
behavior: "smooth",
});
window.location.hash = targetHash;
}
return false;
}
if (document.location.origin == url.origin) {
let doNavigate: null | (() => void) = null;
let pollWaitFetchDoneIntervalId: null | NodeJS.Timer = null;
const navigateOrWait = () => {
if (doNavigate != null) {
(doNavigate as any)();
if (pollWaitFetchDoneIntervalId != null) {
clearTimeout(pollWaitFetchDoneIntervalId);
pollWaitFetchDoneIntervalId = null;
}
if (target != null && onClickHandler != null) {
target.removeEventListener("click", onClickHandler);
onClickHandler = null;
}
} else {
pollWaitFetchDoneIntervalId = setTimeout(() => {
if (pollWaitFetchDoneIntervalId != null) {
clearTimeout(pollWaitFetchDoneIntervalId);
pollWaitFetchDoneIntervalId = null;
}
navigateOrWait();
}, 10);
}
};
let onClickHandler: ((subEv: MouseEvent) => boolean) | null = (
subEv: MouseEvent
) => {
subEv.preventDefault();
navigateOrWait();
return true;
};
target.addEventListener("click", onClickHandler);
const res = await fetch(url.href, {
headers: {
accept: "text/html",
"accept-charset": "utf8",
"x-requested-with": "XMLHttpRequest",
},
});
const html = await res.text();
doNavigate = () => navigate(url, true, html, res.url);
}
return false;
}
return false;
}
async function onFormSubmit(e: SubmitEvent) {
const target = e.target as HTMLElement;
if (["form"].includes(target.tagName.toLowerCase())) {
e.preventDefault();
const form = target as HTMLFormElement;
const method = form.method;
const url = new URL(form.action);
const body = new URLSearchParams(new FormData(form) as {}).toString();
const res = await fetch(url.href, {
method: method,
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "text/html",
"accept-charset": "utf8",
"x-requested-with": "XMLHttpRequest",
},
body,
});
const html = await res.text();
navigate(url, true, html, res.url);
}
}
function onPopState(e: PopStateEvent) {
const url = new URL(e.state.href);
if (document.location.origin == url.origin) {
navigate(url, false).then(() => {
document.documentElement.scrollTop = e.state.scrollTop || 0;
});
}
}
document.addEventListener("submit", onFormSubmit);
document.addEventListener("mousedown", onMouseDown);
window.addEventListener("popstate", onPopState);
return () => {
document.removeEventListener("submit", onFormSubmit);
document.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("popstate", onPopState);
};
}
start();
}, []);
return <></>;
};
export default AppRouter;