A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Synchronize signed-out auth state across logout flows

- Add shared auth-cache helper to clear and invalidate the me query
- Use the helper after logout and account deletion before redirecting home
- Switch the home route to the shared current-user hook and add coverage

+318 -12
+167
apps/web/src/components/Header.test.tsx
··· 1 + // @vitest-environment jsdom 2 + 3 + import { act } from "react"; 4 + import { createRoot, type Root } from "react-dom/client"; 5 + import { afterEach, describe, expect, it, vi } from "vitest"; 6 + import Header from "./Header"; 7 + 8 + const mockNavigate = vi.fn(); 9 + const mockPublishSignedOutAuthState = vi.fn(); 10 + const mockMutateAsync = vi.fn(); 11 + const mockUseMutation = vi.fn(); 12 + const mockUseQueryClient = vi.fn(); 13 + 14 + vi.mock("@tanstack/react-query", async (importOriginal) => { 15 + const actual = await importOriginal<typeof import("@tanstack/react-query")>(); 16 + 17 + return { 18 + ...actual, 19 + useMutation: (...args: unknown[]) => mockUseMutation(...args), 20 + useQueryClient: () => mockUseQueryClient(), 21 + }; 22 + }); 23 + 24 + vi.mock("@tanstack/react-router", () => ({ 25 + Link: ({ 26 + children, 27 + ...props 28 + }: { 29 + children: React.ReactNode; 30 + [key: string]: unknown; 31 + }) => <a {...props}>{children}</a>, 32 + useLocation: () => ({ pathname: "/" }), 33 + useNavigate: () => mockNavigate, 34 + })); 35 + 36 + vi.mock("@/components/theme-provider", () => ({ 37 + useTheme: () => ({ seedColor: "#abcdef" }), 38 + })); 39 + 40 + vi.mock("@/components/ui/m3-button", () => ({ 41 + M3Button: ({ 42 + children, 43 + asChild, 44 + ...props 45 + }: { 46 + children: React.ReactNode; 47 + asChild?: boolean; 48 + [key: string]: unknown; 49 + }) => (asChild ? children : <button {...props}>{children}</button>), 50 + })); 51 + 52 + vi.mock("@/components/ui/popover", () => ({ 53 + Popover: ({ children }: { children: React.ReactNode }) => ( 54 + <div>{children}</div> 55 + ), 56 + PopoverTrigger: ({ children }: { children: React.ReactNode }) => ( 57 + <div>{children}</div> 58 + ), 59 + PopoverContent: ({ children }: { children: React.ReactNode }) => ( 60 + <div>{children}</div> 61 + ), 62 + })); 63 + 64 + vi.mock("@/lib/auth-cache", () => ({ 65 + publishSignedOutAuthState: (...args: unknown[]) => 66 + mockPublishSignedOutAuthState(...args), 67 + })); 68 + 69 + declare global { 70 + var IS_REACT_ACT_ENVIRONMENT: boolean; 71 + } 72 + 73 + globalThis.IS_REACT_ACT_ENVIRONMENT = true; 74 + 75 + describe("Header logout", () => { 76 + let container: HTMLDivElement | null = null; 77 + let root: Root | null = null; 78 + 79 + afterEach(() => { 80 + vi.clearAllMocks(); 81 + 82 + if (root) { 83 + act(() => { 84 + root.unmount(); 85 + }); 86 + } 87 + 88 + container?.remove(); 89 + container = null; 90 + root = null; 91 + document.body.innerHTML = ""; 92 + }); 93 + 94 + function renderHeader(user: { 95 + did: string; 96 + handle: string; 97 + displayName: string; 98 + }) { 99 + mockUseQueryClient.mockReturnValue({ name: "query-client" }); 100 + mockPublishSignedOutAuthState.mockResolvedValue(undefined); 101 + mockUseMutation.mockImplementation( 102 + (options: { onSuccess?: () => Promise<void> }) => { 103 + mockMutateAsync.mockImplementation(async () => { 104 + await options.onSuccess?.(); 105 + }); 106 + 107 + return { 108 + isPending: false, 109 + mutateAsync: mockMutateAsync, 110 + }; 111 + }, 112 + ); 113 + 114 + container = document.createElement("div"); 115 + document.body.appendChild(container); 116 + root = createRoot(container); 117 + 118 + act(() => { 119 + root?.render(<Header user={user} isAuthLoading={false} />); 120 + }); 121 + } 122 + 123 + it("publishes signed-out auth state before navigating home", async () => { 124 + renderHeader({ 125 + did: "did:plc:alice", 126 + handle: "alice", 127 + displayName: "Alice", 128 + }); 129 + 130 + const signOutButton = Array.from(document.querySelectorAll("button")).find( 131 + (button) => button.textContent?.includes("Sign out"), 132 + ); 133 + 134 + expect(signOutButton).toBeDefined(); 135 + 136 + await act(async () => { 137 + signOutButton?.dispatchEvent( 138 + new MouseEvent("click", { bubbles: true, cancelable: true }), 139 + ); 140 + }); 141 + 142 + expect(mockMutateAsync).toHaveBeenCalledWith({}); 143 + expect(mockPublishSignedOutAuthState).toHaveBeenCalledWith({ 144 + name: "query-client", 145 + }); 146 + expect(mockNavigate).toHaveBeenCalledWith({ to: "/" }); 147 + }); 148 + 149 + it("renders signed-out actions when no user is present", () => { 150 + mockUseQueryClient.mockReturnValue({ name: "query-client" }); 151 + mockUseMutation.mockReturnValue({ 152 + isPending: false, 153 + mutateAsync: mockMutateAsync, 154 + }); 155 + 156 + container = document.createElement("div"); 157 + document.body.appendChild(container); 158 + root = createRoot(container); 159 + 160 + act(() => { 161 + root?.render(<Header user={null} isAuthLoading={false} />); 162 + }); 163 + 164 + expect(document.body.textContent).toContain("Sign in"); 165 + expect(document.body.textContent).not.toContain("Sign out"); 166 + }); 167 + });
+3 -2
apps/web/src/components/Header.tsx
··· 22 22 PopoverContent, 23 23 PopoverTrigger, 24 24 } from "@/components/ui/popover"; 25 + import { publishSignedOutAuthState } from "@/lib/auth-cache"; 25 26 import { cn } from "@/lib/utils"; 26 27 import { 27 28 type GlobalNavItem, ··· 68 69 const logoutMutation = useMutation({ 69 70 mutationKey: ["auth", "logout"], 70 71 ...authControllerLogoutMutation(), 71 - onSuccess: () => { 72 - queryClient.clear(); 72 + onSuccess: async () => { 73 + await publishSignedOutAuthState(queryClient); 73 74 navigate({ to: "/" }); 74 75 }, 75 76 });
+26
apps/web/src/lib/auth-cache.test.ts
··· 1 + // @vitest-environment jsdom 2 + 3 + import { authControllerMeQueryKey } from "@opnshelf/api"; 4 + import { QueryClient } from "@tanstack/react-query"; 5 + import { describe, expect, it, vi } from "vitest"; 6 + import { publishSignedOutAuthState } from "./auth-cache"; 7 + 8 + describe("publishSignedOutAuthState", () => { 9 + it("publishes a signed-out auth state and invalidates the auth query", async () => { 10 + const queryClient = new QueryClient(); 11 + const queryKey = authControllerMeQueryKey(); 12 + const cancelQueries = vi.spyOn(queryClient, "cancelQueries"); 13 + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); 14 + 15 + queryClient.setQueryData(queryKey, { 16 + did: "did:plc:alice", 17 + handle: "alice", 18 + }); 19 + 20 + await publishSignedOutAuthState(queryClient); 21 + 22 + expect(cancelQueries).toHaveBeenCalledWith({ queryKey }); 23 + expect(queryClient.getQueryData(queryKey)).toBeNull(); 24 + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }); 25 + }); 26 + });
+10
apps/web/src/lib/auth-cache.ts
··· 1 + import { authControllerMeQueryKey } from "@opnshelf/api"; 2 + import type { QueryClient } from "@tanstack/react-query"; 3 + 4 + export async function publishSignedOutAuthState(queryClient: QueryClient) { 5 + const queryKey = authControllerMeQueryKey(); 6 + 7 + await queryClient.cancelQueries({ queryKey }); 8 + queryClient.setQueryData(queryKey, null); 9 + await queryClient.invalidateQueries({ queryKey }); 10 + }
+106
apps/web/src/routes/-index.test.tsx
··· 1 + // @vitest-environment jsdom 2 + 3 + import { act } from "react"; 4 + import { createRoot, type Root } from "react-dom/client"; 5 + import { afterEach, describe, expect, it, vi } from "vitest"; 6 + import { HomePage } from "./index"; 7 + 8 + const mockUseCurrentUser = vi.fn(); 9 + 10 + vi.mock("@tanstack/react-router", () => ({ 11 + createFileRoute: () => () => ({}), 12 + })); 13 + 14 + vi.mock("@/hooks/useCurrentUser", () => ({ 15 + useCurrentUser: () => mockUseCurrentUser(), 16 + })); 17 + 18 + vi.mock("@/components/AuthLoadingState", () => ({ 19 + AuthLoadingState: () => <div>Auth loading</div>, 20 + })); 21 + 22 + vi.mock("@/components/home/LandingHomePage", () => ({ 23 + LandingHomePage: () => <div>Landing home</div>, 24 + })); 25 + 26 + vi.mock("@/components/home/DashboardHomePage", () => ({ 27 + DashboardHomePage: ({ user }: { user: { handle: string } }) => ( 28 + <div>Dashboard for {user.handle}</div> 29 + ), 30 + })); 31 + 32 + declare global { 33 + var IS_REACT_ACT_ENVIRONMENT: boolean; 34 + } 35 + 36 + globalThis.IS_REACT_ACT_ENVIRONMENT = true; 37 + 38 + describe("HomePage", () => { 39 + let container: HTMLDivElement | null = null; 40 + let root: Root | null = null; 41 + 42 + afterEach(() => { 43 + vi.clearAllMocks(); 44 + 45 + if (root) { 46 + act(() => { 47 + root.unmount(); 48 + }); 49 + } 50 + 51 + container?.remove(); 52 + container = null; 53 + root = null; 54 + document.body.innerHTML = ""; 55 + }); 56 + 57 + function renderHomePage() { 58 + container = document.createElement("div"); 59 + document.body.appendChild(container); 60 + root = createRoot(container); 61 + 62 + act(() => { 63 + root?.render(<HomePage />); 64 + }); 65 + } 66 + 67 + it("uses the shared current-user hook while auth is loading", () => { 68 + mockUseCurrentUser.mockReturnValue({ 69 + data: undefined, 70 + isLoading: true, 71 + }); 72 + 73 + renderHomePage(); 74 + 75 + expect(mockUseCurrentUser).toHaveBeenCalledTimes(1); 76 + expect(document.body.textContent).toContain("Auth loading"); 77 + }); 78 + 79 + it("renders the landing page when there is no current user", () => { 80 + mockUseCurrentUser.mockReturnValue({ 81 + data: null, 82 + isLoading: false, 83 + }); 84 + 85 + renderHomePage(); 86 + 87 + expect(mockUseCurrentUser).toHaveBeenCalledTimes(1); 88 + expect(document.body.textContent).toContain("Landing home"); 89 + expect(document.body.textContent).not.toContain("Dashboard for"); 90 + }); 91 + 92 + it("renders the dashboard when the current user exists", () => { 93 + mockUseCurrentUser.mockReturnValue({ 94 + data: { 95 + did: "did:plc:alice", 96 + handle: "alice", 97 + }, 98 + isLoading: false, 99 + }); 100 + 101 + renderHomePage(); 102 + 103 + expect(document.body.textContent).toContain("Dashboard for alice"); 104 + expect(document.body.textContent).not.toContain("Landing home"); 105 + }); 106 + });
+3 -8
apps/web/src/routes/index.tsx
··· 1 - import { authControllerMeOptions } from "@opnshelf/api"; 2 - import { useQuery } from "@tanstack/react-query"; 3 1 import { createFileRoute } from "@tanstack/react-router"; 4 2 import { AuthLoadingState } from "@/components/AuthLoadingState"; 5 3 import { DashboardHomePage } from "@/components/home/DashboardHomePage"; 6 4 import { LandingHomePage } from "@/components/home/LandingHomePage"; 5 + import { useCurrentUser } from "@/hooks/useCurrentUser"; 7 6 8 7 export const Route = createFileRoute("/")({ 9 8 head: () => ({ ··· 19 18 component: HomePage, 20 19 }); 21 20 22 - function HomePage() { 23 - const { data: user, isLoading: isUserLoading } = useQuery({ 24 - ...authControllerMeOptions(), 25 - staleTime: 5 * 60 * 1000, 26 - retry: false, 27 - }); 21 + export function HomePage() { 22 + const { data: user, isLoading: isUserLoading } = useCurrentUser(); 28 23 29 24 if (isUserLoading) { 30 25 return <AuthLoadingState className="max-w-6xl py-16" />;
+3 -2
apps/web/src/routes/profile.$handle.settings.tsx
··· 53 53 } from "@/components/ui/select"; 54 54 import { Skeleton } from "@/components/ui/skeleton"; 55 55 import { Switch } from "@/components/ui/switch"; 56 + import { publishSignedOutAuthState } from "@/lib/auth-cache"; 56 57 import { 57 58 AVATAR_UPLOAD_HELP_TEXT, 58 59 getAvatarUploadErrorMessage, ··· 191 192 const deleteAccountMutation = useMutation({ 192 193 mutationKey: ["users", "account", "delete"], 193 194 ...usersControllerDeleteMyAccountMutation(), 194 - onSuccess: () => { 195 + onSuccess: async () => { 195 196 if (user?.did) { 196 197 clearDismissedTraktImportJobIds(user.did); 197 198 } ··· 201 202 posthog.reset(); 202 203 setShowDeleteDialog(false); 203 204 toast.success("Account deleted"); 204 - queryClient.clear(); 205 + await publishSignedOutAuthState(queryClient); 205 206 router.navigate({ to: "/" }); 206 207 }, 207 208 onError: (error) => {