diff --git a/__tests__/hooks/useWavesList.test.tsx b/__tests__/hooks/useWavesList.test.tsx index bbe0d43f5f..54127bef6e 100644 --- a/__tests__/hooks/useWavesList.test.tsx +++ b/__tests__/hooks/useWavesList.test.tsx @@ -1,50 +1,56 @@ -import { renderHook } from '@testing-library/react'; -import React from 'react'; -import useWavesList from '@/hooks/useWavesList'; -import { AuthContext } from '@/components/auth/Auth'; -import { ApiWaveType } from '@/generated/models/ApiWaveType'; +import { renderHook } from "@testing-library/react"; +import React from "react"; +import useWavesList from "@/hooks/useWavesList"; +import { AuthContext } from "@/components/auth/Auth"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; -jest.mock('@/hooks/useWavesOverview', () => ({ +jest.mock("@/hooks/useWavesOverview", () => ({ useWavesOverview: jest.fn(), })); -jest.mock('@/hooks/usePinnedWavesServer', () => ({ +jest.mock("@/hooks/usePinnedWavesServer", () => ({ usePinnedWavesServer: jest.fn(), })); -jest.mock('@/hooks/useWaveData', () => ({ +jest.mock("@/hooks/useWaveData", () => ({ useWaveData: jest.fn(), })); -jest.mock('@/hooks/useShowFollowingWaves', () => ({ +jest.mock("@/hooks/useShowFollowingWaves", () => ({ useShowFollowingWaves: jest.fn(() => [false]), })); -const useWavesOverviewMock = require('@/hooks/useWavesOverview').useWavesOverview as jest.Mock; -const usePinnedWavesServerMock = require('@/hooks/usePinnedWavesServer').usePinnedWavesServer as jest.Mock; -const useWaveDataMock = require('@/hooks/useWaveData').useWaveData as jest.Mock; +const useWavesOverviewMock = require("@/hooks/useWavesOverview") + .useWavesOverview as jest.Mock; +const usePinnedWavesServerMock = require("@/hooks/usePinnedWavesServer") + .usePinnedWavesServer as jest.Mock; +const useWaveDataMock = require("@/hooks/useWaveData").useWaveData as jest.Mock; const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - + {children} ); const dmWave = { - id: '1', + id: "1", created_at: 0, metrics: { latest_drop_timestamp: 50 }, wave: { type: ApiWaveType.Chat }, chat: { scope: { group: { is_direct_message: true } } }, } as any; const mainWave = { - id: '2', + id: "2", created_at: 1, metrics: { latest_drop_timestamp: 100 }, wave: { type: ApiWaveType.Rank }, } as any; const pinnedExtra = { - id: '3', + id: "3", created_at: 2, metrics: { latest_drop_timestamp: 200 }, wave: { type: ApiWaveType.Rank }, @@ -58,38 +64,43 @@ beforeEach(() => { isFetchingNextPage: false, hasNextPage: false, fetchNextPage: jest.fn(), - status: 'success', + status: "success", refetch: jest.fn(), }); - usePinnedWavesServerMock.mockReturnValue({ - pinnedIds: ['2', '3'], + usePinnedWavesServerMock.mockReturnValue({ + pinnedIds: ["2", "3"], pinnedWaves: [pinnedExtra], - pinWave: jest.fn(), + pinWave: jest.fn(), unpinWave: jest.fn(), isLoading: false, isError: false, - refetch: jest.fn() + refetch: jest.fn(), + }); + useWaveDataMock.mockReturnValue({ + data: pinnedExtra, + isLoading: false, + isError: false, + refetch: jest.fn(), }); - useWaveDataMock.mockReturnValue({ data: pinnedExtra, isLoading: false, isError: false, refetch: jest.fn() }); }); -test('combines main and pinned waves, filtering DMs and flagging pinned', () => { +test("combines main and pinned waves, filtering DMs and flagging pinned", () => { const { result } = renderHook(() => useWavesList(), { wrapper }); const waves = result.current.waves; - expect(waves.map((w: any) => w.id)).toEqual(['3', '2']); + expect(waves.map((w: any) => w.id)).toEqual(["3", "2"]); expect(waves.every((w: any) => w.isPinned)).toBe(true); - expect(result.current.pinnedWaves.map((w: any) => w.id)).toEqual(['3']); + expect(result.current.pinnedWaves.map((w: any) => w.id)).toEqual(["3"]); }); -test('indicates loading when pinned wave is still loading', () => { - usePinnedWavesServerMock.mockReturnValue({ - pinnedIds: ['2', '3'], +test("indicates loading when pinned wave is still loading", () => { + usePinnedWavesServerMock.mockReturnValue({ + pinnedIds: ["2", "3"], pinnedWaves: [], - pinWave: jest.fn(), + pinWave: jest.fn(), unpinWave: jest.fn(), isLoading: true, isError: false, - refetch: jest.fn() + refetch: jest.fn(), }); const { result } = renderHook(() => useWavesList(), { wrapper }); expect(result.current.isPinnedWavesLoading).toBe(true); diff --git a/hooks/usePinnedWavesServer.ts b/hooks/usePinnedWavesServer.ts index 34b8d87a58..4882e1712a 100644 --- a/hooks/usePinnedWavesServer.ts +++ b/hooks/usePinnedWavesServer.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useContext, useEffect, useRef } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; import { useQuery, useMutation, @@ -78,8 +78,11 @@ export function usePinnedWavesServer(): UsePinnedWavesServerReturn { } }, [isAuthenticated, PINNED_WAVES_QUERY_KEY, queryClient]); - // Derive pinned IDs from pinned waves - const pinnedIds = pinnedWaves.map((wave) => wave.id); + // Derive pinned IDs from pinned waves with stable identity + const pinnedIds = useMemo( + () => pinnedWaves.map((wave) => wave.id), + [pinnedWaves] + ); // Shared invalidation logic for both pin and unpin operations const invalidateWavesQueries = useCallback(() => { diff --git a/hooks/useWavesList.ts b/hooks/useWavesList.ts index ef0625d669..a2d72d8029 100644 --- a/hooks/useWavesList.ts +++ b/hooks/useWavesList.ts @@ -1,13 +1,6 @@ "use client"; -import { - useContext, - useMemo, - useCallback, - useRef, - useEffect, - useState, -} from "react"; +import { useContext, useMemo, useCallback } from "react"; import { AuthContext } from "@/components/auth/Auth"; import { useWavesOverview } from "./useWavesOverview"; import { WAVE_FOLLOWING_WAVES_PARAMS } from "@/components/react-query-wrapper/utils/query-utils"; @@ -21,22 +14,6 @@ interface EnhancedWave extends ApiWave { isPinned: boolean; } -// Helper function for deep comparison of wave arrays -function areWavesEqual(arrA: EnhancedWave[], arrB: EnhancedWave[]): boolean { - if (arrA === arrB) return true; - if (arrA.length !== arrB.length) return false; - - // Compare each wave by ID, updatedAt, isPinned, and muted status - for (let i = 0; i < arrA.length; i++) { - if (arrA[i]?.id !== arrB[i]?.id) return false; - if (arrA[i]?.created_at !== arrB[i]?.created_at) return false; - if (arrA[i]?.isPinned !== arrB[i]?.isPinned) return false; - if (arrA[i]?.metrics.muted !== arrB[i]?.metrics.muted) return false; - } - - return true; -} - /** * Hook for managing and fetching waves list including pinned waves * @returns Wave list data and loading states @@ -53,12 +30,6 @@ const useWavesList = () => { refetch: refetchPinnedWaves, } = usePinnedWavesServer(); const [following] = useShowFollowingWaves(); - // Use state for allWaves instead of ref to ensure reactivity - const [allWaves, setAllWaves] = useState([]); - - // Use ref to avoid too many re-renders for derived values - const prevMainWavesRef = useRef([]); - const prevPinnedWavesRef = useRef([]); // Track connected identity state - memoize to prevent re-renders const isConnectedIdentity = useMemo(() => { @@ -84,7 +55,6 @@ const useWavesList = () => { const mainWavesMap = useMemo(() => { const map = new Map(); mainWaves.forEach((wave) => map.set(wave.id, wave)); - prevMainWavesRef.current = mainWaves; return map; }, [mainWaves]); @@ -94,7 +64,7 @@ const useWavesList = () => { }, [mainWavesMap]); // Server provides full pinned waves data, so no individual fetching needed - const missingPinnedIds: string[] = []; + const missingPinnedIds = useMemo(() => [], []); // Function to refetch all waves (main and pinned) const refetchAllWaves = useCallback(() => { // Refetch main waves overview @@ -120,12 +90,7 @@ const useWavesList = () => { } }); - // Update the ref if content changed - still useful for comparison and memoization - if (!areWavesEqual(result, prevPinnedWavesRef.current)) { - prevPinnedWavesRef.current = result; - } - - return result; // Return the actual result, not the ref + return result; }, [serverPinnedWaves]); // New drops counts are now managed externally @@ -179,18 +144,10 @@ const useWavesList = () => { return allWavesArray; }, [mainWaves, separatelyFetchedPinnedWaves, pinnedIds]); - // New drops counting logic has been removed and will be managed by context + // Derived data should come directly from memoized inputs + const allWaves = combinedWaves; - // Update allWaves state when the combined waves change - useEffect(() => { - // Using a functional update and removing the allWaves dependency - // to break the infinite update cycle - setAllWaves((prevWaves) => { - return !areWavesEqual(combinedWaves, prevWaves) - ? combinedWaves - : prevWaves; - }); - }, [combinedWaves]); + // New drops counting logic has been removed and will be managed by context // Use server-side loading and error states const isPinnedWavesLoading = isPinnedWavesLoadingServer; @@ -205,7 +162,7 @@ const useWavesList = () => { // Components using this hook will only re-render when the values they use actually change return useMemo( () => ({ - // Main data - now using state instead of ref, with enhanced type + // Main data - derived from combined waves, with enhanced type waves: allWaves, // Original waves pagination and loading @@ -225,7 +182,7 @@ const useWavesList = () => { removePinnedWave: unpinWave, // Additional data that might be useful - mainWaves: prevMainWavesRef.current, + mainWaves, missingPinnedIds, mainWavesRefetch, // Refetch all waves including main and pinned @@ -238,12 +195,12 @@ const useWavesList = () => { hasNextPage, fetchNextPageStable, mainWavesStatus, + mainWaves, allPinnedWaves, isPinnedWavesLoading, hasPinnedWavesError, pinWave, unpinWave, - prevMainWavesRef.current, missingPinnedIds, mainWavesRefetch, refetchAllWaves,