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() {