From 0c4814f4d65ea6adfaec4f56c64266c0211a4feb Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Wed, 19 Aug 2026 00:40:02 +0200 Subject: [PATCH] =?UTF-8?q?Corrige=20le=20Realtime=20qui=20ne=20diffusait?= =?UTF-8?q?=20aucun=20=C3=A9v=C3=A9nement=20=C3=A0=20travers=20l'app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les canaux Realtime (Char, Gardien, classement, journal, calendrier, Icare) s'abonnaient juste après createClient(), avant que la session ne soit chargée de façon asynchrone — le canal rejoignait donc Supabase en rôle "anon" au lieu de "authenticated", et les policies RLS "to authenticated" bloquaient silencieusement tous les événements (l'abonnement lui-même réussissait, ce qui masquait le problème). Ajoute waitForRealtimeAuth() dans src/lib/supabase/client.ts, qui attend la session et pousse explicitement le token avant de s'abonner. Vérifié avec deux sessions navigateur distinctes : les changements se propagent maintenant réellement sans rechargement. --- src/app/calendrier/calendrier-view.tsx | 23 +++++---- src/app/char/char-view.tsx | 25 ++++++---- src/app/char/questions/questions-view.tsx | 21 ++++++--- src/app/icare/icare-view.tsx | 23 +++++---- src/app/journal/journal-view.tsx | 33 +++++++------ src/app/leaderboard/leaderboard-view.tsx | 57 +++++++++++++---------- src/lib/supabase/client.ts | 16 +++++++ 7 files changed, 128 insertions(+), 70 deletions(-) diff --git a/src/app/calendrier/calendrier-view.tsx b/src/app/calendrier/calendrier-view.tsx index 13802ba..e2b64b6 100644 --- a/src/app/calendrier/calendrier-view.tsx +++ b/src/app/calendrier/calendrier-view.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import { DayCard, type DayRow } from "./day-card"; import { PANTHEON_PRESETS, getGodPreset } from "@/lib/pantheon"; import { IconPlus } from "@/components/icons"; @@ -69,15 +69,22 @@ export function CalendrierView({ useEffect(() => { const supabase = createClient(); - const channel = supabase - .channel("calendrier-changes") - .on("postgres_changes", { event: "*", schema: "public", table: "days" }, refetch) - .on("postgres_changes", { event: "*", schema: "public", table: "events" }, refetch) - .on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) - .subscribe(); + let channel: ReturnType | null = null; + let cancelled = false; + + waitForRealtimeAuth(supabase).then(() => { + if (cancelled) return; + channel = supabase + .channel("calendrier-changes") + .on("postgres_changes", { event: "*", schema: "public", table: "days" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "events" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) + .subscribe(); + }); return () => { - supabase.removeChannel(channel); + cancelled = true; + if (channel) supabase.removeChannel(channel); }; }, []); diff --git a/src/app/char/char-view.tsx b/src/app/char/char-view.tsx index 6e7051e..8bbc94a 100644 --- a/src/app/char/char-view.tsx +++ b/src/app/char/char-view.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import { EntryRanking } from "./entry-ranking"; import { GardienBar } from "./gardien-bar"; import { RouletteSection } from "./roulette-section"; @@ -64,16 +64,23 @@ export function CharView({ useEffect(() => { const supabase = createClient(); - const channel = supabase - .channel("char-changes") - .on("postgres_changes", { event: "*", schema: "public", table: "chariot_slots" }, refetch) - .on("postgres_changes", { event: "*", schema: "public", table: "chariot_entries" }, refetch) - .on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch) - .on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) - .subscribe(); + let channel: ReturnType | null = null; + let cancelled = false; + + waitForRealtimeAuth(supabase).then(() => { + if (cancelled) return; + channel = supabase + .channel("char-changes") + .on("postgres_changes", { event: "*", schema: "public", table: "chariot_slots" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "chariot_entries" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) + .subscribe(); + }); return () => { - supabase.removeChannel(channel); + cancelled = true; + if (channel) supabase.removeChannel(channel); }; }, [refetch]); diff --git a/src/app/char/questions/questions-view.tsx b/src/app/char/questions/questions-view.tsx index 79d43b2..9fcf671 100644 --- a/src/app/char/questions/questions-view.tsx +++ b/src/app/char/questions/questions-view.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { IconChevronDown, IconPencil, IconPlus, IconTrash } from "@/components/icons"; -import { createClient } from "@/lib/supabase/client"; +import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import type { QuestionRow } from "./page"; function QuestionForm({ @@ -211,14 +211,21 @@ export function QuestionsView({ useEffect(() => { const supabase = createClient(); - const channel = supabase - .channel("char-questions-changes") - .on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch) - .on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) - .subscribe(); + let channel: ReturnType | null = null; + let cancelled = false; + + waitForRealtimeAuth(supabase).then(() => { + if (cancelled) return; + channel = supabase + .channel("char-questions-changes") + .on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) + .subscribe(); + }); return () => { - supabase.removeChannel(channel); + cancelled = true; + if (channel) supabase.removeChannel(channel); }; }, [refetch]); diff --git a/src/app/icare/icare-view.tsx b/src/app/icare/icare-view.tsx index d0ccb37..6847bc3 100644 --- a/src/app/icare/icare-view.tsx +++ b/src/app/icare/icare-view.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Avatar } from "@/components/avatar"; import { computeRanksBy } from "@/lib/ranking"; -import { createClient } from "@/lib/supabase/client"; +import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import { IcarusGame } from "./icarus-game"; import type { IcarusScoreEntry } from "./page"; @@ -45,15 +45,22 @@ export function IcareView({ useEffect(() => { const supabase = createClient(); - const channel = supabase - .channel("icarus-scores") - .on("postgres_changes", { event: "*", schema: "public", table: "icarus_scores" }, () => { - refetch(); - }) - .subscribe(); + let channel: ReturnType | null = null; + let cancelled = false; + + waitForRealtimeAuth(supabase).then(() => { + if (cancelled) return; + channel = supabase + .channel("icarus-scores") + .on("postgres_changes", { event: "*", schema: "public", table: "icarus_scores" }, () => { + refetch(); + }) + .subscribe(); + }); return () => { - supabase.removeChannel(channel); + cancelled = true; + if (channel) supabase.removeChannel(channel); }; }, [refetch]); diff --git a/src/app/journal/journal-view.tsx b/src/app/journal/journal-view.tsx index 4225390..2dd7de4 100644 --- a/src/app/journal/journal-view.tsx +++ b/src/app/journal/journal-view.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; type Entry = { id: number; @@ -31,20 +31,27 @@ export function JournalView({ useEffect(() => { const supabase = createClient(); - const channel = supabase - .channel("journal-points-log") - .on( - "postgres_changes", - { event: "INSERT", schema: "public", table: "points_log" }, - (payload) => { - const row = payload.new as Entry; - setEntries((current) => [row, ...current]); - }, - ) - .subscribe(); + let channel: ReturnType | null = null; + let cancelled = false; + + waitForRealtimeAuth(supabase).then(() => { + if (cancelled) return; + channel = supabase + .channel("journal-points-log") + .on( + "postgres_changes", + { event: "INSERT", schema: "public", table: "points_log" }, + (payload) => { + const row = payload.new as Entry; + setEntries((current) => [row, ...current]); + }, + ) + .subscribe(); + }); return () => { - supabase.removeChannel(channel); + cancelled = true; + if (channel) supabase.removeChannel(channel); }; }, []); diff --git a/src/app/leaderboard/leaderboard-view.tsx b/src/app/leaderboard/leaderboard-view.tsx index 3468056..bb5543f 100644 --- a/src/app/leaderboard/leaderboard-view.tsx +++ b/src/app/leaderboard/leaderboard-view.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import { Avatar } from "@/components/avatar"; import { Podium } from "@/components/podium"; import { JudgePointControls } from "@/components/judge-point-controls"; @@ -47,34 +47,41 @@ export function LeaderboardView({ useEffect(() => { const supabase = createClient(); - const channel = supabase - .channel("leaderboard-profiles") - .on( - "postgres_changes", - { event: "*", schema: "public", table: "profiles" }, - (payload) => { - if (payload.eventType === "DELETE") return; - const row = payload.new as Profile; + let channel: ReturnType | null = null; + let cancelled = false; - setProfiles((current) => { - const exists = current.some((p) => p.id === row.id); - return exists - ? current.map((p) => (p.id === row.id ? { ...p, ...row } : p)) - : [...current, row]; - }); + waitForRealtimeAuth(supabase).then(() => { + if (cancelled) return; + channel = supabase + .channel("leaderboard-profiles") + .on( + "postgres_changes", + { event: "*", schema: "public", table: "profiles" }, + (payload) => { + if (payload.eventType === "DELETE") return; + const row = payload.new as Profile; - const previousPoints = pointsRef.current.get(row.id); - if (previousPoints !== undefined && previousPoints !== row.points) { - setFlashingId(row.id); - setTimeout(() => setFlashingId((current) => (current === row.id ? null : current)), 900); - } - pointsRef.current.set(row.id, row.points); - }, - ) - .subscribe(); + setProfiles((current) => { + const exists = current.some((p) => p.id === row.id); + return exists + ? current.map((p) => (p.id === row.id ? { ...p, ...row } : p)) + : [...current, row]; + }); + + const previousPoints = pointsRef.current.get(row.id); + if (previousPoints !== undefined && previousPoints !== row.points) { + setFlashingId(row.id); + setTimeout(() => setFlashingId((current) => (current === row.id ? null : current)), 900); + } + pointsRef.current.set(row.id, row.points); + }, + ) + .subscribe(); + }); return () => { - supabase.removeChannel(channel); + cancelled = true; + if (channel) supabase.removeChannel(channel); }; }, []); diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 2ad2591..39ce5b3 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -1,4 +1,5 @@ import { createBrowserClient } from "@supabase/ssr"; +import type { SupabaseClient } from "@supabase/supabase-js"; export function createClient() { return createBrowserClient( @@ -6,3 +7,18 @@ export function createClient() { process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ); } + +// createClient() charge la session (et donc le token Realtime) de façon +// asynchrone après coup : un .channel().subscribe() lancé juste après +// rejoint le canal encore en rôle "anon", et les policies RLS +// "to authenticated" ne laissent alors jamais passer aucun événement +// (le canal s'abonne "avec succès" mais ne reçoit plus rien ensuite). +// À appeler avant tout .channel(...).subscribe(). +export async function waitForRealtimeAuth(supabase: SupabaseClient) { + const { + data: { session }, + } = await supabase.auth.getSession(); + if (session) { + supabase.realtime.setAuth(session.access_token); + } +}