mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Page transition prefetching
This commit is contained in:
parent
639ba47447
commit
bcab1045d6
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -73,6 +73,8 @@
|
||||
"pooler",
|
||||
"posthog",
|
||||
"preconfigured",
|
||||
"Prefetcher",
|
||||
"Prefetchers",
|
||||
"Proxied",
|
||||
"psql",
|
||||
"qrcode",
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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={[]}
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
70
apps/dashboard/src/lib/prefetch/hook-prefetcher.tsx
Normal file
70
apps/dashboard/src/lib/prefetch/hook-prefetcher.tsx
Normal 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;
|
||||
}
|
||||
54
apps/dashboard/src/lib/prefetch/url-prefetcher.tsx
Normal file
54
apps/dashboard/src/lib/prefetch/url-prefetcher.tsx
Normal 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)} />;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
10
packages/stack-shared/src/utils/regex.tsx
Normal file
10
packages/stack-shared/src/utils/regex.tsx
Normal 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;
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user