diff --git a/audit.md b/audit.md index 21d31a9..afaa0eb 100644 --- a/audit.md +++ b/audit.md @@ -74,6 +74,20 @@ Verify mutations `invalidateQueries`; consider optimistic insert so reviews appe --- ## Implementation status +- [x] U2 — killed the hard `window.location.href='/'` reloads. `Login.tsx` + (post-login) now `await refetchUser()` + `navigate('/')`; `Settings.tsx` + (post-account-deletion) `queryClient.clear()` + `await refetchUser()` + + `navigate('/')` — deletion wipes the whole react-query cache so no stale user + data survives the now-gone account. Both keep the SPA mounted (warm cache, no + white flash). `tsc` + ESLint (0 errors) clean. +- [x] R4 — split `refetchUser` / `refetchFollowing` (`AuthContext.tsx`). The 60s + badge poll (`NotificationContext.tsx:34`) now only hits `users/me/`; the + following list is fetched once on login (effect keyed on `user?.username`) and + cleared on logout. Added `refetchFollowing` to the context value and call it + after follow/unfollow in `PublicProfile.tsx` (both the main + mini toggles) so + the sidebar list updates immediately instead of waiting for the next poll. + Drops one `following_list` GET per minute per logged-in client. `tsc` + ESLint + (0 errors) clean. - [x] Q2 — `useCategories()` query hook (`api/queries/useCategories.ts`, key `queryKeys.categories()`, 30 min `staleTime`). Replaced raw `api.get('categories/')` + `useState`/`useEffect` in `App.tsx` with the hook; diff --git a/frontend/src/api/queries/useFlavorsList.ts b/frontend/src/api/queries/useFlavorsList.ts index 0a23dae..cb6417c 100644 --- a/frontend/src/api/queries/useFlavorsList.ts +++ b/frontend/src/api/queries/useFlavorsList.ts @@ -41,8 +41,9 @@ const fetchAllFlavors = async (): Promise => { const all: FlavorListItem[] = []; let url: string | null = 'flavors/'; while (url) { - const res: { data: FlavorListItem[] | Paginated } = - await api.get>(url); + const res: { data: FlavorListItem[] | Paginated } = await api.get< + FlavorListItem[] | Paginated + >(url); all.push(...unwrap(res.data)); const next = Array.isArray(res.data) ? null : (res.data.next ?? null); // `next` is an absolute URL; api baseURL already covers the prefix, so use the relative tail. diff --git a/frontend/src/components/layout/NotificationMenu.tsx b/frontend/src/components/layout/NotificationMenu.tsx index 45b6ac3..a32477e 100644 --- a/frontend/src/components/layout/NotificationMenu.tsx +++ b/frontend/src/components/layout/NotificationMenu.tsx @@ -1,13 +1,4 @@ -import { - Avatar, - Box, - Button, - Menu, - MenuItem, - Typography, - alpha, - useTheme, -} from '@mui/material'; +import { Avatar, Box, Button, Menu, MenuItem, Typography, alpha, useTheme } from '@mui/material'; import type { MenuProps, SxProps, Theme } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../../hooks/useAuth'; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 958f7b7..0c971b7 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -24,6 +24,7 @@ export interface AuthContextValue { following: FollowingEntry[]; loadingUser: boolean; refetchUser: () => Promise; + refetchFollowing: () => Promise; logout: () => Promise; } @@ -35,6 +36,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const [following, setFollowing] = useState([]); const [loadingUser, setLoadingUser] = useState(true); + // Following list rarely changes: fetched on login and after a follow/unfollow + // mutation — NOT on the 60s badge poll (that only needs `users/me/`). + const refetchFollowing = useCallback(async () => { + try { + const followRes = await api.get('users/following_list/'); + const data = Array.isArray(followRes.data) ? followRes.data : followRes.data.results || []; + setFollowing(data); + } catch { + /* ignore */ + } + }, []); + const refetchUser = useCallback(async () => { try { const res = await api.get('users/me/', { @@ -44,15 +57,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setUser(res.data); if (res.data.theme) localStorage.setItem('theme', res.data.theme); if (res.data.language) i18n.changeLanguage(res.data.language); - try { - const followRes = await api.get('users/following_list/'); - const data = Array.isArray(followRes.data) - ? followRes.data - : followRes.data.results || []; - setFollowing(data); - } catch { - /* ignore */ - } } else { setUser(null); } @@ -67,6 +71,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { refetchUser(); }, [refetchUser]); + // Sync following list with login state: load once on login, clear on logout. + // Keyed on username so it doesn't re-run on every badge-count refresh. + useEffect(() => { + if (user?.username) { + refetchFollowing(); + } else { + setFollowing([]); + } + }, [user?.username, refetchFollowing]); + const logout = useCallback(async () => { try { await api.post('auth/logout/'); @@ -80,7 +94,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { }, [i18n]); return ( - + {children} ); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 498c054..f24ae2f 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; import { Typography, TextField, Button, Tab, Tabs, Alert, Stack, Box } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import api from '../lib/api'; import { useTitle } from '../hooks/useTitle'; +import { useAuth } from '../hooks/useAuth'; import { useTranslation } from 'react-i18next'; import { PageShell, GlassCard, FormCard } from '../components/ui'; @@ -15,6 +16,8 @@ const readError = (err: unknown): ApiError => err as ApiError; const Login: React.FC = () => { const { t } = useTranslation(); + const navigate = useNavigate(); + const { refetchUser } = useAuth(); useTitle('Login'); const [tab, setTab] = useState(0); const [username, setUsername] = useState(''); @@ -32,7 +35,10 @@ const Login: React.FC = () => { setMessage(null); try { await api.post('auth/token/', { username, password }); - window.location.href = '/'; + // Pull the new session into AuthContext, then SPA-navigate — keeps the + // warm react-query cache instead of a full-page reload. + await refetchUser(); + navigate('/'); } catch (err) { const data = readError(err).response?.data; const errorMsg = data?.non_field_errors?.[0] || t('auth.loginFailed'); diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index c4f2b06..d5d2769 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -32,6 +32,7 @@ import { type PublicProfileRating, } from '../api/queries/usePublicProfile'; import { useCurrentUserLite } from '../api/queries/useCurrentUserLite'; +import { useAuth } from '../hooks/useAuth'; import { useAddProfileComment, useDeleteProfileComment, @@ -54,6 +55,7 @@ const PublicProfile: React.FC = () => { const navigate = useNavigate(); const { data, isLoading: loading } = usePublicProfile(username); const { data: currentUser } = useCurrentUserLite(); + const { refetchFollowing } = useAuth(); const followToggle = useFollowToggle(); const addComment = useAddProfileComment(); const deleteCommentMutation = useDeleteProfileComment(); @@ -106,6 +108,7 @@ const PublicProfile: React.FC = () => { userId: data.id, currentlyFollowing: data.is_following, }); + refetchFollowing(); } catch { notify({ message: 'Failed to update follow status. Please login.', severity: 'error' }); } @@ -117,6 +120,7 @@ const PublicProfile: React.FC = () => { userId: user.id, currentlyFollowing: !!user.is_following, }); + refetchFollowing(); } catch { notify({ message: 'Failed', severity: 'error' }); } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 014678a..90720ad 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -29,7 +29,9 @@ import { } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { useAuth } from '../hooks/useAuth'; import { useTitle } from '../hooks/useTitle'; import type { CatppuccinTheme } from '../theme'; import { PageShell, GlassCard, FormCard } from '../components/ui'; @@ -47,6 +49,8 @@ const readErr = (err: unknown): string | undefined => const Settings: React.FC = ({ themeName, onThemeChange }) => { const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { refetchUser } = useAuth(); useTitle(t('nav.settings')); const { confirm } = useConfirm(); const { anchor: drawerAnchor, setAnchor: setDrawerAnchor } = useDrawerAnchor(); @@ -182,7 +186,11 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { if (!ok) return; try { await confirmDeletion.mutateAsync(deletionCode); - window.location.href = '/'; + // Account gone: clear AuthContext + wipe every cached query (stale user + // data must not survive), then SPA-navigate home. + queryClient.clear(); + await refetchUser(); + navigate('/'); } catch { setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' }); }