mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
perf(web): stop every navigation from waiting on the network
Root beforeLoad now reads the session and flags through the query cache instead of refetching them on every navigation and preload. A signed-in session is reused for a minute, a signed-out one is always re-checked, and every sign-in, profile and 2FA change invalidates the cached session before re-running route guards. The active Lingui locale is no longer re-activated per navigation. Links now preload on intent with TanStack Query deciding freshness, the pending loader shows after 300ms and holds for 300ms, and it fades in on in-app navigations while still taking over the HTML loader without a blink on first load.
This commit is contained in:
@@ -1,9 +1,21 @@
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
export function LoadingScreen() {
|
||||
// Fades in on in-app navigations. On first load it takes over from the identical HTML loader in index.html, which
|
||||
// hides the moment React renders, so there it appears at full opacity instead of blinking the logo.
|
||||
const router = useRouter({ warn: false });
|
||||
const hasNavigated = router?.state.resolvedLocation !== undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex h-svh w-svw flex-col items-center justify-center gap-y-6 bg-bg">
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex h-svh w-svw flex-col items-center justify-center gap-y-6 bg-bg",
|
||||
hasNavigated && "starting:opacity-0 transition-opacity duration-standard ease-enter",
|
||||
)}
|
||||
>
|
||||
<BrandIcon variant="icon" className="size-12" />
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DialogProps } from "../store";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
@@ -19,6 +20,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { sessionQueryKey } from "@/libs/root-context";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { useDialogStore } from "../store";
|
||||
|
||||
@@ -28,6 +30,7 @@ const formSchema = z.object({
|
||||
|
||||
export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
@@ -62,7 +65,7 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
|
||||
description: t`Two-factor authentication is now disabled.`,
|
||||
id: toastId,
|
||||
});
|
||||
void router.invalidate();
|
||||
void queryClient.invalidateQueries({ queryKey: sessionQueryKey }).then(() => router.invalidate());
|
||||
closeDialog();
|
||||
form.reset();
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DialogProps } from "../store";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useState } from "react";
|
||||
@@ -25,6 +26,7 @@ import { downloadWithAnchor } from "@reactive-resume/utils/file";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { sessionQueryKey } from "@/libs/root-context";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { useDialogStore } from "../store";
|
||||
|
||||
@@ -48,6 +50,7 @@ type TwoFactorQRCodeProps = {
|
||||
|
||||
export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [totpUri, setTotpUri] = useState<string | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
@@ -136,7 +139,7 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
|
||||
|
||||
const onConfirmBackup = () => {
|
||||
toast.add({ type: "success", description: t`Two-factor authentication is now enabled.` });
|
||||
void router.invalidate();
|
||||
void queryClient.invalidateQueries({ queryKey: sessionQueryKey }).then(() => router.invalidate());
|
||||
closeDialog();
|
||||
onReset();
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter, useSearch } from "@tanstack/react-router";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
@@ -11,6 +11,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { sessionQueryKey } from "@/libs/root-context";
|
||||
import { getAuthRedirectOptions, getOAuthPasskeyOptions, getOAuthSignInOptions, isOAuthRedirect } from "../redirect";
|
||||
|
||||
export function SocialAuth() {
|
||||
@@ -50,6 +51,7 @@ type SocialAuthButtonsProps = {
|
||||
|
||||
function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { callbackURL } = useSearch({ from: "/auth" });
|
||||
|
||||
const runSignIn = async (
|
||||
@@ -73,6 +75,7 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
|
||||
}
|
||||
toast.close(toastId);
|
||||
if (isOAuthRedirect(data)) return;
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey });
|
||||
await router.invalidate();
|
||||
if (isPasskey) void router.navigate(getAuthRedirectOptions(callbackURL));
|
||||
};
|
||||
|
||||
@@ -18,12 +18,14 @@ const mocks = vi.hoisted(() => ({
|
||||
verifyBackupCode: vi.fn(),
|
||||
signup: vi.fn(),
|
||||
continueOAuth: vi.fn(),
|
||||
queryClient: { invalidateQueries: vi.fn(async () => {}) },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { google: "Google", github: "GitHub", linkedin: "LinkedIn", custom: "SSO", passkey: true },
|
||||
}),
|
||||
useQueryClient: () => mocks.queryClient,
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useSearch: () => ({ callbackURL: mocks.callbackURL }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
@@ -12,6 +12,7 @@ import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { sessionQueryKey } from "@/libs/root-context";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SocialAuth } from "../components/social-auth";
|
||||
import { getAuthRedirectOptions, getOAuthPasskeyOptions, getOAuthSignInOptions, isOAuthRedirect } from "../redirect";
|
||||
@@ -30,6 +31,7 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
|
||||
const router = useRouter();
|
||||
const { callbackURL, reauthenticate } = useSearch({ from: "/auth" });
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
@@ -85,6 +87,7 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
|
||||
|
||||
toast.close(toastId);
|
||||
if (isOAuthRedirect(result.data)) return;
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey });
|
||||
await router.invalidate();
|
||||
void navigate(getAuthRedirectOptions(callbackURL));
|
||||
} catch {
|
||||
@@ -110,10 +113,11 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
|
||||
});
|
||||
if (error || isOAuthRedirect(data)) return;
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey });
|
||||
await router.invalidate();
|
||||
void navigate(getAuthRedirectOptions(callbackURL));
|
||||
});
|
||||
}, [providers, router, navigate, callbackURL]);
|
||||
}, [providers, router, navigate, callbackURL, queryClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
@@ -11,6 +12,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { sessionQueryKey } from "@/libs/root-context";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SocialAuth } from "../components/social-auth";
|
||||
import { getOAuthSignInOptions, isOAuthRedirect } from "../redirect";
|
||||
@@ -36,6 +38,7 @@ type Props = {
|
||||
|
||||
export function RegisterPage({ disableEmailAuth }: Props) {
|
||||
const { callbackURL, reauthenticate } = useSearch({ from: "/auth" });
|
||||
const queryClient = useQueryClient();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
@@ -80,6 +83,7 @@ export function RegisterPage({ disableEmailAuth }: Props) {
|
||||
}
|
||||
if (isOAuthRedirect(continuation.data)) return;
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey });
|
||||
setSubmitted(true);
|
||||
toast.close(toastId);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
@@ -9,6 +10,7 @@ import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { OTPField } from "@reactive-resume/ui/components/otp-field";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { sessionQueryKey } from "@/libs/root-context";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { getAuthRedirectOptions, getOAuthSignInOptions, isOAuthRedirect } from "../redirect";
|
||||
|
||||
@@ -26,6 +28,7 @@ type TwoFactorVerificationPageProps = {
|
||||
|
||||
function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerificationPageProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { callbackURL, reauthenticate } = useSearch({ from: "/auth" });
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -63,6 +66,7 @@ function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerification
|
||||
|
||||
toast.close(toastId);
|
||||
if (isOAuthRedirect(data)) return;
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey });
|
||||
await router.invalidate();
|
||||
void navigate(getAuthRedirectOptions(callbackURL));
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AuthSession } from "@reactive-resume/auth/types";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouteContext, useRouter } from "@tanstack/react-router";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
@@ -20,6 +20,7 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { sessionQueryKey } from "@/libs/root-context";
|
||||
import { SettingsSection } from "../section";
|
||||
|
||||
const nameSchema = z.string().trim().min(1).max(64);
|
||||
@@ -125,6 +126,7 @@ type ProfileSectionProps = { session: AuthSession };
|
||||
|
||||
export function ProfileSection({ session }: ProfileSectionProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const context = useRouteContext({ strict: false });
|
||||
const smtpEnabled = context.flags?.smtpEnabled ?? false;
|
||||
const user = session.user;
|
||||
@@ -133,6 +135,7 @@ export function ProfileSection({ session }: ProfileSectionProps) {
|
||||
const updateUser = async (patch: Parameters<typeof authClient.updateUser>[0]) => {
|
||||
const { error } = await authClient.updateUser(patch);
|
||||
if (error) return getReadableErrorMessage(error, t`Couldn't save. Try again.`);
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey });
|
||||
await router.invalidate();
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -119,6 +119,8 @@ export const getLocaleMessages = async (locale: string) => {
|
||||
};
|
||||
|
||||
export const loadLocale = async (locale: string) => {
|
||||
// Re-activating the active locale emits "change" and re-renders every translated component.
|
||||
if (i18n.locale === locale) return;
|
||||
const { locale: resolvedLocale, messages } = await getLocaleMessages(locale);
|
||||
i18n.loadAndActivate({ locale: resolvedLocale, messages });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { loadRootContext, sessionQueryKey } from "./root-context";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
getFlags: vi.fn(async () => ({ disableSignups: false, disableEmailAuth: false, smtpEnabled: false })),
|
||||
}));
|
||||
|
||||
vi.mock("./auth/session", () => ({ getSession: mocks.getSession }));
|
||||
vi.mock("./orpc/client", () => ({ client: { flags: { get: mocks.getFlags } } }));
|
||||
vi.mock("./locale", () => ({ getLocale: () => "en-US", loadLocale: vi.fn(async () => {}) }));
|
||||
|
||||
const signedIn = { user: { id: "user-1" }, session: { id: "session-1" } };
|
||||
|
||||
describe("loadRootContext", () => {
|
||||
beforeEach(() => {
|
||||
mocks.getSession.mockReset();
|
||||
mocks.getFlags.mockClear();
|
||||
});
|
||||
|
||||
it("reuses a signed-in session and the flags across navigations", async () => {
|
||||
mocks.getSession.mockResolvedValue(signedIn);
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await loadRootContext(queryClient);
|
||||
const second = await loadRootContext(queryClient);
|
||||
|
||||
expect(second.session).toEqual(signedIn);
|
||||
expect(mocks.getSession).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.getFlags).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refetches the session after it is invalidated (sign-out)", async () => {
|
||||
mocks.getSession.mockResolvedValueOnce(signedIn).mockResolvedValueOnce(null);
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await loadRootContext(queryClient);
|
||||
await queryClient.invalidateQueries({ queryKey: sessionQueryKey });
|
||||
const after = await loadRootContext(queryClient);
|
||||
|
||||
expect(after.session).toBeNull();
|
||||
expect(mocks.getSession).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.getFlags).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never reuses a signed-out result, so a sign-in elsewhere is seen on the next navigation", async () => {
|
||||
mocks.getSession.mockResolvedValueOnce(null).mockResolvedValueOnce(signedIn);
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await loadRootContext(queryClient);
|
||||
const after = await loadRootContext(queryClient);
|
||||
|
||||
expect(after.session).toEqual(signedIn);
|
||||
expect(mocks.getSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,29 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { getSession } from "./auth/session";
|
||||
import { getLocale, loadLocale } from "./locale";
|
||||
import { client } from "./orpc/client";
|
||||
import { getTheme } from "./theme";
|
||||
|
||||
export async function loadRootContext() {
|
||||
const [theme, locale, session, flags] = await Promise.all([
|
||||
getTheme(),
|
||||
getLocale(),
|
||||
getSession(),
|
||||
client.flags.get(),
|
||||
]);
|
||||
export const sessionQueryKey = ["auth", "session"] as const;
|
||||
const flagsQueryKey = ["flags"] as const;
|
||||
|
||||
await loadLocale(locale);
|
||||
// Root beforeLoad runs on every navigation and every hover preload, so the network reads come from the query cache.
|
||||
// A signed-in session is reused for a minute; a signed-out one is always re-checked, so signing in elsewhere shows up
|
||||
// on the next navigation. Anything that signs in, signs out or edits the profile invalidates `sessionQueryKey` before
|
||||
// calling `router.invalidate()`. Flags come from server env and only change on a restart.
|
||||
export async function loadRootContext(queryClient: QueryClient) {
|
||||
const theme = getTheme();
|
||||
const locale = getLocale();
|
||||
|
||||
const [session, flags] = await Promise.all([
|
||||
queryClient.query({
|
||||
queryKey: sessionQueryKey,
|
||||
queryFn: getSession,
|
||||
staleTime: (query) => (query.state.data ? 60_000 : 0),
|
||||
}),
|
||||
queryClient.query({ queryKey: flagsQueryKey, queryFn: () => client.flags.get(), staleTime: 5 * 60_000 }),
|
||||
loadLocale(locale),
|
||||
]);
|
||||
|
||||
return { theme, locale, session, flags };
|
||||
}
|
||||
|
||||
@@ -10,12 +10,18 @@ import { routeTree } from "./routeTree.gen";
|
||||
export const getRouter = async () => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const { theme, locale, session, flags } = await loadRootContext();
|
||||
const { theme, locale, session, flags } = await loadRootContext(queryClient);
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultStructuralSharing: true,
|
||||
// Hovering a link starts its loaders; TanStack Query decides freshness, not the router's preload cache.
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
// Past 300ms a navigation shows the loader, held long enough (300ms) for its 200ms fade-in to land.
|
||||
defaultPendingMs: 300,
|
||||
defaultPendingMinMs: 300,
|
||||
defaultErrorComponent: ErrorScreen,
|
||||
defaultPendingComponent: LoadingScreen,
|
||||
defaultNotFoundComponent: NotFoundScreen,
|
||||
|
||||
@@ -88,7 +88,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
],
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => loadRootContext(),
|
||||
beforeLoad: ({ context }) => loadRootContext(context.queryClient),
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
|
||||
Reference in New Issue
Block a user