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] 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 }; }