Page transition prefetching

This commit is contained in:
Konstantin Wohlwend 2025-10-16 12:02:46 -07:00
parent 639ba47447
commit bcab1045d6
15 changed files with 201 additions and 29 deletions

View File

@ -73,6 +73,8 @@
"pooler",
"posthog",
"preconfigured",
"Prefetcher",
"Prefetchers",
"Proxied",
"psql",
"qrcode",

View File

@ -98,7 +98,6 @@ export class OAuthModel implements AuthorizationCodeModel {
assertScopeIsValid(scope);
const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(client.id));
console.log("generateAccessToken", client, user, scope);
const refreshTokenObj = await this._getOrCreateRefreshTokenObj(client, user, scope);
return await generateAccessTokenFromRefreshTokenIfValid({

View File

@ -1,3 +1,4 @@
import { UrlPrefetcher } from "@/lib/prefetch/url-prefetcher";
import SidebarLayout from "./sidebar-layout";
import { AdminAppProvider } from "./use-admin-app";
@ -6,6 +7,10 @@ export default async function Layout(
) {
return (
<AdminAppProvider projectId={(await props.params).projectId}>
{/* Pre-fetch the current URL to prevent request waterfalls */}
<UrlPrefetcher href="" />
<SidebarLayout projectId={(await props.params).projectId}>
{props.children}
</SidebarLayout>

View File

@ -1,34 +1,43 @@
"use client";
import { StackAdminApp, useUser } from "@stackframe/stack";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { notFound } from "next/navigation";
import React from "react";
const StackAdminAppContext = React.createContext<StackAdminApp<false> | null>(null);
export function AdminAppProvider(props: { projectId: string, children: React.ReactNode }) {
const user = useUser({ or: "redirect", projectIdMustMatch: "internal" });
const projects = user.useOwnedProjects();
const project = projects.find(p => p.id === props.projectId);
if (!project) {
console.warn(`Project ${props.projectId} does not exist, or ${user.id} does not have access to it`);
return notFound();
}
const app = useAdminApp(props.projectId);
return (
<StackAdminAppContext.Provider value={project.app}>
<StackAdminAppContext.Provider value={app}>
{props.children}
</StackAdminAppContext.Provider>
);
}
export function useAdminApp() {
export function useAdminAppIfExists() {
const stackAdminApp = React.useContext(StackAdminAppContext);
if (!stackAdminApp) {
throw new StackAssertionError("useAdminApp must be used within an AdminInterfaceProvider");
return null;
}
return stackAdminApp;
}
export function useAdminApp(projectId?: string) {
const user = useUser({ or: "redirect", projectIdMustMatch: "internal" });
const projects = user.useOwnedProjects();
const providedApp = useAdminAppIfExists();
if (projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) {
console.warn(`Project ${projectId} does not exist, or ${user.id} does not have access to it`);
return notFound();
}
return project.app;
} else {
return providedApp ?? throwErr("useAdminApp must be used within an AdminInterfaceProvider");
}
}

View File

@ -6,7 +6,7 @@ import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects';
import { deindent } from '@stackframe/stack-shared/dist/utils/strings';
import { ActionCell, AvatarCell, BadgeCell, DataTableColumnHeader, DataTableManualPagination, DateCell, SearchToolbarItem, SimpleTooltip, TextCell } from "@stackframe/stack-ui";
import { ColumnDef, ColumnFiltersState, Row, SortingState, Table } from "@tanstack/react-table";
import React, { useState } from "react";
import { useState } from "react";
import { Link } from '../link';
import { CreateCheckoutDialog } from '../payments/create-checkout-dialog';
import { DeleteUserDialog, ImpersonateUserDialog } from '../user-dialogs';
@ -186,7 +186,6 @@ export function extendUsers(users: ServerUser[] & { nextCursor?: string | null }
export function UserTable() {
const stackAdminApp = useAdminApp();
const router = useRouter();
const [showAnonymous, setShowAnonymous] = useState(false);
const [filters, setFilters] = useState<Parameters<typeof stackAdminApp.listUsers>[0]>({
limit: 10,
orderBy: "signedUpAt",
@ -194,11 +193,6 @@ export function UserTable() {
includeAnonymous: false,
});
// Update filters when showAnonymous changes
React.useEffect(() => {
setFilters(prev => ({ ...prev, includeAnonymous: showAnonymous }));
}, [showAnonymous]);
const users = extendUsers(stackAdminApp.useUsers(filters));
const onUpdate = async (options: {
@ -235,7 +229,7 @@ export function UserTable() {
return <DataTableManualPagination
columns={columns}
data={users}
toolbarRender={(table) => userToolbarRender(table, showAnonymous, setShowAnonymous)}
toolbarRender={(table) => userToolbarRender(table, filters?.includeAnonymous ?? false, (value) => setFilters(prev => ({ ...prev, includeAnonymous: value })))}
onUpdate={onUpdate}
defaultVisibility={{ emailVerified: false }}
defaultColumnFilters={[]}

View File

@ -1,12 +1,13 @@
'use client';
import NextLink from 'next/link'; // eslint-disable-line no-restricted-imports
import { UrlPrefetcher } from '@/lib/prefetch/url-prefetcher';
import { cn } from "../lib/utils";
// eslint-disable-next-line
import NextLink from 'next/link';
import { useRouter, useRouterConfirm } from "./router";
type LinkProps = {
href: string,
href: string | URL,
children: React.ReactNode,
className?: string,
target?: string,
@ -29,11 +30,12 @@ export function Link(props: LinkProps) {
if (needConfirm) {
e.preventDefault();
props.onClick?.();
router.push(props.href);
router.push(props.href.toString());
}
props.onClick?.();
}}
>
<UrlPrefetcher href={props.href} />
{props.children}
</NextLink>;

View File

@ -0,0 +1,70 @@
"use client";
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 { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
import { Suspense, useEffect } from "react";
let isPrefetching = false;
let hasSetupHookPrefetcher = false;
type HookPrefetcherCallback = () => void;
export function HookPrefetcher(props: {
callbacks: HookPrefetcherCallback[],
}) {
useEffect(() => {
if (process.env.NODE_ENV !== "development") return;
if (hasSetupHookPrefetcher) return;
hasSetupHookPrefetcher = true;
setGlobal("use-async-cache-execution-hooks", [
...(getGlobal("use-async-cache-execution-hooks") ?? []),
(options: { caller: string, dependencies: any[], cache: AsyncCache<any, any> }) => {
if (options.cache.isDirty(options.dependencies)) {
if (isPrefetching) {
// all good, continue
console.log(`Prefetched ${options.caller}`);
} else {
console.warn(deindent`
Fetched ${options.caller} without prefetching! Could you maybe add a HookPrefetcher to make this transition faster?
To do this, if you used a <Link> to navigate to this page, you can add the hook to the \`urlPrefetchers\` in apps/dashboard/src/lib/prefetch/url-prefetcher.tsx. If you didn't use a <Link>, you can use the <HookPrefetcher> component to prefetch the data.
`, options);
}
}
},
]);
}, []);
const components = props.callbacks.map((callback, i) => () => {
isPrefetching = true;
try {
callback();
return null;
} finally {
isPrefetching = false;
}
} );
return <>
{components.map((Component, i) => (
<ErrorBoundary
key={i}
errorComponent={HookPrefetcherErrorComponent}
>
<Suspense fallback={null}>
<Component />
</Suspense>
</ErrorBoundary>
))}
</>;
}
function HookPrefetcherErrorComponent(props: { error: Error }) {
useEffect(() => {
captureError("hook-prefetcher", props.error);
}, [props.error]);
return null;
}

View File

@ -0,0 +1,54 @@
"use client";
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { stackAppInternalsSymbol } from "@stackframe/stack";
import { createCachedRegex } from "@stackframe/stack-shared/dist/utils/regex";
import { useEffect, useState } from "react";
import { HookPrefetcher } from "./hook-prefetcher";
const urlPrefetchers: { [Key in `/projects/${string}/${string}`]: ((match: RegExpMatchArray) => void)[] } = {
"/projects/*/**": [
([_, projectId]) => useAdminApp(projectId).useProject().useConfig(),
],
"/projects/*/users": [
([_, 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,
}),
],
};
function matchPrefetcherPattern(pattern: string, pathname: string) {
// * should match anything except slashes, at least 1 character; ** should match anything including slashes, can be zero characters
// any other character should match exactly
// trailing slashes are ignored
const regex = createCachedRegex(`^${
pattern
.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&")
.replace(/\*\*/g, "\u0001")
.replace(/\*/g, "([^/]+)")
.replace(/\u0001/g, "(.*)")
}\/?$`);
return regex.exec(pathname) || (!pathname.endsWith("/") && regex.exec(`${pathname}/`));
}
function getMatchingPrefetchers(url: URL) {
if (url.origin !== window.location.origin) return [];
return Object.entries(urlPrefetchers)
.map(([pattern, prefetchers]) => [pattern, prefetchers, matchPrefetcherPattern(pattern, url.pathname)] as const)
.flatMap(([_, prefetchers, match]) => match ? prefetchers.map((prefetcher) => () => prefetcher(match)) : []);
}
export function UrlPrefetcher(props: { href: string | URL }) {
const [url, setUrl] = useState<URL | null>(null);
useEffect(() => {
setUrl(new URL(props.href.toString(), window.location.href));
}, [props.href]);
if (!url) return null;
return <HookPrefetcher key={url.toString()} callbacks={getMatchingPrefetchers(url)} />;
}

View File

@ -119,6 +119,7 @@ export class AsyncCache<D extends any[], T> {
readonly refresh = this._createKeyed("refresh");
readonly invalidate = this._createKeyed("invalidate");
readonly onStateChange = this._createKeyed("onStateChange");
readonly isDirty = this._createKeyed("isDirty");
}
class AsyncValueCache<T> {
@ -205,8 +206,8 @@ class AsyncValueCache<T> {
}
/**
* Invalidates the cache, marking it to refresh 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). If anyone was listening to it,
* it will refresh immediately.
*/
invalidate(): void {
this._store.setUnavailable();
@ -216,6 +217,10 @@ class AsyncValueCache<T> {
}
}
isDirty(): boolean {
return this._pendingPromise === undefined;
}
onStateChange(callback: (value: T, oldValue: T | undefined) => void): { unsubscribe: () => void } {
const storeObj = this._store.onChange(callback);

View File

@ -21,3 +21,11 @@ export function createGlobal<T>(key: string, init: () => T) {
}
return globalVar[stackGlobalsSymbol][key] as T;
}
export function getGlobal(key: string): any {
return globalVar[stackGlobalsSymbol][key];
}
export function setGlobal(key: string, value: any) {
globalVar[stackGlobalsSymbol][key] = value;
}

View File

@ -170,6 +170,10 @@ export function mapRefState<T, R>(refState: RefState<T>, mapper: (value: T) => R
};
}
export function shouldRethrowRenderingError(error: unknown): boolean {
return !!error && typeof error === "object" && "digest" in error && error.digest === "BAILOUT_TO_CLIENT_SIDE_RENDERING";
}
export class NoSuspenseBoundaryError extends Error {
digest: string;
reason: string;

View File

@ -0,0 +1,10 @@
const cachedRegexes = new Map<string, RegExp>();
export function createCachedRegex(pattern: string) {
const cached = cachedRegexes.get(pattern);
if (cached) return cached;
const regex = new RegExp(pattern);
cachedRegexes.set(pattern, regex);
return regex;
}

View File

@ -33,6 +33,9 @@ export function useUser(options: GetUserOptions = {}): CurrentUser | CurrentInte
* @returns the current Stack app
*/
export function useStackApp<ProjectId extends string>(options: { projectIdMustMatch?: ProjectId } = {}): StackClientApp<true, ProjectId> {
if (typeof useContext !== "function") {
throw new Error("useStackApp() can only be used in a React Client Component. Make sure you're not calling it from a Server Component, or any other environment.");
}
const context = useContext(StackContext);
if (context === null) {
throw new Error("useStackApp must be used within a StackProvider");

View File

@ -2,6 +2,7 @@ import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import { AsyncCache } from "@stackframe/stack-shared/dist/utils/caches";
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, concatStacktraces, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { getGlobal } from "@stackframe/stack-shared/dist/utils/globals";
import { filterUndefined, omit } from "@stackframe/stack-shared/dist/utils/objects";
import { ReactPromise } from "@stackframe/stack-shared/dist/utils/promises";
import { suspendIfSsr } from "@stackframe/stack-shared/dist/utils/react";
@ -156,6 +157,12 @@ export function useAsyncCache<D extends any[], T>(cache: AsyncCache<D, Result<T>
// we explicitly don't want to run this hook in SSR
suspendIfSsr(caller);
// on the dashboard, we do some perf monitoring for pre-fetching which should hook right in here
const asyncCacheHooks: any[] = getGlobal("use-async-cache-execution-hooks") ?? [];
for (const hook of asyncCacheHooks) {
hook({ cache, caller, dependencies });
}
const id = React.useId();
// whenever the dependencies change, we need to refresh the promise cache

View File

@ -1108,7 +1108,7 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
// IF_PLATFORM react-like
useUsers(options?: ServerListUsersOptions): ServerUser[] & { nextCursor: string | null } {
const crud = useAsyncCache(this._serverUsersCache, [options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query] as const, "useServerUsers()");
const crud = useAsyncCache(this._serverUsersCache, [options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query] as const, "useUsers()");
const result: any = crud.items.map((j) => this._serverUserFromCrud(j));
result.nextCursor = crud.pagination?.next_cursor ?? null;
return result as any;