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:
Amruth Pillai
2026-09-29 12:35:38 +02:00
parent 982e688e65
commit 500cabaf4a
14 changed files with 132 additions and 17 deletions
@@ -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 }),
+6 -2
View File
@@ -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;
};
+2
View File
@@ -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 });
};
+57
View File
@@ -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);
});
});
+20 -8
View File
@@ -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 };
}
+7 -1
View File
@@ -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,
+1 -1
View File
@@ -88,7 +88,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
],
};
},
beforeLoad: async () => loadRootContext(),
beforeLoad: ({ context }) => loadRootContext(context.queryClient),
});
function RootComponent() {