.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`,
};

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;

GitFOSS • v0.2.0 (#421408f) • MIT License