Fix retry issue

This commit is contained in:
Konstantin Wohlwend 2025-11-05 20:34:42 -08:00
parent 7f2de7e1ec
commit f4419c9fba
4 changed files with 211 additions and 109 deletions

View File

@ -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<any, any> }) => {
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 <PrefetchMany callbacks={componentCallbacks} />;
}
return null;
} finally {
isPrefetchingCounter--;
}
};
return <>
{components.map((Component, i) => (
<ErrorBoundary
key={i}
errorComponent={HookPrefetcherErrorComponent}
>
<Suspense fallback={null}>
<Component />
</Suspense>
</ErrorBoundary>
))}
</>;
return (
<ErrorBoundary
key={i}
errorComponent={HookPrefetcherErrorComponent}
>
<Suspense fallback={null}>
<Component />
</Suspense>
</ErrorBoundary>
);
})}
</>;
};
return <PrefetchMany callbacks={props.callbacks} />;
}
function HookPrefetcherErrorComponent(props: { error: Error }) {

View File

@ -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<string, ((match: RegExpMatchArray, query: URLSearchParams, hash: string) => void)[]> = {
const urlPrefetchers: Record<string, ((match: RegExpMatchArray, query: URLSearchParams, hash: string) => 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 });
},
],
};

View File

@ -45,6 +45,8 @@ export type ClientInterfaceOptions = {
});
export class StackClientInterface {
private pendingNetworkDiagnostics?: ReturnType<StackClientInterface["_runNetworkDiagnosticsInner"]>;
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<void>) => {
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,

View File

@ -102,7 +102,7 @@ export class AsyncCache<D extends any[], T> {
}
async refreshWhere(predicate: (dependencies: D) => boolean) {
const promises: Promise<T>[] = [];
const promises: Promise<void>[] = [];
for (const [dependencies, cache] of this._map) {
if (predicate(dependencies)) {
promises.push(cache.refresh());
@ -111,6 +111,16 @@ export class AsyncCache<D extends any[], T> {
await Promise.all(promises);
}
async invalidateWhere(predicate: (dependencies: D) => boolean) {
const promises: Promise<void>[] = [];
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<T> {
}
/**
* 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<T> {
return await this.getOrWait("write-only");
async refresh(): Promise<void> {
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<void> {
this._store.setUnavailable();
this._pendingPromise = undefined;
if (this._subscriptionsCount > 0) {
runAsynchronously(this.refresh());
}
await this.refresh();
}
isDirty(): boolean {
@ -242,11 +251,11 @@ class AsyncValueCache<T> {
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();
}
});