From 3d5a551233c69f87cfa52c6f22d00969dd0f8bee Mon Sep 17 00:00:00 2001 From: Isaac Priestley Date: Sun, 8 Feb 2026 16:27:19 -0500 Subject: [PATCH 1/4] fix: keep locations tab in sync with websocket location changes --- src/hooks/useLocations.ts | 154 +++++++++++++++++++++++++------------- 1 file changed, 103 insertions(+), 51 deletions(-) diff --git a/src/hooks/useLocations.ts b/src/hooks/useLocations.ts index 85ee1346..65b1ce01 100644 --- a/src/hooks/useLocations.ts +++ b/src/hooks/useLocations.ts @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState, useEffect, useCallback, useRef } from "react" import { useClient, useCampaign } from "@/contexts/AppContext" import type { Location } from "@/types" @@ -34,75 +34,127 @@ export function useLocations(fightId: string | undefined): UseLocationsResult { const [locations, setLocations] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + // Track fight_id from websocket payloads to reliably filter cross-fight updates. + const latestWsFightId = useRef(fightId) + // Tracks shot->location assignments from encounter updates to detect location changes. + const lastLocationSignature = useRef("") - const fetchLocations = useCallback(async () => { - if (!fightId) { - setLocations([]) + useEffect(() => { + latestWsFightId.current = fightId + }, [fightId]) + + const fetchLocations = useCallback( + async (showLoading: boolean = true) => { + if (!fightId) { + setLocations([]) + setError(null) + setLoading(false) + return + } + + if (showLoading) { + setLoading(true) + } setError(null) - setLoading(false) - return - } - - setLoading(true) - setError(null) - - try { - const response = await client.getFightLocations(fightId) - setLocations(response.data.locations || []) - } catch (err) { - console.error("Error fetching locations:", err) - setError("Failed to load locations") - setLocations([]) - } finally { - setLoading(false) - } - }, [fightId, client]) + + try { + const response = await client.getFightLocations(fightId) + setLocations(response.data.locations || []) + } catch (err) { + console.error("Error fetching locations:", err) + setError("Failed to load locations") + setLocations([]) + } finally { + if (showLoading) { + setLoading(false) + } + } + }, + [fightId, client] + ) // Initial fetch useEffect(() => { fetchLocations() }, [fetchLocations]) + // Subscribe to fight_id updates for precise fight scoping. + useEffect(() => { + if (!fightId) return + + const unsubscribe = subscribeToEntity("fight_id", (wsFightId: unknown) => { + if (typeof wsFightId === "string") { + latestWsFightId.current = wsFightId + } + }) + + return unsubscribe + }, [fightId, subscribeToEntity]) + // Subscribe to WebSocket updates for locations - // The locations array contains location objects with fight_id, so we can filter directly + // Accept both direct arrays and wrapped payload shapes. useEffect(() => { if (!fightId) return const unsubscribe = subscribeToEntity("locations", (data: unknown) => { - // Data is an array of Location objects - if (Array.isArray(data)) { - // Filter to only include locations for this fight - // Each location has a fight_id property we can use for verification - const locationsData = data as Location[] - - // If locations array is empty, it might be for any fight - accept it - // If locations have fight_id, verify they're for this fight - const isForThisFight = - locationsData.length === 0 || - locationsData.some(loc => loc.fight_id === fightId) - - if (!isForThisFight) { - console.log( - "📍 [useLocations] Ignoring locations update for different fight" - ) - return - } - - console.log( - "📍 [useLocations] WebSocket locations update received for fight:", - fightId, - "with", - locationsData.length, - "locations" - ) - // Update locations without setting loading=true to avoid flicker - setLocations(locationsData) + const currentFightId = latestWsFightId.current + if (currentFightId && currentFightId !== fightId) { + return } + + const locationsData = Array.isArray(data) + ? (data as Location[]) + : data && + typeof data === "object" && + "locations" in (data as Record) && + Array.isArray((data as { locations: unknown }).locations) + ? ((data as { locations: Location[] }).locations ?? []) + : null + + if (!locationsData) return + + const hasFightIds = locationsData.some(loc => !!loc.fight_id) + const isForThisFight = + locationsData.length === 0 || + !hasFightIds || + locationsData.some(loc => loc.fight_id === fightId) + + if (!isForThisFight) return + + // Update locations without loading state changes to avoid UI flicker. + setLocations(locationsData) }) return unsubscribe }, [fightId, subscribeToEntity]) + // Fallback: when encounter updates change shot->location mapping, refetch locations. + useEffect(() => { + if (!fightId) return + + const unsubscribe = subscribeToEntity("encounter", (data: unknown) => { + if (!data || typeof data !== "object") return + + const encounter = data as { + id?: string + shots?: Array<{ id?: string; location_id?: string | null }> + } + if (encounter.id !== fightId || !Array.isArray(encounter.shots)) return + + const signature = encounter.shots + .map(shot => `${shot.id || ""}:${shot.location_id || ""}`) + .sort() + .join("|") + + if (signature === lastLocationSignature.current) return + + lastLocationSignature.current = signature + fetchLocations(false) + }) + + return unsubscribe + }, [fightId, fetchLocations, subscribeToEntity]) + return { locations, loading, From b15cb649cf856e416d12b2ee5e2e4aba4ef8e946 Mon Sep 17 00:00:00 2001 From: Isaac Priestley Date: Sun, 8 Feb 2026 16:30:58 -0500 Subject: [PATCH 2/4] fix: keep locations sync websocket-only --- src/hooks/useLocations.ts | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/src/hooks/useLocations.ts b/src/hooks/useLocations.ts index 65b1ce01..23170e32 100644 --- a/src/hooks/useLocations.ts +++ b/src/hooks/useLocations.ts @@ -36,8 +36,6 @@ export function useLocations(fightId: string | undefined): UseLocationsResult { const [error, setError] = useState(null) // Track fight_id from websocket payloads to reliably filter cross-fight updates. const latestWsFightId = useRef(fightId) - // Tracks shot->location assignments from encounter updates to detect location changes. - const lastLocationSignature = useRef("") useEffect(() => { latestWsFightId.current = fightId @@ -128,33 +126,6 @@ export function useLocations(fightId: string | undefined): UseLocationsResult { return unsubscribe }, [fightId, subscribeToEntity]) - // Fallback: when encounter updates change shot->location mapping, refetch locations. - useEffect(() => { - if (!fightId) return - - const unsubscribe = subscribeToEntity("encounter", (data: unknown) => { - if (!data || typeof data !== "object") return - - const encounter = data as { - id?: string - shots?: Array<{ id?: string; location_id?: string | null }> - } - if (encounter.id !== fightId || !Array.isArray(encounter.shots)) return - - const signature = encounter.shots - .map(shot => `${shot.id || ""}:${shot.location_id || ""}`) - .sort() - .join("|") - - if (signature === lastLocationSignature.current) return - - lastLocationSignature.current = signature - fetchLocations(false) - }) - - return unsubscribe - }, [fightId, fetchLocations, subscribeToEntity]) - return { locations, loading, From f064447d12ad9a9749110bcce3a46416c5912d86 Mon Sep 17 00:00:00 2001 From: Isaac Priestley Date: Sun, 8 Feb 2026 16:32:33 -0500 Subject: [PATCH 3/4] refactor: enforce strict websocket payload contract for locations --- src/hooks/useLocations.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/hooks/useLocations.ts b/src/hooks/useLocations.ts index 23170e32..16b26160 100644 --- a/src/hooks/useLocations.ts +++ b/src/hooks/useLocations.ts @@ -89,8 +89,8 @@ export function useLocations(fightId: string | undefined): UseLocationsResult { return unsubscribe }, [fightId, subscribeToEntity]) - // Subscribe to WebSocket updates for locations - // Accept both direct arrays and wrapped payload shapes. + // Subscribe to WebSocket updates for locations. + // Contract: "locations" payload is always Location[]. useEffect(() => { if (!fightId) return @@ -100,16 +100,12 @@ export function useLocations(fightId: string | undefined): UseLocationsResult { return } - const locationsData = Array.isArray(data) - ? (data as Location[]) - : data && - typeof data === "object" && - "locations" in (data as Record) && - Array.isArray((data as { locations: unknown }).locations) - ? ((data as { locations: Location[] }).locations ?? []) - : null + if (!Array.isArray(data)) { + console.warn("[useLocations] Ignoring invalid locations payload", data) + return + } - if (!locationsData) return + const locationsData = data as Location[] const hasFightIds = locationsData.some(loc => !!loc.fight_id) const isForThisFight = From 2144aa9e9274f11f9846d80e367f5baadf4ddcfb Mon Sep 17 00:00:00 2001 From: Isaac Priestley Date: Sun, 8 Feb 2026 16:39:41 -0500 Subject: [PATCH 4/4] fix: avoid dropping valid locations websocket updates --- src/hooks/useLocations.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/hooks/useLocations.ts b/src/hooks/useLocations.ts index 16b26160..fe4f5dbc 100644 --- a/src/hooks/useLocations.ts +++ b/src/hooks/useLocations.ts @@ -95,11 +95,6 @@ export function useLocations(fightId: string | undefined): UseLocationsResult { if (!fightId) return const unsubscribe = subscribeToEntity("locations", (data: unknown) => { - const currentFightId = latestWsFightId.current - if (currentFightId && currentFightId !== fightId) { - return - } - if (!Array.isArray(data)) { console.warn("[useLocations] Ignoring invalid locations payload", data) return @@ -108,10 +103,11 @@ export function useLocations(fightId: string | undefined): UseLocationsResult { const locationsData = data as Location[] const hasFightIds = locationsData.some(loc => !!loc.fight_id) - const isForThisFight = - locationsData.length === 0 || - !hasFightIds || - locationsData.some(loc => loc.fight_id === fightId) + const isForThisFight = hasFightIds + ? locationsData.some(loc => loc.fight_id === fightId) + : locationsData.length === 0 + ? !latestWsFightId.current || latestWsFightId.current === fightId + : true if (!isForThisFight) return