feat: bring back instant router (AppRouter island)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}
</>
);
@@ -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, " ");
@@ -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;
@@ -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`,
@@ -25,7 +25,7 @@ const LoginView: ReactView<LoginViewProps> = ({
style={{
justifyContent: "center",
alignItems: "center",
- minHeight: `calc(100vh - 72px)`,
+ minHeight: `calc(100vh - 64px)`,
}}
>
{errorMessage && (
@@ -26,7 +26,7 @@ const RegisterView: ReactView<RegisterViewProps> = ({
style={{
justifyContent: "center",
alignItems: "center",
- minHeight: `calc(100vh - 72px)`,
+ minHeight: `calc(100vh - 64px)`,
}}
>
{errorMessage && (
@@ -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
@@ -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
@@ -400,7 +400,7 @@ function clientSideRouter() {
__name(clientSideRouter, "clientSideRouter");
-var ClientSideRouterEventPrefix = `instantroute_`;
+var ClientSideRouterEventPrefix = `app_router_`;
var ClientSideRouterEvents = {
LOADING: `${ClientSideRouterEventPrefix}loading`,
LOADED: `${ClientSideRouterEventPrefix}loaded`,