diff --git a/apps/dashboard/src/lib/prefetch/hook-prefetcher.tsx b/apps/dashboard/src/lib/prefetch/hook-prefetcher.tsx index 2558de7ac..abdfd2c02 100644 --- a/apps/dashboard/src/lib/prefetch/hook-prefetcher.tsx +++ b/apps/dashboard/src/lib/prefetch/hook-prefetcher.tsx @@ -3,15 +3,14 @@ import { AsyncCache } from "@stackframe/stack-shared/dist/utils/caches"; import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { getGlobal, setGlobal } from "@stackframe/stack-shared/dist/utils/globals"; -import { neverResolve } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { ErrorBoundary } from "next/dist/client/components/error-boundary"; -import { Suspense, use, useEffect } from "react"; +import { Suspense, useEffect } from "react"; -let isPrefetching = false; +let isPrefetchingCounter = 0; let hasSetupHookPrefetcher = false; -type HookPrefetcherCallback = () => void; +export type HookPrefetcherCallback = () => HookPrefetcherCallback[] | void; export function HookPrefetcher(props: { callbacks: HookPrefetcherCallback[], @@ -24,7 +23,7 @@ export function HookPrefetcher(props: { ...(getGlobal("use-async-cache-execution-hooks") ?? []), (options: { caller: string, dependencies: any[], cache: AsyncCache }) => { if (options.cache.isDirty(options.dependencies)) { - if (isPrefetching) { + if (isPrefetchingCounter > 0) { // all good, continue if (process.env.NODE_ENV === "development") { console.info(`Prefetching ${options.caller}...`); @@ -41,28 +40,37 @@ export function HookPrefetcher(props: { ]); }, []); - const components = props.callbacks.map((callback, i) => () => { - isPrefetching = true; - try { - callback(); - return use(neverResolve()); - } finally { - isPrefetching = false; - } - } ); + const PrefetchMany = (props: { callbacks: HookPrefetcherCallback[] }): React.ReactNode => { + return <> + {props.callbacks.map((callback, i) => { + const Component = () => { + isPrefetchingCounter++; + try { + const componentCallbacks = callback(); + if (componentCallbacks) { + return ; + } + return null; + } finally { + isPrefetchingCounter--; + } + }; - return <> - {components.map((Component, i) => ( - - - - - - ))} - ; + return ( + + + + + + ); + })} + ; + }; + + return ; } function HookPrefetcherErrorComponent(props: { error: Error }) { diff --git a/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx b/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx index c1c427a74..aa6f76915 100644 --- a/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx +++ b/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx @@ -5,153 +5,230 @@ import { useUser } from "@stackframe/stack"; import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; import { createCachedRegex } from "@stackframe/stack-shared/dist/utils/regex"; import { useEffect, useState } from "react"; -import { HookPrefetcher } from "./hook-prefetcher"; +import { HookPrefetcher, HookPrefetcherCallback } from "./hook-prefetcher"; // note that URL prefetchers are allowed to return early before execution of all hooks (but not call hook conditionally beyond that) // this is because we suspend the component -const urlPrefetchers: Record void)[]> = { +const urlPrefetchers: Record void | HookPrefetcherCallback[])[]> = { "/projects/*": [ // TODO: we currently don't prefetch metrics as they are pretty slow to fetch // ([_, projectId]) => (useAdminApp(projectId) as any)[stackAppInternalsSymbol].useMetrics(false), ], "/projects/*/**": [ - ([_, projectId]) => useAdminApp(projectId).useProject().useConfig(), + ([_, projectId]) => { + useAdminApp(projectId).useProject().useConfig(); + }, ], "/projects/*/users": [ // TODO: we currently don't prefetch metrics as they are pretty slow to fetch // ([_, projectId]) => (useAdminApp(projectId) as any)[stackAppInternalsSymbol].useMetrics(), - ([_, projectId]) => useAdminApp(projectId).useUsers({ limit: 1 }), - ([_, projectId]) => useAdminApp(projectId).useUsers({ - limit: 10, - orderBy: "signedUpAt", - desc: true, - includeAnonymous: false, - }), + ([_, projectId]) => { + useAdminApp(projectId).useUsers({ limit: 1 }); + }, + ([_, projectId]) => { + useAdminApp(projectId).useUsers({ + limit: 10, + orderBy: "signedUpAt", + desc: true, + includeAnonymous: false, + }); + }, ], "/projects/*/users/*": [ - ([_, projectId, userId]) => useAdminApp(projectId).useUser(userId), ([_, projectId, userId]) => { const user = useAdminApp(projectId).useUser(userId); - user?.useContactChannels(); - }, - ([_, projectId, userId]) => { - const user = useAdminApp(projectId).useUser(userId); - user?.useTeams(); - }, - ([_, projectId, userId]) => { - const user = useAdminApp(projectId).useUser(userId); - user?.useOAuthProviders(); + if (user) { + return [ + () => { + user.useContactChannels(); + }, + () => { + user.useTeams(); + }, + () => { + user.useOAuthProviders(); + }, + ]; + } }, ], "/projects/*/team-settings": [ - ([_, projectId]) => useAdminApp(projectId).useTeamPermissionDefinitions(), + ([_, projectId]) => { + useAdminApp(projectId).useTeamPermissionDefinitions(); + }, ], "/projects/*/team-permissions": [ - ([_, projectId]) => useAdminApp(projectId).useTeamPermissionDefinitions(), - ([_, projectId]) => useAdminApp(projectId).useProjectPermissionDefinitions(), + ([_, projectId]) => { + useAdminApp(projectId).useTeamPermissionDefinitions(); + }, + ([_, projectId]) => { + useAdminApp(projectId).useProjectPermissionDefinitions(); + }, ], "/projects/*/project-permissions": [ - ([_, projectId]) => useAdminApp(projectId).useProjectPermissionDefinitions(), - ([_, projectId]) => useAdminApp(projectId).useTeamPermissionDefinitions(), + ([_, projectId]) => { + useAdminApp(projectId).useProjectPermissionDefinitions(); + }, + ([_, projectId]) => { + useAdminApp(projectId).useTeamPermissionDefinitions(); + }, ], "/projects/*/teams": [ - ([_, projectId]) => useAdminApp(projectId).useTeams(), + ([_, projectId]) => { + useAdminApp(projectId).useTeams(); + }, ], "/projects/*/teams/*": [ - ([_, projectId]) => useAdminApp(projectId).useTeamPermissionDefinitions(), - ([_, projectId]) => useAdminApp(projectId).useUsers({ limit: 10 }), + ([_, projectId]) => { + useAdminApp(projectId).useTeamPermissionDefinitions(); + }, + ([_, projectId]) => { + useAdminApp(projectId).useUsers({ limit: 10 }); + }, ([_, projectId, teamId]) => { const team = useAdminApp(projectId).useTeam(teamId); - team?.useUsers(); + if (team) { + return [() => { + team.useUsers(); + }]; + } }, ], "/projects/*/api-keys": [ - ([_, projectId]) => useAdminApp(projectId).useInternalApiKeys(), + ([_, projectId]) => { + useAdminApp(projectId).useInternalApiKeys(); + }, ], "/projects/*/webhooks": [ - ([_, projectId]) => useAdminApp(projectId).useSvixToken(), + ([_, projectId]) => { + useAdminApp(projectId).useSvixToken(); + }, ], "/projects/*/webhooks/*": [ - ([_, projectId]) => useAdminApp(projectId).useSvixToken(), + ([_, projectId]) => { + useAdminApp(projectId).useSvixToken(); + }, ], "/projects/*/email-drafts": [ - ([_, projectId]) => useAdminApp(projectId).useEmailDrafts(), + ([_, projectId]) => { + useAdminApp(projectId).useEmailDrafts(); + }, ], "/projects/*/email-drafts/*": [ - ([_, projectId]) => useAdminApp(projectId).useEmailDrafts(), - ([_, projectId]) => useAdminApp(projectId).useEmailThemes(), + ([_, projectId]) => { + useAdminApp(projectId).useEmailDrafts(); + }, + ([_, projectId]) => { + useAdminApp(projectId).useEmailThemes(); + }, ([_, projectId, draftId]) => { const adminApp = useAdminApp(projectId); const draft = adminApp.useEmailDrafts().find((d) => d.id === draftId); if (draft) { - adminApp.useEmailPreview({ - themeId: draft.themeId, - templateTsxSource: draft.tsxSource, - }); + return [() => { + adminApp.useEmailPreview({ + themeId: draft.themeId, + templateTsxSource: draft.tsxSource, + }); + }]; } }, ], "/projects/*/emails": [ - ([_, projectId]) => useAdminApp(projectId).useUsers({ limit: 10 }), + ([_, projectId]) => { + useAdminApp(projectId).useUsers({ limit: 10 }); + }, ], "/projects/*/email-templates": [ - ([_, projectId]) => useAdminApp(projectId).useEmailTemplates(), + ([_, projectId]) => { + useAdminApp(projectId).useEmailTemplates(); + }, ], "/projects/*/email-templates/*": [ - ([_, projectId]) => useAdminApp(projectId).useEmailTemplates(), - ([_, projectId]) => useAdminApp(projectId).useEmailThemes(), + ([_, projectId]) => { + useAdminApp(projectId).useEmailTemplates(); + }, + ([_, projectId]) => { + useAdminApp(projectId).useEmailThemes(); + }, ([_, projectId, templateId]) => { const adminApp = useAdminApp(projectId); const template = adminApp.useEmailTemplates().find((t) => t.id === templateId); if (template) { - adminApp.useEmailPreview({ - themeId: template.themeId, - templateTsxSource: template.tsxSource, - }); + return [() => { + adminApp.useEmailPreview({ + themeId: template.themeId, + templateTsxSource: template.tsxSource, + }); + }]; } }, ], "/projects/*/email-themes": [ - ([_, projectId]) => useAdminApp(projectId).useProject().useConfig(), - ([_, projectId]) => useAdminApp(projectId).useEmailThemes(), + ([_, projectId]) => { + useAdminApp(projectId).useProject().useConfig(); + }, + ([_, projectId]) => { + useAdminApp(projectId).useEmailThemes(); + }, ([_, projectId]) => { const adminApp = useAdminApp(projectId); const themes = adminApp.useEmailThemes(); themes.forEach((theme) => { - adminApp.useEmailPreview({ - themeId: theme.id, - templateTsxSource: previewTemplateSource, - }); + return [() => { + adminApp.useEmailPreview({ + themeId: theme.id, + templateTsxSource: previewTemplateSource, + }); + }]; }); }, ], "/projects/*/email-themes/*": [ - ([_, projectId, themeId]) => useAdminApp(projectId).useEmailTheme(themeId), + ([_, projectId, themeId]) => { + useAdminApp(projectId).useEmailTheme(themeId); + }, ([_, projectId, themeId]) => { const adminApp = useAdminApp(projectId); const theme = adminApp.useEmailTheme(themeId); - adminApp.useEmailPreview({ - themeTsxSource: theme.tsxSource, - templateTsxSource: previewTemplateSource, - }); + return [() => { + adminApp.useEmailPreview({ + themeTsxSource: theme.tsxSource, + templateTsxSource: previewTemplateSource, + }); + }]; }, ], "/projects/*/project-settings": [ - ([_, projectId]) => useAdminApp(projectId).useProject(), - ([_, projectId]) => useAdminApp(projectId).useProject().useProductionModeErrors(), - () => useUser({ or: "redirect", projectIdMustMatch: "internal" }), + ([_, projectId]) => { + useAdminApp(projectId).useProject(); + }, + ([_, projectId]) => { + useAdminApp(projectId).useProject().useProductionModeErrors(); + }, + () => { + useUser({ or: "redirect", projectIdMustMatch: "internal" }); + }, ([_, projectId]) => { const project = useAdminApp(projectId).useProject(); const teams = useUser({ or: "redirect", projectIdMustMatch: "internal" }).useTeams(); const ownerTeam = teams.find((team) => team.id === project.ownerTeamId); - ownerTeam?.useUsers(); + if (ownerTeam) { + return [() => { + ownerTeam.useUsers(); + }]; + } }, ], "/projects/*/payments/**": [ - ([_, projectId]) => useAdminApp(projectId).useStripeAccountInfo(), + ([_, projectId]) => { + useAdminApp(projectId).useStripeAccountInfo(); + }, ], "/projects/*/payments/transactions": [ - ([_, projectId]) => useAdminApp(projectId).useTransactions({ limit: 10 }), + ([_, projectId]) => { + useAdminApp(projectId).useTransactions({ limit: 10 }); + }, ], }; diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 6e26a1d97..dd9faae7a 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -45,6 +45,8 @@ export type ClientInterfaceOptions = { }); export class StackClientInterface { + private pendingNetworkDiagnostics?: ReturnType; + constructor(public readonly options: ClientInterfaceOptions) { // nothing here } @@ -58,6 +60,19 @@ export class StackClientInterface { } public async runNetworkDiagnostics(session?: InternalSession | null, requestType?: "client" | "server" | "admin") { + if (this.pendingNetworkDiagnostics) { + return await this.pendingNetworkDiagnostics; + } + + this.pendingNetworkDiagnostics = this._runNetworkDiagnosticsInner(session, requestType); + try { + return await this.pendingNetworkDiagnostics; + } finally { + this.pendingNetworkDiagnostics = undefined; + } + } + + private async _runNetworkDiagnosticsInner(session?: InternalSession | null, requestType?: "client" | "server" | "admin") { const tryRequest = async (cb: () => Promise) => { try { await cb(); @@ -72,12 +87,6 @@ export class StackClientInterface { throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`); } }); - const apiRoot = session !== undefined && requestType !== undefined ? await tryRequest(async () => { - const res = await this.sendClientRequestInner("/", {}, session!, requestType); - if (res.status === "error") { - throw res.error; - } - }) : "Not tested"; const baseUrlBackend = await tryRequest(async () => { const res = await fetch(new URL("/health", this.getApiUrl())); if (!res.ok) { @@ -99,7 +108,6 @@ export class StackClientInterface { return { "navigator?.onLine": globalVar.navigator?.onLine, cfTrace, - apiRoot, baseUrlBackend, prodDashboard, prodBackend, diff --git a/packages/stack-shared/src/utils/caches.tsx b/packages/stack-shared/src/utils/caches.tsx index c0aa6ac2f..99236833c 100644 --- a/packages/stack-shared/src/utils/caches.tsx +++ b/packages/stack-shared/src/utils/caches.tsx @@ -102,7 +102,7 @@ export class AsyncCache { } async refreshWhere(predicate: (dependencies: D) => boolean) { - const promises: Promise[] = []; + const promises: Promise[] = []; for (const [dependencies, cache] of this._map) { if (predicate(dependencies)) { promises.push(cache.refresh()); @@ -111,6 +111,16 @@ export class AsyncCache { await Promise.all(promises); } + async invalidateWhere(predicate: (dependencies: D) => boolean) { + const promises: Promise[] = []; + for (const [dependencies, cache] of this._map) { + if (predicate(dependencies)) { + promises.push(cache.invalidate().catch(() => undefined)); + } + } + await Promise.all(promises); + } + readonly isCacheAvailable = this._createKeyed("isCacheAvailable"); readonly getIfCached = this._createKeyed("getIfCached"); readonly getOrWait = this._createKeyed("getOrWait"); @@ -199,22 +209,21 @@ class AsyncValueCache { } /** - * Refetches the value from the fetcher, and updates the cache with it. + * If anyone is listening to the cache, refreshes the value, and sets it without invalidating the cache. */ - async refresh(): Promise { - return await this.getOrWait("write-only"); + async refresh(): Promise { + if (this._subscriptionsCount > 0) { + await this.getOrWait("write-only"); + } } /** - * Invalidates the cache, marking it dirty (ie. it will be refreshed on the next read). If anyone was listening to it, - * it will refresh immediately. + * Invalidates the cache, marking it dirty (ie. it will be refreshed on the next read). It will refresh immediately. */ - invalidate(): void { + async invalidate(): Promise { this._store.setUnavailable(); this._pendingPromise = undefined; - if (this._subscriptionsCount > 0) { - runAsynchronously(this.refresh()); - } + await this.refresh(); } isDirty(): boolean { @@ -242,11 +251,11 @@ class AsyncValueCache { if (--this._subscriptionsCount === 0) { const currentRefreshPromiseIndex = ++this._mostRecentRefreshPromiseIndex; runAsynchronously(async () => { - // wait a few seconds; if anything changes during that time, we don't want to refresh + // wait a few seconds; we want to keep the cache up during this time // else we do unnecessary requests if we unsubscribe and then subscribe again immediately await wait(5000); if (this._subscriptionsCount === 0 && currentRefreshPromiseIndex === this._mostRecentRefreshPromiseIndex) { - this.invalidate(); + await this.invalidate(); } });