GitFOSS
feat: bring back instant router (AppRouter island)
+ 359
- 69
Previously it was provided by fastify-stream-react-views but then browser decided to make some APIs read-only, breaking it. This is a rewrite as an island so that it runs.

@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1712449693623,
+  "_generatedAtUnix": 1712535226331,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "assets": {

...
@@ -15,6 +15,12 @@
     }
   },
   "islands": {
+    "AppRouter": {
+      "hash": "4bea9624c055922280782592f3d728874a47b1f2",
+      "pathSource": "./app/islands/AppRouter.tsx",
+      "pathBundle": "./public/.islands/AppRouter.bundle.js",
+      "pathSourceMap": "./public/.islands/AppRouter.bundle.js.map"
+    },
     "Code": {
       "hash": "4b585b9dc0f2dcd0dacbfed17fcf8c0c4f40e0e8",
       "pathSource": "./app/islands/Code.tsx",

...
@@ -22,13 +28,13 @@
       "pathSourceMap": "./public/.islands/Code.bundle.js.map"
     },
     "InstantRouterIndicator": {
-      "hash": "7fd24eef65b3e804b4497a26dc2e96c2af054d20",
+      "hash": "7950e8886858e70a4b3616bc551c30a2ca9744bd",
       "pathSource": "./app/islands/InstantRouterIndicator.tsx",
       "pathBundle": "./public/.islands/InstantRouterIndicator.bundle.js",
       "pathSourceMap": "./public/.islands/InstantRouterIndicator.bundle.js.map"
     },
     "PullRequestSourceSelect": {
-      "hash": "6d0ed5a1d2dfc8841961827e69242458e2ff32fb",
+      "hash": "e9a167828cf774db2391b674d5ef5eaba8b3f8cd",
       "pathSource": "./app/islands/PullRequestSourceSelect.tsx",
       "pathBundle": "./public/.islands/PullRequestSourceSelect.bundle.js",
       "pathSourceMap": "./public/.islands/PullRequestSourceSelect.bundle.js.map"

...
@@ -58,7 +64,7 @@
       "pathSourceMap": "./public/.islands/RepositoryFilesDiffsList.bundle.js.map"
     },
     "RepositoryForkForm": {
-      "hash": "a0ea58d89401471e74e9f1fb60f10e70e187fd1b",
+      "hash": "95d83a51500d8a10819df92c81e16e4891640a9b",
       "pathSource": "./app/islands/RepositoryForkForm.tsx",
       "pathBundle": "./public/.islands/RepositoryForkForm.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryForkForm.bundle.js.map"

...
@@ -76,13 +82,13 @@
       "pathSourceMap": "./public/.islands/RepositoryInitialSetup.bundle.js.map"
     },
     "RepositoryPullRequestCreateForm": {
-      "hash": "5af2ef37ec2c003ff238db565143d0f522dfbd32",
+      "hash": "47bb70e8c7ffbc9b2b05f85f2955deda92136951",
       "pathSource": "./app/islands/RepositoryPullRequestCreateForm.tsx",
       "pathBundle": "./public/.islands/RepositoryPullRequestCreateForm.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryPullRequestCreateForm.bundle.js.map"
     },
     "RepositoryTreeView": {
-      "hash": "80608f2940dad21e9e89f6534f9d1a38be8db277",
+      "hash": "6ae32b3d9d68df79fa2479c4b22c1da948320bda",
       "pathSource": "./app/islands/RepositoryTreeView.tsx",
       "pathBundle": "./public/.islands/RepositoryTreeView.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryTreeView.bundle.js.map"

...
@@ -96,7 +102,7 @@
   },
   "views": {
     "HomeView": {
-      "hash": "14f6b7a4360a42125e6ba984ad7f4c5bc89b9f64",
+      "hash": "b5e4852402693d127221441f6075aea131a5ef61",
       "pathSource": "./app/views/HomeView.tsx"
     },
     "InternalErrorView": {

...
@@ -104,11 +110,11 @@
       "pathSource": "./app/views/InternalErrorView.tsx"
     },
     "LoginView": {
-      "hash": "a9c2a6fa3392cfb3ff7efab74c7b694356c8ec1d",
+      "hash": "367f96c1512dd718dcd3380abe84ea44fab1960f",
       "pathSource": "./app/views/auth/LoginView.tsx"
     },
     "RegisterView": {
-      "hash": "b8ae30863b43ab5f1abf0cc8eef267acc661344d",
+      "hash": "482ee764c0886fcca1cd7ec010d66e8244f71e9a",
       "pathSource": "./app/views/auth/RegisterView.tsx"
     },
     "OrganizationDetailsView": {

...
@@ -116,7 +122,7 @@
       "pathSource": "./app/views/organization/OrganizationDetailsView.tsx"
     },
     "RepositoryBrowserView": {
-      "hash": "9a451556ce04140117bdfe1c01f8289c8d06981a",
+      "hash": "250df130001f1a23730fe44eee6371a05fdd2025",
       "pathSource": "./app/views/repository/RepositoryBrowserView.tsx"
     },
     "RepositoryCommitsLogView": {

...
@@ -132,7 +138,7 @@
       "pathSource": "./app/views/repository/RepositoryCreateView.tsx"
     },
     "RepositoryDetailsView": {
-      "hash": "1b405ef6bb7ac0d8471f2937a1a0424a21de9d67",
+      "hash": "8f1460767fe170aa825ab4dac93d63aa6bf59b1c",
       "pathSource": "./app/views/repository/RepositoryDetailsView.tsx"
     },
     "RepositoryExploreView": {

...
@@ -140,11 +146,11 @@
       "pathSource": "./app/views/repository/RepositoryExploreView.tsx"
     },
     "RepositoryForkView": {
-      "hash": "035ad04f3eba7a86f398515b43609f5506e28792",
+      "hash": "63f15218c58170202e5ca5ba21adb1df9b9811fd",
       "pathSource": "./app/views/repository/RepositoryForkView.tsx"
     },
     "RepositoryShowObjectView": {
-      "hash": "cf63c6b6caaac822f36ddf17e663c705b10b4aa6",
+      "hash": "4e3a98afeeea8ac3d8333c8fa114ef6846482c2c",
       "pathSource": "./app/views/repository/RepositoryShowObjectView.tsx"
     },
     "RepositoryPullRequestCreateView": {

...
@@ -152,7 +158,7 @@
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestCreateView.tsx"
     },
     "RepositoryPullRequestDetailsView": {
-      "hash": "ed4271e3cbb04e19dbf6f622e19149068f2f25cb",
+      "hash": "cceff37024b4ef5e07d6834d40fde849bc53dc04",
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx"
     },
     "RepositoryPullRequestsView": {

...
@@ -164,7 +170,7 @@
       "pathSource": "./app/views/user/UserDashboardView.tsx"
     },
     "UserDetailsView": {
-      "hash": "ce954e6fe9d3b11e5f822c6ba426df7ea2501758",
+      "hash": "042857aba2aef94f81ce321b479bbb8109a78c6b",
       "pathSource": "./app/views/user/UserDetailsView.tsx"
     }
   }

@@ -1,5 +1,6 @@
 import React, { PropsWithChildren } from "react";
-import RootAppRouter from "./routes";
+
+import AppRouter from "./islands/AppRouter";
 
 // import { AppRoute } from "./routes.defs";
 // import HomeView from "./views/HomeView";

...
@@ -7,7 +8,9 @@ import RootAppRouter from "./routes";
 export function App({ children }: PropsWithChildren<{}>) {
   return (
     <>
-      <RootAppRouter server={null as any} />
+      <div data-islandid={`${AppRouter.name}$$0`}>
+        <AppRouter />
+      </div>
       {children}
     </>
   );

app/components/Layout.tsx
@@ -14,7 +14,7 @@ interface LayoutProps extends CommonViewProps {
 }
 
 const BRANDLINE_HEIGHT = 4;
-const HEADER_HEIGHT = 72;
+const HEADER_HEIGHT = 64;
 
 function removeCommentsAndSpacing(str = "") {
   return str.replace(/\/\*.*\*\//g, " ").replace(/\s+/g, " ");

new file
app/islands/AppRouter.tsx
@@ -0,0 +1,281 @@
+// 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;

app/islands/InstantRouterIndicator.tsx
@@ -6,7 +6,7 @@ import styled, { css, keyframes } from "styled-components";
 
 interface InstantRouterIndicatorProps {}
 
-export const ClientSideRouterEventPrefix = `instantroute_`;
+export const ClientSideRouterEventPrefix = `app_router_`;
 export const ClientSideRouterEvents = {
   LOADING: `${ClientSideRouterEventPrefix}loading`,
   LOADED: `${ClientSideRouterEventPrefix}loaded`,

app/views/auth/LoginView.tsx
@@ -25,7 +25,7 @@ const LoginView: ReactView<LoginViewProps> = ({
         style={{
           justifyContent: "center",
           alignItems: "center",
-          minHeight: `calc(100vh - 72px)`,
+          minHeight: `calc(100vh - 64px)`,
         }}
       >
         {errorMessage && (

app/views/auth/RegisterView.tsx
@@ -26,7 +26,7 @@ const RegisterView: ReactView<RegisterViewProps> = ({
         style={{
           justifyContent: "center",
           alignItems: "center",
-          minHeight: `calc(100vh - 72px)`,
+          minHeight: `calc(100vh - 64px)`,
         }}
       >
         {errorMessage && (

app/views/repository/RepositoryBrowserView.tsx
@@ -97,52 +97,6 @@ const RepositoryBrowserView: ReactView<RepositoryBrowserViewProps> = ({
             }}
             themeScheme={commonProps.themeScheme}
           >
-            <Grid.Row
-              nowrap
-              gap={12}
-              alignItems={"center"}
-              style={{ width: "100%" }}
-            >
-              <div
-                style={{
-                  display: "flex",
-                  alignItems: "center",
-                  justifyContent: "center",
-                  height: 24,
-                  width: 24,
-                  background: linguistInfos.color,
-                  borderRadius: 4,
-                  color: "white",
-                  fontSize:
-                    linguistInfos.extensions.length > 0 &&
-                    linguistInfos.extensions[0].slice(1).length <= 4
-                      ? linguistInfos.extensions[0].slice(1).length <= 2
-                        ? 12
-                        : 9
-                      : 6,
-                }}
-              >
-                {linguistInfos.extensions.length > 0
-                  ? linguistInfos.extensions[0]
-                  : ""}
-              </div>
-              <div>
-                <strong>
-                  {linguistInfos.languageDisplayName ||
-                    `lang: ${linguistInfos.language}`}
-                </strong>
-              </div>
-              <div>
-                <span>({linguistInfos.mimeType})</span>
-              </div>
-            </Grid.Row>
-            <div
-              style={{
-                height: 1,
-                margin: "8px 0",
-                background: NamedColors.BORDER_CARD[commonProps.themeScheme],
-              }}
-            />
             <Grid.Row fluid nowrap alignItems={"center"} gap={8}>
               <Select
                 themeScheme={commonProps.themeScheme}

...
@@ -219,6 +173,52 @@ const RepositoryBrowserView: ReactView<RepositoryBrowserViewProps> = ({
                   )
               )}
             </Grid.Row>
+            <div
+              style={{
+                height: 1,
+                margin: "8px 0",
+                background: NamedColors.BORDER_CARD[commonProps.themeScheme],
+              }}
+            />
+            <Grid.Row
+              nowrap
+              gap={12}
+              alignItems={"center"}
+              style={{ width: "100%" }}
+            >
+              <div
+                style={{
+                  display: "flex",
+                  alignItems: "center",
+                  justifyContent: "center",
+                  height: 24,
+                  width: 24,
+                  background: linguistInfos.color,
+                  borderRadius: 4,
+                  color: "white",
+                  fontSize:
+                    linguistInfos.extensions.length > 0 &&
+                    linguistInfos.extensions[0].slice(1).length <= 4
+                      ? linguistInfos.extensions[0].slice(1).length <= 2
+                        ? 12
+                        : 9
+                      : 6,
+                }}
+              >
+                {linguistInfos.extensions.length > 0
+                  ? linguistInfos.extensions[0]
+                  : ""}
+              </div>
+              <div>
+                <strong>
+                  {linguistInfos.languageDisplayName ||
+                    `lang: ${linguistInfos.language}`}
+                </strong>
+              </div>
+              <div>
+                <span>({linguistInfos.mimeType})</span>
+              </div>
+            </Grid.Row>
           </Card>
           {linguistInfos.type === "image" ? (
             <Card

app/views/repository/RepositoryDetailsView.tsx
@@ -148,7 +148,7 @@ const RepositoryDetailsView: ReactView<RepositoryDetailsViewProps> = ({
                 </Card>
                 <Card
                   data-islandid={`${RepositoryTreeView.name}$$0`}
-                  style={{ width: "100%", marginTop: 16, padding: 0 }}
+                  style={{ width: "100%", marginTop: 8, padding: 0 }}
                   themeScheme={commonProps.themeScheme}
                 >
                   <RepositoryTreeView

public/instant-router.js.old
@@ -400,7 +400,7 @@ function clientSideRouter() {
 
 __name(clientSideRouter, "clientSideRouter");
 
-var ClientSideRouterEventPrefix = `instantroute_`;
+var ClientSideRouterEventPrefix = `app_router_`;
 var ClientSideRouterEvents = {
   LOADING: `${ClientSideRouterEventPrefix}loading`,
   LOADED: `${ClientSideRouterEventPrefix}loaded`,