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),
+ };
}
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 };
}