diff --git a/apps/web/src/components/layout/loading-screen.tsx b/apps/web/src/components/layout/loading-screen.tsx
index f0830d4eb..d914d1fd4 100644
--- a/apps/web/src/components/layout/loading-screen.tsx
+++ b/apps/web/src/components/layout/loading-screen.tsx
@@ -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 (
-
+
diff --git a/apps/web/src/dialogs/auth/disable-two-factor.tsx b/apps/web/src/dialogs/auth/disable-two-factor.tsx
index e94e6f6ca..f8425f39e 100644
--- a/apps/web/src/dialogs/auth/disable-two-factor.tsx
+++ b/apps/web/src/dialogs/auth/disable-two-factor.tsx
@@ -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();
},
diff --git a/apps/web/src/dialogs/auth/enable-two-factor.tsx b/apps/web/src/dialogs/auth/enable-two-factor.tsx
index de87b477d..973c16133 100644
--- a/apps/web/src/dialogs/auth/enable-two-factor.tsx
+++ b/apps/web/src/dialogs/auth/enable-two-factor.tsx
@@ -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
(null);
const [backupCodes, setBackupCodes] = useState(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();
};
diff --git a/apps/web/src/features/auth/components/social-auth.tsx b/apps/web/src/features/auth/components/social-auth.tsx
index 9ce1e05ad..74af02c73 100644
--- a/apps/web/src/features/auth/components/social-auth.tsx
+++ b/apps/web/src/features/auth/components/social-auth.tsx
@@ -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));
};
diff --git a/apps/web/src/features/auth/login-redirect.test.tsx b/apps/web/src/features/auth/login-redirect.test.tsx
index a9006f5a0..62f7d90dc 100644
--- a/apps/web/src/features/auth/login-redirect.test.tsx
+++ b/apps/web/src/features/auth/login-redirect.test.tsx
@@ -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 }),
diff --git a/apps/web/src/features/auth/pages/login.tsx b/apps/web/src/features/auth/pages/login.tsx
index b7ce6bf07..8ca48e4a5 100644
--- a/apps/web/src/features/auth/pages/login.tsx
+++ b/apps/web/src/features/auth/pages/login.tsx
@@ -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 (
<>
diff --git a/apps/web/src/features/auth/pages/register.tsx b/apps/web/src/features/auth/pages/register.tsx
index 52599130b..66f228655 100644
--- a/apps/web/src/features/auth/pages/register.tsx
+++ b/apps/web/src/features/auth/pages/register.tsx
@@ -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);
},
diff --git a/apps/web/src/features/auth/pages/verify-2fa.tsx b/apps/web/src/features/auth/pages/verify-2fa.tsx
index 7592cf316..73bbbf46d 100644
--- a/apps/web/src/features/auth/pages/verify-2fa.tsx
+++ b/apps/web/src/features/auth/pages/verify-2fa.tsx
@@ -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));
},
diff --git a/apps/web/src/features/settings/account/profile.tsx b/apps/web/src/features/settings/account/profile.tsx
index 3461f3663..550dcd85d 100644
--- a/apps/web/src/features/settings/account/profile.tsx
+++ b/apps/web/src/features/settings/account/profile.tsx
@@ -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[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;
};
diff --git a/apps/web/src/libs/locale.ts b/apps/web/src/libs/locale.ts
index bf2257f3b..a40553630 100644
--- a/apps/web/src/libs/locale.ts
+++ b/apps/web/src/libs/locale.ts
@@ -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 });
};
diff --git a/apps/web/src/libs/root-context.test.ts b/apps/web/src/libs/root-context.test.ts
new file mode 100644
index 000000000..8f2e2c042
--- /dev/null
+++ b/apps/web/src/libs/root-context.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/libs/root-context.ts b/apps/web/src/libs/root-context.ts
index ab6a7c6db..9a175ee1f 100644
--- a/apps/web/src/libs/root-context.ts
+++ b/apps/web/src/libs/root-context.ts
@@ -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 };
}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index d67361e8e..f70329291 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -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,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 72829342c..221b13f0d 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -88,7 +88,7 @@ export const Route = createRootRouteWithContext()({
],
};
},
- beforeLoad: async () => loadRootContext(),
+ beforeLoad: ({ context }) => loadRootContext(context.queryClient),
});
function RootComponent() {