From b385dce604404584c000432a9d96f28428f94da4 Mon Sep 17 00:00:00 2001 From: maramina Date: Wed, 21 Oct 2026 23:57:13 +0100 Subject: [PATCH] Fix hook state refresh and duplicate content IDs --- src/app/globals.css | 9 +++-- src/components/auth/auth-provider.tsx | 2 +- src/lib/hooks/use-credentials.test.tsx | 10 +++++- src/lib/hooks/use-credentials.ts | 23 ++++++------ src/lib/hooks/use-notifications.test.tsx | 24 +++++++++++++ src/lib/hooks/use-notifications.ts | 5 +-- src/lib/hooks/use-quiz.test.tsx | 45 ++++++++++++++++++++++++ src/lib/hooks/use-quiz.ts | 12 +------ 8 files changed, 99 insertions(+), 31 deletions(-) create mode 100644 src/lib/hooks/use-quiz.test.tsx diff --git a/src/app/globals.css b/src/app/globals.css index b58dedd..1868d74 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -67,11 +67,13 @@ } /* Smooth transitions for page content */ -#page-content { +#page-content, +#auth-content { animation: fadeIn 0.2s ease-out; } -#page-content.no-animation { +#page-content.no-animation, +#auth-content.no-animation { animation: none !important; } @@ -157,7 +159,8 @@ html, body { scroll-behavior: auto !important; } - #page-content { + #page-content, + #auth-content { animation: none; } } diff --git a/src/components/auth/auth-provider.tsx b/src/components/auth/auth-provider.tsx index ac54967..b55c8b6 100644 --- a/src/components/auth/auth-provider.tsx +++ b/src/components/auth/auth-provider.tsx @@ -27,7 +27,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
{/* key={pathname} remounts the div on route change so the fadeIn animation replays instead of only running on initial load. */} -
+
{children}
diff --git a/src/lib/hooks/use-credentials.test.tsx b/src/lib/hooks/use-credentials.test.tsx index 9a6185e..76cd99c 100644 --- a/src/lib/hooks/use-credentials.test.tsx +++ b/src/lib/hooks/use-credentials.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { useCredentials, useCredentialDetail, useVerifyCredential } from "./use-credentials"; import { useAuthStore } from "@/store/auth-store"; @@ -129,4 +129,12 @@ describe("useVerifyCredential", () => { expect(result.current.error).toBeTruthy(); }); }); + + it("stops loading when credential id is empty", async () => { + const { verifyCredential } = await import("@/lib/api/credentials"); + const { result } = renderHook(() => useVerifyCredential("")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(verifyCredential).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/hooks/use-credentials.ts b/src/lib/hooks/use-credentials.ts index a8c571d..a761123 100644 --- a/src/lib/hooks/use-credentials.ts +++ b/src/lib/hooks/use-credentials.ts @@ -101,20 +101,15 @@ export function useVerifyCredential(credentialId: string) { const [error, setError] = useState(null); useEffect(() => { - if (!credentialId) return; + if (!credentialId) { + setLoading(false); + setVerification(null); + setError(null); + return; + } const controller = new AbortController(); setLoading(true); - verifyCredential(credentialId) - .then((data) => { - // #507 — Handle both `valid` and `isValid` property names from API - const valid = data.valid ?? (data as any).isValid ?? false; - setVerification({ - valid, - metadata: data.metadata, - verifiedAt: data.verifiedAt, - }); - }) - .catch((err) => + setError(null); verifyCredential(credentialId, controller.signal) .then(setVerification) .catch((err) => { @@ -123,7 +118,9 @@ export function useVerifyCredential(credentialId: string) { err instanceof Error ? err.message : "Verification failed" ); }) - .finally(() => setLoading(false)); + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); return () => controller.abort(); }, [credentialId]); diff --git a/src/lib/hooks/use-notifications.test.tsx b/src/lib/hooks/use-notifications.test.tsx index fa3c854..d4f2d1d 100644 --- a/src/lib/hooks/use-notifications.test.tsx +++ b/src/lib/hooks/use-notifications.test.tsx @@ -142,4 +142,28 @@ describe("useNotifications", () => { expect(getNotifications).toHaveBeenCalledTimes(2); }); + + it("refetches when the JWT changes without refetching on unrelated renders", async () => { + const { getNotifications } = await import("@/lib/api/notifications"); + vi.mocked(getNotifications).mockResolvedValue([]); + + useAuthStore.setState({ jwt: "old-jwt" }); + const { result, rerender } = renderHook(() => useNotifications()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(getNotifications).toHaveBeenCalledTimes(1); + expect(getNotifications).toHaveBeenLastCalledWith( + "old-jwt", + expect.any(AbortSignal) + ); + + rerender(); + expect(getNotifications).toHaveBeenCalledTimes(1); + + useAuthStore.setState({ jwt: "new-jwt" }); + await waitFor(() => expect(getNotifications).toHaveBeenCalledTimes(2)); + expect(getNotifications).toHaveBeenLastCalledWith( + "new-jwt", + expect.any(AbortSignal) + ); + }); }); diff --git a/src/lib/hooks/use-notifications.ts b/src/lib/hooks/use-notifications.ts index c3d56f8..126256b 100644 --- a/src/lib/hooks/use-notifications.ts +++ b/src/lib/hooks/use-notifications.ts @@ -25,6 +25,7 @@ export function useNotifications() { if (!token) { setNotifications([]); setLoading(false); + setError(null); return; } const controller = new AbortController(); @@ -39,7 +40,7 @@ export function useNotifications() { console.error("Failed to fetch notifications:", e); setError(e instanceof Error ? e.message : "Failed to load notifications"); } finally { - setLoading(false); + if (!controller.signal.aborted) setLoading(false); } }, []); @@ -79,7 +80,7 @@ export function useNotifications() { useEffect(() => { refetch(); return () => abortRef.current?.abort(); - }, [refetch]); + }, [refetch, jwt]); const unreadCount = notifications.filter((n) => !n.read).length; diff --git a/src/lib/hooks/use-quiz.test.tsx b/src/lib/hooks/use-quiz.test.tsx new file mode 100644 index 0000000..6ee3c7b --- /dev/null +++ b/src/lib/hooks/use-quiz.test.tsx @@ -0,0 +1,45 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAuthStore } from "@/store/auth-store"; +import { getQuiz } from "@/lib/api/quizzes"; +import { useQuiz } from "./use-quiz"; + +vi.mock("@/lib/api/quizzes", () => ({ + getQuiz: vi.fn(), +})); + +vi.mock("@/lib/api/client", () => ({ + isAbortError: vi.fn((error) => error?.name === "AbortError"), +})); + +describe("useQuiz", () => { + beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ jwt: null }); + }); + + it("clears a previous course error when fetching a new course", async () => { + let resolveSecondFetch!: (quiz: { id: string }) => void; + vi.mocked(getQuiz) + .mockRejectedValueOnce(new Error("Course A failed")) + .mockImplementationOnce( + () => new Promise((resolve) => { resolveSecondFetch = resolve; }) as never + ); + + const { result, rerender } = renderHook( + ({ courseId }) => useQuiz(courseId), + { initialProps: { courseId: "course-a" } } + ); + + await waitFor(() => expect(result.current.error).toBe("Course A failed")); + + rerender({ courseId: "course-b" }); + await waitFor(() => expect(result.current.loading).toBe(true)); + expect(result.current.error).toBeNull(); + + await act(async () => { + resolveSecondFetch({ id: "quiz-b" }); + }); + await waitFor(() => expect(result.current.quiz).toEqual({ id: "quiz-b" })); + }); +}); \ No newline at end of file diff --git a/src/lib/hooks/use-quiz.ts b/src/lib/hooks/use-quiz.ts index 41b203e..c4b97d4 100644 --- a/src/lib/hooks/use-quiz.ts +++ b/src/lib/hooks/use-quiz.ts @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useCallback, useEffect, useState } from "react"; import { useAuthStore } from "@/store/auth-store"; import { getQuiz } from "@/lib/api/quizzes"; import { isAbortError } from "@/lib/api/client"; @@ -21,14 +20,10 @@ export function useQuiz(courseId: string) { setFetchTick((t) => t + 1); }, []); - const fetchQuiz = useCallback(() => { - if (!courseId) return; - setLoading(true); - setError(null); - getQuiz(courseId, jwt ?? undefined) useEffect(() => { if (!courseId) { setLoading(false); + setError(null); return; } abortRef.current?.abort(); @@ -48,15 +43,10 @@ export function useQuiz(courseId: string) { return () => controller.abort(); }, [courseId, jwt, fetchTick]); - useEffect(() => { - fetchQuiz(); - }, [fetchQuiz]); - return { quiz, loading, error, refetch, - refetch: fetchQuiz, }; }