.ts
TypeScript
(application/typescript)
// 1st-party
import { ReactIsland } from "@ethicdevs/react-monolith";
// 3rd-party
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`,
};

const AppRouter: ReactIsland = () => {
  const domParserRef = useRef(
    typeof DOMParser !== "undefined" ? new DOMParser() : null
  );
  const domParser = domParserRef.current!;

  const loadUrlRef = useRef<string | null>(null);

  const evalPageScripts = () => {
    const scripts = document.body.querySelectorAll("script");
    console.log("scripts:", scripts);

    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;
        }
        newScript.textContent = script.textContent;

        const parentNode = script.parentNode;

        if (parentNode) {
          script.parentNode.removeChild(script);
          parentNode.appendChild(newScript);
        }
      }
    });
  };

  useEffect(() => {
    function run() {
      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
      ): 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
            );

            const res = await fetch(url.href, {
              redirect: "follow",
              headers: {
                accept: "text/html",
                "accept-charset": "utf8",
                "x-requested-with": "XMLHttpRequest",
              },
            });
            const html = await res.text();
            const targetUrl = new URL(res.url);

            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;
          }
        }
      }

      document.addEventListener("mousedown", (e) => {
        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);
            doNavigate = () => navigate(url);
          }
          return false;
        }

        return false;
      });

      window.addEventListener("popstate", (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;
          });
        }
      });

      // window.addEventListener("replaceState", () =>
      //   console.log("replaceState event")
      // );

      // window.addEventListener("pushState", () =>
      //   console.log("pushState event")
      // );
    }

    run();
  }, []);

  return <></>;
};

export default AppRouter;