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:
Matthew Meszaros
2026-10-14 04:09:39 +00:00
committed by GitHub
11 changed files with 398 additions and 36 deletions
@@ -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 };
}