diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index de9af2919..004c7b7a7 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -948,7 +948,7 @@ export default class Reactor { shutdown() { this._isShutdown = true; - this._ws.close(); + this._ws?.close(); } /** diff --git a/client/www/components/clientOnlyPage.tsx b/client/www/components/clientOnlyPage.tsx new file mode 100644 index 000000000..a980026cc --- /dev/null +++ b/client/www/components/clientOnlyPage.tsx @@ -0,0 +1,29 @@ +import { useIsHydrated } from '@/lib/hooks/useIsHydrated'; +import { NextRouter, useRouter } from 'next/router'; + +export function useReadyRouter(): Omit { + const router = useRouter(); + if (!router.isReady) { + throw new Error( + 'Router wasn not ready. Make sure to call this hook somewhere inside an `asClientOnlyPage` component', + ); + } + return router; +} + +export function asClientOnlyPage( + Component: React.ComponentType, +) { + return function ClientOnlyPage(props: Props) { + const isHydrated = useIsHydrated(); + const router = useRouter(); + if (!isHydrated) { + return null; + } + if (!router.isReady) { + return null; + } + + return ; + }; +} diff --git a/client/www/lib/hooks/useDashFetch.tsx b/client/www/lib/hooks/useDashFetch.tsx new file mode 100644 index 000000000..e4e38ccb6 --- /dev/null +++ b/client/www/lib/hooks/useDashFetch.tsx @@ -0,0 +1,66 @@ +import { useContext, useEffect, useMemo } from 'react'; +import { APIResponse, useAuthedFetch } from '../auth'; +import config from '../config'; +import { DashResponse } from '../types'; +import { TokenContext } from '../contexts'; +import useLocalStorage from './useLocalStorage'; +import { subDays } from 'date-fns'; + +// FNV-1a algorithm +function stringHash(input: string) { + let hash = 0x811c9dc5; // FNV offset basis (32 bit) + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash += + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + hash = hash >>> 0; // Convert to unsigned 32-bit after each iteration + } + return hash.toString(16); +} + +type CachedEntry = { + item: T; + updatedAt: number; +}; + +export type CachedAPIResponse = APIResponse & { fromCache?: boolean }; + +export function useDashFetch(): CachedAPIResponse { + const now = new Date(); + const oneWeekAgo = subDays(now, 7).getTime(); + + const token = useContext(TokenContext); + const [cachedEntry, setCachedEntry] = useLocalStorage< + CachedEntry | undefined + >(`dash:${stringHash(token || 'unk')}`, undefined); + + const item = + cachedEntry && cachedEntry.updatedAt > oneWeekAgo + ? cachedEntry.item + : undefined; + + const dashResponse = useAuthedFetch(`${config.apiURI}/dash`); + useEffect(() => { + if (dashResponse.data) { + setCachedEntry({ + item: dashResponse.data, + updatedAt: Date.now(), + }); + return; + } + if (dashResponse.error) { + setCachedEntry(undefined); + } + }, [dashResponse.data, dashResponse.error]); + + if (dashResponse.isLoading && cachedEntry && item) { + return { + ...dashResponse, + isLoading: false, + data: item, + fromCache: true, + }; + } + + return dashResponse; +} diff --git a/client/www/pages/_devtool/index.tsx b/client/www/pages/_devtool/index.tsx index 9f09803f6..b699d8ed0 100644 --- a/client/www/pages/_devtool/index.tsx +++ b/client/www/pages/_devtool/index.tsx @@ -26,18 +26,126 @@ import { import Auth from '@/components/dash/Auth'; import { isMinRole } from '@/pages/dash/index'; import { TrashIcon, XMarkIcon } from '@heroicons/react/24/solid'; +import { CachedAPIResponse, useDashFetch } from '@/lib/hooks/useDashFetch'; +import { asClientOnlyPage, useReadyRouter } from '@/components/clientOnlyPage'; type InstantReactClient = ReturnType; -export default function Devtool() { - const router = useRouter(); +const Devtool = asClientOnlyPage(DevtoolComp); +export default Devtool; + +function DevtoolComp() { + const router = useReadyRouter(); const authToken = useAuthToken(); - const isHydrated = useIsHydrated(); - const dashResponse = useTokenFetch( - `${config.apiURI}/dash`, - authToken, + if (!authToken) { + return ( + + + + + } + /> + + ); + } + + const appId = router.query.appId as string | undefined; + + if (!appId) { + return ( + +
+
+ No app id provided +

+ We didn't receive an app ID. Double check that you passed an{' '} + appId paramater in your init. If you + continue experiencing issues, ping us on Discord. +

+ +
+
+
+ ); + } + + return ( + + + ); - const appId = router.query.appId as string; +} + +function DevtoolAuthorized({ appId }: { appId: string }) { + const dashResponse = useDashFetch(); + + if (dashResponse.isLoading) { + return null; + } + + if (dashResponse.error) { + const message = dashResponse.error.message; + return ( + +
+
+ 🤕 Failed to load your app + {message ? ( +
+
+ {message} +
+
+ ) : null} +

+ We had some trouble loading your app. Please ping us on{' '} + + discord + {' '} + with details. +

+ +
+
+
+ ); + } + + return ; +} + +function DevtoolWithData({ + dashResponse, + appId, +}: { + dashResponse: CachedAPIResponse; + appId: string; +}) { const app = dashResponse.data?.apps?.find((a) => a.id === appId); const [tab, setTab] = useState('explorer'); const [connection, setConnection] = useState< @@ -80,8 +188,10 @@ export default function Devtool() { return () => removeEventListener('keydown', onKeyDown); }, []); + const adminToken = app?.admin_token; + useEffect(() => { - if (!app) return; + if (!appId || !adminToken) return; if (typeof window === 'undefined') return; try { @@ -90,9 +200,10 @@ export default function Devtool() { apiURI: config.apiURI, websocketURI: config.websocketURI, // @ts-expect-error - __adminToken: app?.admin_token, + __adminToken: adminToken, devtool: false, }); + setConnection({ state: 'ready', db }); return () => { @@ -102,103 +213,14 @@ export default function Devtool() { const message = (error as Error).message; setConnection({ state: 'error', errorMessage: message }); } - }, [router.isReady, app]); + }, [appId, adminToken]); - if (!isHydrated) { + if (!app && dashResponse.fromCache) { + // We couldn't find this app. Perhaps the cache is stale. Let's + // wait for the fresh data. return null; } - if (!authToken) { - return ( - - - - - } - /> - - ); - } - - if (dashResponse.isLoading) { - return ( -
- Loading... -
- ); - } - - if (dashResponse.error) { - const message = dashResponse.error.message; - return ( - -
-
- 🤕 Failed to load your app - {message ? ( -
-
- {message} -
-
- ) : null} -

- We had some trouble loading your app. Please ping us on{' '} - - discord - {' '} - with details. -

- -
-
-
- ); - } - - if (!appId) { - return ( - -
-
- No app id provided -

- We didn't receive an app ID. Double check that you passed an{' '} - appId paramater in your init. If you - continue experiencing issues, ping us on Discord. -

- -
-
-
- ); - } - if (!app) { const user = dashResponse.data?.user; return ( @@ -255,8 +277,8 @@ export default function Devtool() { ) : null}

- We had some trouble connect to Instant's backend. Please ping us - on{' '} + We had some trouble connecting to Instant's backend. Please ping + us on{' '} - -

-
- -
- { - setTab(t.id); - }} - /> -
- {tab === 'explorer' ? ( - - ) : tab === 'sandbox' ? ( -
- -
- ) : tab === 'admin' ? ( -
- -
- ) : tab === 'help' ? ( -
- - -
- ) : null} -
+
+
+
- + { + setTab(t.id); + }} + /> +
+ {tab === 'explorer' ? ( + + ) : tab === 'sandbox' ? ( +
+ +
+ ) : tab === 'admin' ? ( +
+ +
+ ) : tab === 'help' ? ( +
+ + +
+ ) : null} +
+
); } diff --git a/client/www/pages/dash/index.tsx b/client/www/pages/dash/index.tsx index 5b9af36eb..cf6ef3b0a 100644 --- a/client/www/pages/dash/index.tsx +++ b/client/www/pages/dash/index.tsx @@ -15,7 +15,6 @@ import { APIResponse, signOut, useAuthToken, - useAuthedFetch, claimTicket, voidTicket, } from '@/lib/auth'; @@ -56,8 +55,9 @@ import { Sandbox } from '@/components/dash/Sandbox'; import { StorageTab } from '@/components/dash/Storage'; import PersonalAccessTokensScreen from '@/components/dash/PersonalAccessTokensScreen'; import { useForm } from '@/lib/hooks/useForm'; -import { useSchemaQuery } from '@/lib/hooks/explorer'; import useLocalStorage from '@/lib/hooks/useLocalStorage'; +import { useDashFetch } from '@/lib/hooks/useDashFetch'; +import { asClientOnlyPage, useReadyRouter } from '@/components/clientOnlyPage'; // (XXX): we may want to expose this underlying type type InstantReactClient = ReturnType; @@ -110,15 +110,20 @@ export function isMinRole(minRole: Role, role: Role) { // COMPONENTS -export default function DashV2() { +const Dash = asClientOnlyPage(DashV2); + +export default Dash; + +function DashV2() { const token = useAuthToken(); - const isHydrated = useIsHydrated(); - const router = useRouter(); + const readyRouter = useRouter(); const cliAuthCompleteDialog = useDialog(); const [loginTicket, setLoginTicket] = useState(); - const cliNormalTicket = router.query.ticket as string | undefined; - const cliOauthTicket = router.query[cliOauthParamName] as string | undefined; + const cliNormalTicket = readyRouter.query.ticket as string | undefined; + const cliOauthTicket = readyRouter.query[cliOauthParamName] as + | string + | undefined; const cliTicket = cliNormalTicket || cliOauthTicket; useEffect(() => { if (cliTicket) setLoginTicket(cliTicket); @@ -139,10 +144,6 @@ export default function DashV2() { } } - if (!isHydrated) { - return null; - } - if (!token) { return ( (null); - const dashResponse = useAuthedFetch(`${config.apiURI}/dash`); + const dashResponse = useDashFetch(); useEffect(() => { if (!token) return; @@ -306,7 +307,6 @@ function Dashboard() { const showInvitesOnboarding = hasInvites && !dashResponse.data?.apps?.length; useEffect(() => { - if (!router.isReady) return; if (screen && screen !== 'main') return; if (hasInvites) { nav({ @@ -338,7 +338,7 @@ function Dashboard() { }); setLocal('dash_app_id', defaultAppId); - }, [router.isReady, dashResponse.data]); + }, [dashResponse.data]); useEffect(() => { if (!app) return; @@ -356,7 +356,7 @@ function Dashboard() { return () => { db._core.shutdown(); }; - }, [router.isReady, app?.id, app?.admin_token]); + }, [app?.id, app?.admin_token]); function nav(q: { s: string; app?: string; t?: string }) { if (q.app) setLocal('dash_app_id', q.app);