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}
+
+
+
+
);
};
}