mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-31 16:13:14 +00:00
Merge pull request #967 from warmbly/devin/1791947120-cpu-frontends-ready
feat: avoid rebuilding unchanged dashboard lists and mailbox status query aggregation
This commit is contained in:
@@ -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) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<AccountStatus[], Error>[]) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof c> => c != null) ?? [];
|
||||
.filter((c): c is NonNullable<typeof c> => c != null) ?? [],
|
||||
[queryResult.data],
|
||||
);
|
||||
|
||||
return {
|
||||
...queryResult,
|
||||
|
||||
@@ -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<typeof c> => c != null);
|
||||
const contacts = useMemo(
|
||||
() => queryResult.data?.pages
|
||||
.flatMap((p) => p.data ?? [])
|
||||
.filter((c): c is NonNullable<typeof c> => c != null),
|
||||
[queryResult.data],
|
||||
);
|
||||
|
||||
return {
|
||||
...queryResult,
|
||||
|
||||
@@ -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<typeof d> => d != null);
|
||||
const deals = useMemo(
|
||||
() => queryResult.data?.pages
|
||||
.flatMap((p) => p.data ?? [])
|
||||
.filter((d): d is NonNullable<typeof d> => d != null),
|
||||
[queryResult.data],
|
||||
);
|
||||
|
||||
const total = queryResult.data?.pages[0]?.pagination.total ?? 0;
|
||||
|
||||
|
||||
@@ -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<typeof t> => t != null);
|
||||
const tasks = useMemo(
|
||||
() => queryResult.data?.pages
|
||||
.flatMap((p) => p.data ?? [])
|
||||
.filter((t): t is NonNullable<typeof t> => t != null),
|
||||
[queryResult.data],
|
||||
);
|
||||
|
||||
const total = queryResult.data?.pages[0]?.pagination.total ?? 0;
|
||||
|
||||
|
||||
@@ -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) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof ReactQuery>(),
|
||||
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);
|
||||
});
|
||||
@@ -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<typeof m> => m != null);
|
||||
const meetings = useMemo(
|
||||
() => queryResult.data?.pages
|
||||
.flatMap((p) => p.data ?? [])
|
||||
.filter((m): m is NonNullable<typeof m> => m != null),
|
||||
[queryResult.data],
|
||||
);
|
||||
|
||||
const total = queryResult.data?.pages[0]?.pagination.total ?? 0;
|
||||
|
||||
|
||||
@@ -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<typeof e> => e != null) ?? [];
|
||||
.filter((e): e is NonNullable<typeof e> => e != null) ?? [],
|
||||
[q.data],
|
||||
);
|
||||
|
||||
return { ...q, emails };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user