From ca0bc79f3c8d6a4121bfabcfe45a093253374845 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:25:00 +0000 Subject: [PATCH 1/2] feat: reuse dashboard paginated lists across unchanged renders without altering fetch lifecycle --- .../api/hooks/app/campaigns/useCampaigns.ts | 9 +- .../hooks/app/contacts/useSearchContacts.ts | 10 +- .../api/hooks/app/crm/deals/useSearchDeals.ts | 10 +- .../api/hooks/app/crm/tasks/useSearchTasks.ts | 10 +- .../api/hooks/app/emails/useEmails.test.tsx | 79 +++++++++ web/src/lib/api/hooks/app/emails/useEmails.ts | 3 +- .../api/hooks/app/listMemoization.test.tsx | 150 ++++++++++++++++++ .../hooks/app/meetings/useSearchMeetings.ts | 10 +- .../api/hooks/app/unibox/useUniboxSearch.ts | 9 +- 9 files changed, 271 insertions(+), 19 deletions(-) create mode 100644 web/src/lib/api/hooks/app/emails/useEmails.test.tsx create mode 100644 web/src/lib/api/hooks/app/listMemoization.test.tsx diff --git a/web/src/lib/api/hooks/app/campaigns/useCampaigns.ts b/web/src/lib/api/hooks/app/campaigns/useCampaigns.ts index 289aca6a5..cc2c6ab7a 100644 --- a/web/src/lib/api/hooks/app/campaigns/useCampaigns.ts +++ b/web/src/lib/api/hooks/app/campaigns/useCampaigns.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { infiniteQueryOptions, useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; import getCampaigns from "@/lib/api/client/app/campaigns/getCampaigns"; import useAllPages from "@/lib/api/hooks/useAllPages"; @@ -53,10 +54,12 @@ export default function useCampaigns({ query, folder, limit = CAMPAIGNS_PAGE_LIM // Defensive: backend may return `data: null` on empty result sets if // the underlying slice was nil. Coerce + drop nulls so consumers can // safely read fields without optional-chaining every access. - const campaigns = - queryResult.data?.pages + const campaigns = useMemo( + () => queryResult.data?.pages .flatMap((p) => p.data ?? []) - .filter((c): c is NonNullable => c != null) ?? []; + .filter((c): c is NonNullable => c != null) ?? [], + [queryResult.data], + ); return { ...queryResult, diff --git a/web/src/lib/api/hooks/app/contacts/useSearchContacts.ts b/web/src/lib/api/hooks/app/contacts/useSearchContacts.ts index 150a0e235..6456fe039 100644 --- a/web/src/lib/api/hooks/app/contacts/useSearchContacts.ts +++ b/web/src/lib/api/hooks/app/contacts/useSearchContacts.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { infiniteQueryOptions, keepPreviousData, useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; import { DEFAULT_PAGINATION_LIMIT } from "@/lib/information"; import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; @@ -44,9 +45,12 @@ export default function useSearchContacts({ options, limit = DEFAULT_PAGINATION_ // Defensive: backend may serialize a nil slice as JSON null on empty // result sets. `flatMap((p) => p.data)` over null would yield [null]. // Coerce + drop nulls so downstream UIs can safely read fields. - const contacts = queryResult.data?.pages - .flatMap((p) => p.data ?? []) - .filter((c): c is NonNullable => c != null); + const contacts = useMemo( + () => queryResult.data?.pages + .flatMap((p) => p.data ?? []) + .filter((c): c is NonNullable => c != null), + [queryResult.data], + ); return { ...queryResult, diff --git a/web/src/lib/api/hooks/app/crm/deals/useSearchDeals.ts b/web/src/lib/api/hooks/app/crm/deals/useSearchDeals.ts index e22ebe8cc..c635339f3 100644 --- a/web/src/lib/api/hooks/app/crm/deals/useSearchDeals.ts +++ b/web/src/lib/api/hooks/app/crm/deals/useSearchDeals.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { infiniteQueryOptions, useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; import type SearchDeals from "@/lib/api/models/app/crm/SearchDeals"; import type DealsSearchResult from "@/lib/api/models/app/crm/DealsSearchResult"; @@ -35,9 +36,12 @@ export default function useSearchDeals({ filters, limit = 50, enabled = true }: enabled, }); - const deals = queryResult.data?.pages - .flatMap((p) => p.data ?? []) - .filter((d): d is NonNullable => d != null); + const deals = useMemo( + () => queryResult.data?.pages + .flatMap((p) => p.data ?? []) + .filter((d): d is NonNullable => d != null), + [queryResult.data], + ); const total = queryResult.data?.pages[0]?.pagination.total ?? 0; diff --git a/web/src/lib/api/hooks/app/crm/tasks/useSearchTasks.ts b/web/src/lib/api/hooks/app/crm/tasks/useSearchTasks.ts index 90031705d..28f80ec7d 100644 --- a/web/src/lib/api/hooks/app/crm/tasks/useSearchTasks.ts +++ b/web/src/lib/api/hooks/app/crm/tasks/useSearchTasks.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { infiniteQueryOptions, useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; import type SearchTasks from "@/lib/api/models/app/crm/SearchTasks"; import type TasksSearchResult from "@/lib/api/models/app/crm/TasksSearchResult"; @@ -36,9 +37,12 @@ export default function useSearchTasks({ filters, limit = 50, enabled = true }: enabled, }); - const tasks = queryResult.data?.pages - .flatMap((p) => p.data ?? []) - .filter((t): t is NonNullable => t != null); + const tasks = useMemo( + () => queryResult.data?.pages + .flatMap((p) => p.data ?? []) + .filter((t): t is NonNullable => t != null), + [queryResult.data], + ); const total = queryResult.data?.pages[0]?.pagination.total ?? 0; diff --git a/web/src/lib/api/hooks/app/emails/useEmails.test.tsx b/web/src/lib/api/hooks/app/emails/useEmails.test.tsx new file mode 100644 index 000000000..cacc424c5 --- /dev/null +++ b/web/src/lib/api/hooks/app/emails/useEmails.test.tsx @@ -0,0 +1,79 @@ +import type { PropsWithChildren } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import useEmails from "./useEmails"; +import getEmails from "@/lib/api/client/app/emails/getEmails"; +import type Inbox from "@/lib/api/models/app/emails/Inbox"; +import type GetEmails from "@/lib/api/models/app/emails/GetEmails"; + +vi.mock("@/lib/api/client/app/emails/getEmails", () => ({ default: vi.fn() })); + +function harness() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + const wrapper = ({ children }: PropsWithChildren) => {children}; + return { client, wrapper }; +} + +function page(id: string, cursor: string | null): GetEmails { + return { + data: [{ id }] as Inbox[], + pagination: { has_more: cursor !== null, next_cursor: cursor, total: 2 }, + }; +} + +beforeEach(() => vi.resetAllMocks()); + +describe("mailbox query lifecycle", () => { + it("still completes all pages and refreshes both pages on realtime invalidation", async () => { + const { client, wrapper } = harness(); + let revision = 0; + vi.mocked(getEmails).mockImplementation(async (_query, cursor) => cursor ? page(`b-${revision}`, null) : page(`a-${revision}`, "next")); + const { result, rerender, unmount } = renderHook(() => useEmails({ query: "", tag: "" }), { wrapper }); + await waitFor(() => expect(result.current.emails.map((r) => r.id)).toEqual(["a-0", "b-0"])); + expect(getEmails).toHaveBeenCalledTimes(2); + expect(getEmails).toHaveBeenNthCalledWith(1, "", null, "", 200); + expect(getEmails).toHaveBeenNthCalledWith(2, "", "next", "", 200); + const first = result.current.emails; + rerender(); + expect(result.current.emails).toBe(first); + expect(getEmails).toHaveBeenCalledTimes(2); + revision++; + act(() => { void client.invalidateQueries({ queryKey: ["emails"] }); }); + await waitFor(() => expect(result.current.emails.map((r) => r.id)).toEqual(["a-1", "b-1"])); + expect(getEmails).toHaveBeenCalledTimes(4); + expect(result.current.hasNextPage).toBe(false); + unmount(); client.clear(); + }); + + it("respects enabled gating and clears old rows when query or tag changes", async () => { + const { client, wrapper } = harness(); + vi.mocked(getEmails).mockImplementation(async (query, _cursor, tag) => page(`${query}:${tag}`, null)); + const { result, rerender, unmount } = renderHook( + (props) => useEmails(props), + { wrapper, initialProps: { query: "first", tag: "a", enabled: false } }, + ); + expect(getEmails).not.toHaveBeenCalled(); + expect(result.current.emails).toEqual([]); + rerender({ query: "first", tag: "a", enabled: true }); + await waitFor(() => expect(result.current.emails[0]?.id).toBe("first:a")); + rerender({ query: "second", tag: "b", enabled: true }); + expect(result.current.emails).toEqual([]); + await waitFor(() => expect(result.current.emails[0]?.id).toBe("second:b")); + expect(getEmails).toHaveBeenCalledTimes(2); + unmount(); client.clear(); + }); + + it("keeps loaded rows and surfaces a next-page failure without looping requests", async () => { + const { client, wrapper } = harness(); + const error = new Error("next page failed"); + vi.mocked(getEmails).mockResolvedValueOnce(page("a", "next")).mockRejectedValue(error); + const { result, rerender, unmount } = renderHook(() => useEmails({ query: "", tag: "" }), { wrapper }); + await waitFor(() => expect(result.current.isFetchNextPageError).toBe(true)); + expect(result.current.error).toBe(error); + expect(result.current.emails.map((r) => r.id)).toEqual(["a"]); + for (let i = 0; i < 10; i++) rerender(); + expect(getEmails).toHaveBeenCalledTimes(2); + unmount(); client.clear(); + }); +}); diff --git a/web/src/lib/api/hooks/app/emails/useEmails.ts b/web/src/lib/api/hooks/app/emails/useEmails.ts index eb3d94565..5525b7df8 100644 --- a/web/src/lib/api/hooks/app/emails/useEmails.ts +++ b/web/src/lib/api/hooks/app/emails/useEmails.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { infiniteQueryOptions, useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; import type GetEmails from "@/lib/api/models/app/emails/GetEmails"; import getEmails from "@/lib/api/client/app/emails/getEmails"; @@ -42,7 +43,7 @@ export default function useEmails({ query, tag, limit = EMAILS_PAGE_LIMIT, enabl }); const rest = useAllPages(queryResult, enabled); - const emails = queryResult.data?.pages.flatMap((p) => p.data) ?? []; + const emails = useMemo(() => queryResult.data?.pages.flatMap((p) => p.data) ?? [], [queryResult.data]); return { ...queryResult, diff --git a/web/src/lib/api/hooks/app/listMemoization.test.tsx b/web/src/lib/api/hooks/app/listMemoization.test.tsx new file mode 100644 index 000000000..cf4ff13dc --- /dev/null +++ b/web/src/lib/api/hooks/app/listMemoization.test.tsx @@ -0,0 +1,150 @@ +import { useMemo } from "react"; +import type * as ReactQuery from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import useEmails from "@/lib/api/hooks/app/emails/useEmails"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts"; +import useUniboxSearch from "@/lib/api/hooks/app/unibox/useUniboxSearch"; +import useSearchTasks from "@/lib/api/hooks/app/crm/tasks/useSearchTasks"; +import useSearchDeals from "@/lib/api/hooks/app/crm/deals/useSearchDeals"; +import useSearchMeetings from "@/lib/api/hooks/app/meetings/useSearchMeetings"; +import { EMPTY_TASK_SEARCH } from "@/lib/api/models/app/crm/SearchTasks"; +import { EMPTY_DEAL_SEARCH } from "@/lib/api/models/app/crm/SearchDeals"; +import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts"; + +type Row = { id: string }; +type Page = { data?: (Row | null)[] | null; pagination: { total: number } }; + +const state = vi.hoisted(() => ({ + query: { + data: undefined as { pages: Page[] } | undefined, + isFetching: false, + isError: false, + error: null as Error | null, + }, +})); + +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...await importOriginal(), + useInfiniteQuery: () => state.query, +})); +vi.mock("@/lib/api/hooks/useAllPages", () => ({ default: vi.fn() })); +vi.mock("@/hooks/useRealtimeFallback", () => ({ default: () => false })); + +const contactsOptions: SearchContacts = { + query: "", custom_field_filters: [], campaign_ids: [], sort_by: "updated_at", reverse: false, +}; + +const lists: { name: string; useList: () => Row[] | undefined; unloaded: Row[] | undefined }[] = [ + { name: "mailboxes", useList: () => useEmails({ query: "", tag: "" }).emails, unloaded: [] }, + { name: "campaigns", useList: () => useCampaigns({ query: "", folder: "" }).campaigns, unloaded: [] }, + { name: "contacts", useList: () => useSearchContacts({ options: contactsOptions }).contacts, unloaded: undefined }, + { name: "unibox", useList: () => useUniboxSearch({}, "all").emails, unloaded: [] }, + { name: "tasks", useList: () => useSearchTasks({ filters: EMPTY_TASK_SEARCH }).tasks, unloaded: undefined }, + { name: "deals", useList: () => useSearchDeals({ filters: EMPTY_DEAL_SEARCH }).deals, unloaded: undefined }, + { name: "meetings", useList: () => useSearchMeetings({ filters: {} }).meetings, unloaded: undefined }, +]; + +function page(data: Page["data"]): Page { + return { data, pagination: { total: data?.length ?? 0 } }; +} + +beforeEach(() => { + state.query = { data: undefined, isFetching: false, isError: false, error: null }; +}); + +describe.each(lists)("$name list", ({ name, useList, unloaded }) => { + it("keeps the unloaded shape and loaded row identities/order", () => { + const { result, rerender } = renderHook(useList); + expect(result.current).toEqual(unloaded); + const empty = result.current; + rerender(); + expect(result.current).toBe(empty); + const a = { id: "a" }, b = { id: "b" }; + state.query.data = { pages: [page([a]), page([b])] }; + rerender(); + expect(result.current).toEqual([a, b]); + expect(result.current?.[0]).toBe(a); + expect(result.current?.[1]).toBe(b); + }); + + it("does not traverse loaded pages on unrelated renders or fetch/error flag changes", () => { + const read = vi.fn(() => [{ id: "a" }]); + state.query.data = { pages: [{ get data() { return read(); }, pagination: { total: 1 } }] }; + const { result, rerender } = renderHook(useList); + const first = result.current; + expect(read).toHaveBeenCalledTimes(1); + for (let i = 0; i < 10; i++) { + state.query = { ...state.query, isFetching: i % 2 === 0 }; + rerender(); + expect(result.current).toBe(first); + } + state.query = { ...state.query, isError: true, error: new Error("refetch failed") }; + rerender(); + expect(result.current).toBe(first); + expect(read).toHaveBeenCalledTimes(1); + }); + + it("recomputes on append, changed row, removal and cache reset", () => { + const a = { id: "a" }, b = { id: "b" }; + state.query.data = { pages: [page([a])] }; + const { result, rerender } = renderHook(useList); + state.query.data = { pages: [...state.query.data.pages, page([b])] }; + rerender(); + expect(result.current).toEqual([a, b]); + const updated = { id: "a", title: "refreshed" }; + state.query.data = { pages: [page([updated]), page([b])] }; + rerender(); + expect(result.current?.[0]).toBe(updated); + state.query.data = { pages: [page([b])] }; + rerender(); + expect(result.current).toEqual([b]); + state.query.data = undefined; + rerender(); + expect(result.current).toEqual(unloaded); + }); + + if (name !== "mailboxes") { + it("preserves defensive null and missing payload handling", () => { + state.query.data = { pages: [page(null), page(undefined), page([{ id: "a" }, null])] }; + const { result } = renderHook(useList); + expect(result.current).toEqual([{ id: "a" }]); + }); + } +}); + +it("measures repeated list work with 4,000 mailboxes and periodic immutable refreshes", () => { + let pageReads = 0, sorts = 0; + const rows = Array.from({ length: 4_000 }, (_, i) => ({ id: String(i).padStart(5, "0") })); + function snapshot() { + return { + pages: Array.from({ length: 20 }, (_, i) => { + const data = rows.slice(i * 200, (i + 1) * 200); + return { + get data() { pageReads++; return data; }, + pagination: { total: rows.length }, + }; + }), + }; + } + state.query.data = snapshot(); + const start = performance.now(); + const { result, rerender, unmount } = renderHook(() => { + const mailboxes = useEmails({ query: "", tag: "" }).emails; + return useMemo(() => { + sorts++; + return [...mailboxes].sort((a, b) => b.id.localeCompare(a.id)); + }, [mailboxes]); + }); + for (let i = 1; i <= 200; i++) { + if (i % 20 === 0) state.query.data = snapshot(); + rerender(); + } + expect(result.current).toHaveLength(4_000); + expect(result.current[0].id).toBe("03999"); + unmount(); + console.info(JSON.stringify({ fixture: "4,000 mailboxes; initial + 200 renders; 10 refreshes", pageReads, sorts, elapsedMs: performance.now() - start })); + expect(pageReads).toBe(220); + expect(sorts).toBe(11); +}); diff --git a/web/src/lib/api/hooks/app/meetings/useSearchMeetings.ts b/web/src/lib/api/hooks/app/meetings/useSearchMeetings.ts index ab72ffafb..22d1a222b 100644 --- a/web/src/lib/api/hooks/app/meetings/useSearchMeetings.ts +++ b/web/src/lib/api/hooks/app/meetings/useSearchMeetings.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { infiniteQueryOptions, useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; import type { MeetingsPage, MeetingsSearch } from "@/lib/api/models/app/integrations/Integration"; import searchMeetings from "@/lib/api/client/app/meetings/searchMeetings"; @@ -33,9 +34,12 @@ export default function useSearchMeetings({ filters, limit = 50, enabled = true enabled, }); - const meetings = queryResult.data?.pages - .flatMap((p) => p.data ?? []) - .filter((m): m is NonNullable => m != null); + const meetings = useMemo( + () => queryResult.data?.pages + .flatMap((p) => p.data ?? []) + .filter((m): m is NonNullable => m != null), + [queryResult.data], + ); const total = queryResult.data?.pages[0]?.pagination.total ?? 0; diff --git a/web/src/lib/api/hooks/app/unibox/useUniboxSearch.ts b/web/src/lib/api/hooks/app/unibox/useUniboxSearch.ts index 3b8fddae7..777896c71 100644 --- a/web/src/lib/api/hooks/app/unibox/useUniboxSearch.ts +++ b/web/src/lib/api/hooks/app/unibox/useUniboxSearch.ts @@ -1,5 +1,6 @@ // Infinite scroll over the inbox search endpoint. +import { useMemo } from "react"; import { infiniteQueryOptions, useInfiniteQuery, type InfiniteData } from "@tanstack/react-query"; import searchIncoming from "@/lib/api/client/app/unibox/searchIncoming"; import type { UniboxSearchParams } from "@/lib/api/models/app/unibox/UniboxSearch"; @@ -37,10 +38,12 @@ export default function useUniboxSearch(params: UniboxSearchParams, scopeKey: st // Flatten + drop any null rows just in case the server returns a nil // slice (defensive — mirrors the contacts / campaigns hooks). - const emails = - q.data?.pages + const emails = useMemo( + () => q.data?.pages .flatMap((p) => p.data ?? []) - .filter((e): e is NonNullable => e != null) ?? []; + .filter((e): e is NonNullable => e != null) ?? [], + [q.data], + ); return { ...q, emails }; } From 6a6cc3d94ab02d8194e10bab187865ede4318b99 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:25:00 +0000 Subject: [PATCH 2/2] feat: stabilize mailbox status query options and combine callback to avoid repeated aggregation --- .../app/analytics/useAccountStatuses.test.tsx | 104 ++++++++++++++++++ .../hooks/app/analytics/useAccountStatuses.ts | 40 ++++--- 2 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 web/src/lib/api/hooks/app/analytics/useAccountStatuses.test.tsx diff --git a/web/src/lib/api/hooks/app/analytics/useAccountStatuses.test.tsx b/web/src/lib/api/hooks/app/analytics/useAccountStatuses.test.tsx new file mode 100644 index 000000000..5850c3518 --- /dev/null +++ b/web/src/lib/api/hooks/app/analytics/useAccountStatuses.test.tsx @@ -0,0 +1,104 @@ +import type { PropsWithChildren } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import useAccountStatuses from "@/lib/api/hooks/app/analytics/useAccountStatuses"; +import getAccountStatuses from "@/lib/api/client/app/analytics/getAccountStatuses"; +import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; + +vi.mock("@/lib/api/client/app/analytics/getAccountStatuses", () => ({ default: vi.fn() })); + +function harness() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } }); + const wrapper = ({ children }: PropsWithChildren) => {children}; + return { client, wrapper }; +} + +function status(id: string): AccountStatus { + return { id } as AccountStatus; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAccountStatuses).mockImplementation(async (ids) => ids.map(status)); +}); + +describe("account status aggregation", () => { + it("keeps sorted, bounded keys without mutating the input or refetching on reorder", async () => { + const { client, wrapper } = harness(); + const ids = Array.from({ length: 205 }, (_, i) => String(204 - i).padStart(3, "0")); + const original = [...ids]; + const { result, rerender, unmount } = renderHook(({ ids }) => useAccountStatuses(ids), { wrapper, initialProps: { ids } }); + await waitFor(() => expect(result.current.data).toHaveLength(205)); + expect(ids).toEqual(original); + expect(vi.mocked(getAccountStatuses).mock.calls.map(([chunk]) => chunk.length)).toEqual([100, 100, 5]); + expect(result.current.data.map((s) => s.id)).toEqual([...ids].sort()); + expect(getAccountStatuses).toHaveBeenCalledTimes(3); + const first = result.current; + rerender({ ids: [...ids].reverse() }); + expect(result.current).toBe(first); + expect(getAccountStatuses).toHaveBeenCalledTimes(3); + expect(client.getQueryCache().getAll().every((q) => q.queryKey.slice(0, 3).join("/") === "analytics/accounts/list")).toBe(true); + unmount(); client.clear(); + }); + + it("skips per-row aggregation on unchanged renders and recomputes for realtime data", async () => { + const { client, wrapper } = harness(); + const ids = Array.from({ length: 200 }, (_, i) => String(i)); + const { result, rerender, unmount } = renderHook(() => useAccountStatuses(ids), { wrapper }); + await waitFor(() => expect(result.current.data).toHaveLength(200)); + const first = result.current; + const query = client.getQueryCache().getAll()[0]; + // Every aggregation maps each chunk into observations. + const observationMaps = client.getQueryCache().getAll().map((q) => vi.spyOn(q.state.data as AccountStatus[], "map")); + const start = performance.now(); + for (let i = 0; i < 200; i++) rerender(); + console.info(JSON.stringify({ fixture: "200 statuses; two 100-row chunks; 200 unchanged renders", observationMaps: observationMaps.reduce((sum, spy) => sum + spy.mock.calls.length, 0), elapsedMs: performance.now() - start })); + expect(result.current).toBe(first); + for (const spy of observationMaps) { + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + } + act(() => client.setQueryData(query.queryKey, [status("updated")])); + await waitFor(() => expect(result.current.data[0].id).toBe("updated")); + expect(result.current.observations[0]).toMatchObject({ status: status("updated"), current: true }); + unmount(); client.clear(); + }); + + it("preserves enabled gating, empty selection and source-revision freshness", async () => { + const { client, wrapper } = harness(); + const { result, rerender, unmount } = renderHook( + ({ ids, enabled, revision }) => useAccountStatuses(ids, { enabled, sourceRevision: revision }), + { wrapper, initialProps: { ids: [] as string[], enabled: false, revision: 0 } }, + ); + expect(result.current.data).toEqual([]); + expect(getAccountStatuses).not.toHaveBeenCalled(); + rerender({ ids: ["a"], enabled: false, revision: 0 }); + expect(getAccountStatuses).not.toHaveBeenCalled(); + rerender({ ids: ["a"], enabled: true, revision: 0 }); + await waitFor(() => expect(result.current.data).toEqual([status("a")])); + rerender({ ids: ["b"], enabled: true, revision: 1 }); + await waitFor(() => expect(result.current.data).toEqual([status("b")])); + expect(getAccountStatuses).toHaveBeenCalledTimes(2); + expect(client.getQueryCache().getAll().some((q) => q.queryKey[4] === 1)).toBe(true); + unmount(); client.clear(); + }); + + it("marks retained observations non-current during refetch and on refetch failure", async () => { + const { client, wrapper } = harness(); + const ids = ["a"]; + const { result, unmount } = renderHook(() => useAccountStatuses(ids), { wrapper }); + await waitFor(() => expect(result.current.observations[0]?.current).toBe(true)); + const observedAt = result.current.observations[0].observedAt; + let reject!: (reason: Error) => void; + vi.mocked(getAccountStatuses).mockImplementationOnce(() => new Promise((_resolve, rej) => { reject = rej; })); + act(() => { void client.invalidateQueries({ queryKey: ["analytics", "accounts"] }); }); + await waitFor(() => expect(result.current.isFetching).toBe(true)); + expect(result.current.observations[0]).toMatchObject({ observedAt, current: false }); + act(() => reject(new Error("refetch failed"))); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toEqual([status("a")]); + expect(result.current.observations[0]).toMatchObject({ observedAt, current: false }); + unmount(); client.clear(); + }); +}); diff --git a/web/src/lib/api/hooks/app/analytics/useAccountStatuses.ts b/web/src/lib/api/hooks/app/analytics/useAccountStatuses.ts index f6355053b..e135688b5 100644 --- a/web/src/lib/api/hooks/app/analytics/useAccountStatuses.ts +++ b/web/src/lib/api/hooks/app/analytics/useAccountStatuses.ts @@ -1,4 +1,5 @@ -import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useQueries, type UseQueryResult } from "@tanstack/react-query"; import getAccountStatuses from "@/lib/api/client/app/analytics/getAccountStatuses"; import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; @@ -22,22 +23,27 @@ export default function useAccountStatuses(emailIds: string[], options?: { enabl // Sort so a chunk's id set (and its query key) is stable regardless of the // order the mailbox list arrived in; adding a mailbox only refetches the // chunk it lands in. - const chunks = chunk([...emailIds].sort()); - - return useQueries({ - queries: chunks.map((ids) => ({ - queryKey: ["analytics", "accounts", "list", ids, options?.sourceRevision ?? 0], + const enabled = options?.enabled ?? true; + const sourceRevision = options?.sourceRevision ?? 0; + const queries = useMemo( + () => chunk([...emailIds].sort()).map((ids) => ({ + queryKey: ["analytics", "accounts", "list", ids, sourceRevision], queryFn: ({ signal }: { signal: AbortSignal }) => getAccountStatuses(ids, signal), - enabled: ids.length > 0 && (options?.enabled ?? true), + enabled: ids.length > 0 && enabled, })), - // react-query memoizes this by result reference, so the merged array is - // stable across renders until a chunk actually updates. - combine: (results) => ({ - data: results.flatMap((r) => (r.data ?? []) as AccountStatus[]), - observations: results.flatMap((r) => (r.data ?? []).map((status) => ({ status, observedAt: r.dataUpdatedAt, current: r.isSuccess && !r.isFetching }))), - isLoading: results.some((r) => r.isLoading), - isFetching: results.some((r) => r.isFetching), - isError: results.some((r) => r.isError), - }), - }); + [emailIds, enabled, sourceRevision], + ); + + return useQueries({ queries, combine: combineAccountStatuses }); +} + +// TanStack reuses the aggregation only while both results and this callback are stable. +function combineAccountStatuses(results: UseQueryResult[]) { + return { + data: results.flatMap((r) => r.data ?? []), + observations: results.flatMap((r) => (r.data ?? []).map((status) => ({ status, observedAt: r.dataUpdatedAt, current: r.isSuccess && !r.isFetching }))), + isLoading: results.some((r) => r.isLoading), + isFetching: results.some((r) => r.isFetching), + isError: results.some((r) => r.isError), + }; }