diff --git a/frontend/app/core/contexts/SearchContext.tsx b/frontend/app/core/contexts/SearchContext.tsx index b444cb6..956ca34 100644 --- a/frontend/app/core/contexts/SearchContext.tsx +++ b/frontend/app/core/contexts/SearchContext.tsx @@ -1,4 +1,5 @@ import { createContext, useState, useCallback, type ReactNode } from "react" +import { useInvalidateServers } from "@/core/hooks/useInvalidateServers" interface SearchContextType { searchQuery: string @@ -14,10 +15,14 @@ export { SearchContext } export function SearchProvider({ children }: { children: ReactNode }) { const [searchQuery, setSearchQuery] = useState("") const [refreshCounter, setRefreshCounter] = useState(0) + const invalidateServers = useInvalidateServers() const triggerRefresh = useCallback(() => { setRefreshCounter(prev => prev + 1) - }, []) + // Le nouveau serveur doit apparaître dans le Ctrl+K (SearchBar) et dans + // la home : on invalide les deux caches pour forcer un refetch client. + invalidateServers() + }, [invalidateServers]) return ( diff --git a/frontend/app/core/hooks/useInvalidateServers.ts b/frontend/app/core/hooks/useInvalidateServers.ts new file mode 100644 index 0000000..13b6c9e --- /dev/null +++ b/frontend/app/core/hooks/useInvalidateServers.ts @@ -0,0 +1,19 @@ +import { useCallback } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { SERVERS_QUERY_KEY, SERVERS_SEARCH_QUERY_KEY } from "@/core/lib/query-keys" + +/** + * Retourne un callback qui invalide les caches globaux de la liste des + * serveurs (home + recherche Ctrl+K) pour forcer un refetch client. + * + * À appeler après toute mutation qui change la liste ou son contenu : + * ajout, suppression, renommage, changement d'IP/favicon, visibilité. + */ +export function useInvalidateServers() { + const queryClient = useQueryClient() + + return useCallback(() => { + queryClient.invalidateQueries({ queryKey: SERVERS_QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: SERVERS_SEARCH_QUERY_KEY }) + }, [queryClient]) +} diff --git a/frontend/app/core/lib/query-keys.ts b/frontend/app/core/lib/query-keys.ts new file mode 100644 index 0000000..a7c842f --- /dev/null +++ b/frontend/app/core/lib/query-keys.ts @@ -0,0 +1,17 @@ +/** + * Clés TanStack Query partagées pour la liste des serveurs. + * + * Centralisées ici pour éviter les chaînes magiques dupliquées (typos, + * renommages partiels) : toute lecture/écriture/invalidation de ces caches + * doit passer par ces constantes. + */ + +/** Liste globale des serveurs (home, `include_stats=true`), amorcée par le SSR. */ +export const SERVERS_QUERY_KEY = ["servers"] as const + +/** + * Préfixe du cache de la recherche Ctrl+K. + * La clé complète est `[..., refreshCounter]` ; l'invalidation par préfixe + * (sans `exact: true`) couvre toutes les générations. + */ +export const SERVERS_SEARCH_QUERY_KEY = ["servers-search"] as const diff --git a/frontend/app/core/lib/utils.test.ts b/frontend/app/core/lib/utils.test.ts new file mode 100644 index 0000000..81568dc --- /dev/null +++ b/frontend/app/core/lib/utils.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest" +import { registerTranslations } from "./i18n" +import { formatDate, formatNumber } from "./utils" + +// Dictionnaires minimaux : formatDate ne dépend que de common.months et +// common.dateFormat (même contrat que les locales réelles). +registerTranslations("fr", { + common: { + months: { + 0: "janvier", + 8: "septembre", + 9: "octobre", + }, + dateFormat: "{{day}} {{month}} {{year}}", + }, +}) + +describe("formatDate", () => { + // 13 octobre 2026 23:30 UTC : en Europe/Paris (UTC+2) il est déjà le 14, + // à Kiritimati (UTC+14) le 14 à 13:30. Avec des getters locaux, le HTML + // SSR (UTC) et le rendu client divergeaient -> erreur d'hydratation #418. + const nearMidnightUtc = Date.UTC(2026, 9, 13, 23, 30) + + it("renders the same UTC calendar date regardless of runtime timezone", () => { + const outputs = new Set() + for (const tz of ["UTC", "Europe/Paris", "America/New_York", "Pacific/Kiritimati", "Australia/Sydney"]) { + process.env.TZ = tz + outputs.add(formatDate(nearMidnightUtc, "fr")) + } + process.env.TZ = "UTC" + expect(outputs.size).toBe(1) + expect([...outputs][0]).toBe("13 octobre 2026") + }) + + it("formats a regular date", () => { + process.env.TZ = "Europe/Paris" + expect(formatDate(Date.UTC(2026, 8, 5, 12, 0), "fr")).toBe("5 septembre 2026") + process.env.TZ = "UTC" + }) +}) + +describe("formatNumber", () => { + it("groups thousands deterministically per language", () => { + expect(formatNumber("fr", 1234567)).toBe("1\u202F234\u202F567") + expect(formatNumber("en", 1234567)).toBe("1,234,567") + expect(formatNumber("fr", 0)).toBe("0") + }) +}) diff --git a/frontend/app/core/lib/utils.ts b/frontend/app/core/lib/utils.ts index 29f57df..b9dcdcc 100644 --- a/frontend/app/core/lib/utils.ts +++ b/frontend/app/core/lib/utils.ts @@ -68,13 +68,18 @@ export function formatNumber(language: string | undefined | null, value: number) * navigateur (noms de mois, ordre) et peut casser l'hydratation (#418). * Les noms de mois et le gabarit de format viennent des fichiers de * traduction (common.months / common.dateFormat). + * + * La date est lue en UTC : `getDate()`/`getMonth()` utilisent le fuseau local, + * qui diffère entre le serveur SSR (UTC) et le navigateur (ex. Europe/Paris), + * ce qui faisait diverger le HTML SSR et le rendu client pour les timestamps + * proches de minuit (erreur d'hydratation #418 sur la page serveur). */ export function formatDate(timestamp: number, language: string | undefined | null): string { const lang = acceptableLanguage(language) const d = new Date(timestamp) - const day = d.getDate() - const year = d.getFullYear() - const month = translate(lang, `common.months.${d.getMonth()}`) + const day = d.getUTCDate() + const year = d.getUTCFullYear() + const month = translate(lang, `common.months.${d.getUTCMonth()}`) const template = translate(lang, "common.dateFormat") return template .replaceAll("{{day}}", String(day)) diff --git a/frontend/app/pages/account/index.tsx b/frontend/app/pages/account/index.tsx index e3c945c..6332171 100644 --- a/frontend/app/pages/account/index.tsx +++ b/frontend/app/pages/account/index.tsx @@ -9,6 +9,7 @@ import type { MetaFunction } from "react-router" import { useWebPush } from "@/core/hooks/useWebPush" import { useToast } from "@/core/hooks/useToast" +import { useInvalidateServers } from "@/core/hooks/useInvalidateServers" import { AccountServersTab } from "./components/AccountServersTab" import { AccountAlertsTab } from "./components/AccountAlertsTab" import { AccountProfileTab } from "./components/AccountProfileTab" @@ -46,6 +47,7 @@ export default function Account() { const [servers, setServers] = useState([]) const [allAlerts, setAllAlerts] = useState([]) const [loading, setLoading] = useState(true) + const invalidateServers = useInvalidateServers() // Web Push State from Custom Hook const { @@ -88,6 +90,13 @@ export default function Account() { } }, [getToken, isLoaded, isSignedIn]) + // Après un renommage : on recharge "mes serveurs" ET on invalide le cache + // global pour que la home et le Ctrl+K affichent le nouveau nom. + const handleServersMutated = useCallback(() => { + loadData() + invalidateServers() + }, [loadData, invalidateServers]) + useEffect(() => { if (isLoaded) { // eslint-disable-next-line react-hooks/set-state-in-effect @@ -216,7 +225,7 @@ export default function Account() { t={t} loading={loading} servers={servers} - loadData={loadData} + loadData={handleServersMutated} /> )} diff --git a/frontend/app/pages/admin/index.tsx b/frontend/app/pages/admin/index.tsx index 4684e2d..f49da6d 100644 --- a/frontend/app/pages/admin/index.tsx +++ b/frontend/app/pages/admin/index.tsx @@ -5,6 +5,7 @@ import { useAuth } from "@clerk/react" import { useLanguage } from "@/core/contexts/LanguageContext" import { useAdmin } from "@/core/contexts/AdminContext" import { useToast } from "@/core/hooks/useToast" +import { useInvalidateServers } from "@/core/hooks/useInvalidateServers" import { fetchAdminUsers, fetchServers, toggleServerVisibility } from "@/core/lib/api" import type { User, Server } from "@/core/lib/api" import { Button } from "@/ui/components/button" @@ -80,6 +81,7 @@ export default function AdminDashboard() { const [togglingServerId, setTogglingServerId] = useState(null) const { showToast: triggerToast } = useToast() + const invalidateServers = useInvalidateServers() const loadData = useCallback(async () => { if (!isAdmin) return @@ -104,6 +106,14 @@ export default function AdminDashboard() { } }, [isAdmin, getToken, t, triggerToast]) + // Après une mutation (delete/rename/IP/favicon/visibilité) : on recharge le + // tableau admin ET on invalide le cache global pour que la home et le + // Ctrl+K reflètent le changement sans attendre l'expiration du staleTime. + const refreshAfterMutation = useCallback(() => { + loadData() + invalidateServers() + }, [loadData, invalidateServers]) + useEffect(() => { if (isLoaded && isAdmin) { // eslint-disable-next-line react-hooks/set-state-in-effect @@ -124,6 +134,7 @@ export default function AdminDashboard() { if (result.success) { setServers(prev => prev.map(s => s.id === serverId ? { ...s, hidden: !currentHidden } : s)) + invalidateServers() triggerToast("success", t("admin.toast.visibilitySuccess", { name: serverName })) } else { triggerToast("error", result.message_key ? t(result.message_key) : result.message || t("common.error")) @@ -345,7 +356,7 @@ export default function AdminDashboard() { handleToggleServer={handleToggleServer} getUserDisplayName={getUserDisplayName} t={t} - onRefresh={loadData} + onRefresh={refreshAfterMutation} triggerToast={triggerToast} /> )} diff --git a/frontend/app/pages/home/components/Hero3D.tsx b/frontend/app/pages/home/components/Hero3D.tsx index 0d6e279..38d33eb 100644 --- a/frontend/app/pages/home/components/Hero3D.tsx +++ b/frontend/app/pages/home/components/Hero3D.tsx @@ -3,6 +3,7 @@ import { ArrowDown } from "lucide-react" import { useLanguage } from "@/core/contexts/LanguageContext" import { useTheme } from "@/core/hooks/useTheme" import type { Server as ServerType } from "@/core/lib/api" +import { formatNumber } from "@/core/lib/utils" import { resolveToken, withAlpha } from "@/core/lib/theme-colors" // Predefined heights for the graph nodes (representing server player count stats) @@ -24,19 +25,26 @@ const points = defaultYValues.map((y, idx) => { } }) -// Animated counter hook (module-level to comply with React rules of hooks) +// Animated counter hook (module-level to comply with React rules of hooks). +// SSR-safe : le compteur démarre toujours à 0 (comme le HTML serveur) et la +// préférence `prefers-reduced-motion` n'est lue que dans l'effet, sinon le +// premier rendu client diverge du SSR -> erreur d'hydratation #418. function useAnimatedCount(target: number, duration = 1200) { - const [prefersReducedMotion] = useState( - () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches, - ) const [prevTarget, setPrevTarget] = useState(target) - const [count, setCount] = useState(() => (prefersReducedMotion ? target : 0)) + const [count, setCount] = useState(0) if (prevTarget !== target) { setPrevTarget(target) - setCount(prefersReducedMotion ? target : 0) + setCount(0) } useEffect(() => { - if (prefersReducedMotion || target === 0) return + if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + // Synchro au montage (pas de rendu serveur divergent : SSR et + // premier rendu client partent de 0). + // eslint-disable-next-line react-hooks/set-state-in-effect + setCount(target) + return + } + if (target === 0) return let start = 0 const step = target / (duration / 16) const timer = setInterval(() => { @@ -49,12 +57,12 @@ function useAnimatedCount(target: number, duration = 1200) { } }, 16) return () => clearInterval(timer) - }, [target, duration, prefersReducedMotion]) + }, [target, duration]) return count } export function Hero3D({ servers = [] }: { servers?: ServerType[] }) { - const { t } = useLanguage() + const { t, language } = useLanguage() const { theme } = useTheme() const canvasRef = useRef(null) const containerRef = useRef(null) @@ -429,7 +437,7 @@ export function Hero3D({ servers = [] }: { servers?: ServerType[] }) {

- {animatedTotal.toLocaleString()}+ + {formatNumber(language, animatedTotal)}+

{t("hero.stats.servers")} @@ -440,7 +448,7 @@ export function Hero3D({ servers = [] }: { servers?: ServerType[] }) {

- {animatedOnline.toLocaleString()} + {formatNumber(language, animatedOnline)}

{t("hero.stats.online")} @@ -451,7 +459,7 @@ export function Hero3D({ servers = [] }: { servers?: ServerType[] }) {

- {animatedPlayers.toLocaleString()} + {formatNumber(language, animatedPlayers)}

{t("hero.stats.players")} diff --git a/frontend/app/pages/home/index.tsx b/frontend/app/pages/home/index.tsx index 9b2310e..4972fc5 100644 --- a/frontend/app/pages/home/index.tsx +++ b/frontend/app/pages/home/index.tsx @@ -15,6 +15,7 @@ import { useAuth } from "@clerk/react" import { useAdmin } from "@/core/contexts/AdminContext" import { useSearch } from "@/core/hooks/useSearch" import { useLanguage } from "@/core/contexts/LanguageContext" +import { SERVERS_QUERY_KEY } from "@/core/lib/query-keys" import { Hero3D } from "@/pages/home/components/Hero3D" import { FaqSection } from "@/pages/home/components/FaqSection" @@ -130,7 +131,7 @@ function ServerListContent({ initialServers }: { initialServers: Server[] }) { // données SSR. Remplace l'ancien doublon load()/handleRefresh(), les // caches module-level et le refetch manuel au montage. const { data: servers = initialServers || [], isPending, isFetching, error, refetch } = useQuery({ - queryKey: ["servers"], + queryKey: SERVERS_QUERY_KEY, queryFn: async () => { const token = isLoaded && isSignedIn ? await getToken() : undefined return fetchServers(token ?? undefined, true) diff --git a/frontend/app/ui/layout/SearchBar.tsx b/frontend/app/ui/layout/SearchBar.tsx index 83ed87f..fd0dd9b 100644 --- a/frontend/app/ui/layout/SearchBar.tsx +++ b/frontend/app/ui/layout/SearchBar.tsx @@ -12,6 +12,7 @@ import { useNavigate } from "react-router" import { useQuery, useQueryClient } from "@tanstack/react-query" import { useLanguage } from "@/core/contexts/LanguageContext" import { useSearch } from "@/core/hooks/useSearch" +import { SERVERS_QUERY_KEY, SERVERS_SEARCH_QUERY_KEY } from "@/core/lib/query-keys" import { MINUTE_MS } from "@/core/lib/time" import { LunarLogo } from "@/ui/components/LunarLogo" @@ -57,11 +58,15 @@ export function SearchBar({ value: propValue, onChange: propOnChange, onSelect, const queryClient = useQueryClient() const { data: allServers = [] } = useQuery({ - queryKey: ["servers-search", refreshCounter], + queryKey: [...SERVERS_SEARCH_QUERY_KEY, refreshCounter], queryFn: async () => { // Optimisation : Si la homepage a déjà chargé les serveurs (via SSR ou client), on les réutilise - const cachedServers = queryClient.getQueryData(["servers"]) - if (cachedServers && cachedServers.length > 0) { + // SAUF si un refresh a été demandé (ajout de serveur) : le cache ["servers"] + // est alors invalidé et on doit refetcher pour que le nouveau serveur + // apparaisse dans le Ctrl+K. + const cachedServers = queryClient.getQueryData(SERVERS_QUERY_KEY) + const serversState = queryClient.getQueryState(SERVERS_QUERY_KEY) + if (cachedServers && cachedServers.length > 0 && !serversState?.isInvalidated) { return cachedServers } diff --git a/frontend/app/ui/layout/ThemeToggle.tsx b/frontend/app/ui/layout/ThemeToggle.tsx index 338e5a5..2bf7b6d 100644 --- a/frontend/app/ui/layout/ThemeToggle.tsx +++ b/frontend/app/ui/layout/ThemeToggle.tsx @@ -8,7 +8,12 @@ export function ThemeToggle() { const { t } = useLanguage() return ( -