From cde7cd639dff0c456b7cacacd010ac5d5cb48dd5 Mon Sep 17 00:00:00 2001 From: Randy Dean Date: Mon, 6 Jul 2026 14:23:53 -0400 Subject: [PATCH] Auth: fix verify page stuck on spinner under StrictMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify POST fired from a mount effect via useMutation, and StrictMode's simulated remount detaches the mutation observer from its in-flight request — the component never saw success (no navigation, spinner forever) even though the backend signed the user in. Model the token exchange as a query keyed by the token instead: the StrictMode double-mount dedupes to a single POST and the remounted observer re-attaches to the cached entry. The page now derives navigation from query state, dropping the effect + ref guard entirely. Wrap the test renderer in StrictMode to mirror main.tsx so this class of bug fails in vitest instead of only in the browser. Co-Authored-By: Claude Fable 5 --- js/src/features/auth/Verify.page.tsx | 22 ++++----------- .../features/auth/api/useVerifyMagicLink.ts | 27 +++++++++++++------ js/src/lib/test/render.tsx | 20 ++++++++------ 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/js/src/features/auth/Verify.page.tsx b/js/src/features/auth/Verify.page.tsx index ed643d6..df2ed37 100644 --- a/js/src/features/auth/Verify.page.tsx +++ b/js/src/features/auth/Verify.page.tsx @@ -1,7 +1,6 @@ import { useVerifyMagicLink } from "@/features/auth/api/useVerifyMagicLink"; import { Alert, Button, Center, Loader, Stack, Text } from "@mantine/core"; -import { useEffect, useRef } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Link, Navigate, useSearchParams } from "react-router-dom"; /** * Landing page for the emailed link (`/auth/verify?token=...`). The link is a @@ -11,22 +10,11 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom"; export default function VerifyPage() { const [params] = useSearchParams(); const token = params.get("token"); - const navigate = useNavigate(); - const verify = useVerifyMagicLink(); - // StrictMode double-invokes effects in dev; the token is single-use, so guard the POST. - const fired = useRef(false); + const verify = useVerifyMagicLink(token); - useEffect(() => { - if (fired.current || !token) { - return; - } - fired.current = true; - verify.mutate(token, { - onSuccess: () => { - navigate("/", { replace: true }); - }, - }); - }, [token, verify, navigate]); + if (verify.isSuccess) { + return ; + } if (!token || verify.isError) { return ( diff --git a/js/src/features/auth/api/useVerifyMagicLink.ts b/js/src/features/auth/api/useVerifyMagicLink.ts index a52d47f..fe39007 100644 --- a/js/src/features/auth/api/useVerifyMagicLink.ts +++ b/js/src/features/auth/api/useVerifyMagicLink.ts @@ -1,23 +1,34 @@ import { Session, sessionQueryKey } from "@/features/auth/api/useSession"; import { apiFetch } from "@/lib/api/client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; /** * Exchanges the raw token from the emailed link for a session. On success the - * backend sets the session cookie and we seed the session cache so guards + * backend sets the session cookie; we also seed the session cache so guards * pass without a second round-trip. + * + * Modeled as a query (not a mutation) on purpose: the token is single-use and + * this fires on mount, and StrictMode's simulated remount detaches a mutation + * observer from its in-flight request — the component would never see the + * result. A query keyed by the token is deduped across the double-mount (one + * POST) and the remounted observer re-attaches to the cached entry. */ -export function useVerifyMagicLink() { +export function useVerifyMagicLink(token: string | null) { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (token: string) => - apiFetch("/auth/verify", { + return useQuery({ + queryKey: ["auth", "verify", token], + queryFn: async () => { + const session = await apiFetch("/auth/verify", { method: "POST", body: JSON.stringify({ token }), - }), - onSuccess: (session) => { + }); queryClient.setQueryData(sessionQueryKey, session); + return session; }, + enabled: token !== null, + retry: false, + // Never refetch a consumed token: the entry stays fresh for the page's lifetime. + staleTime: Infinity, }); } diff --git a/js/src/lib/test/render.tsx b/js/src/lib/test/render.tsx index 9445aef..67b07ed 100644 --- a/js/src/lib/test/render.tsx +++ b/js/src/lib/test/render.tsx @@ -2,7 +2,7 @@ import { themeOverride } from "@/app/providers/theme"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, RenderOptions } from "@testing-library/react"; -import { ReactElement, ReactNode } from "react"; +import { ReactElement, ReactNode, StrictMode } from "react"; import { MemoryRouter } from "react-router-dom"; /** @@ -14,15 +14,19 @@ function createWrapper(initialEntries?: string[]) { defaultOptions: { queries: { retry: false } }, }); + // StrictMode mirrors main.tsx: double-invoked effects surface bugs (e.g. + // observers detached from in-flight requests) that a bare render hides. return function Wrapper({ children }: { children: ReactNode }) { return ( - - - - {children} - - - + + + + + {children} + + + + ); }; }