diff --git a/apps/client/src/features/base/queries/base-page-resolver-query.ts b/apps/client/src/features/base/queries/base-page-resolver-query.ts index d9e475110..71361e0e1 100644 --- a/apps/client/src/features/base/queries/base-page-resolver-query.ts +++ b/apps/client/src/features/base/queries/base-page-resolver-query.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import api from "@/lib/api-client"; +import { expandPagesBatched } from "./page-expand-loader"; export type ResolvedPage = { id: string; @@ -13,11 +13,13 @@ export type ResolvedPage = { async function resolvePages(pageIds: string[]): Promise { if (pageIds.length === 0) return []; - const res = await api.post<{ items: ResolvedPage[] }>( - "/bases/pages/resolve", - { pageIds }, - ); - return res.data.items; + const map = await expandPagesBatched(pageIds); + const out: ResolvedPage[] = []; + for (const id of pageIds) { + const p = map.get(id); + if (p) out.push(p); + } + return out; } // Stable, sorted, deduped list so the query key is consistent across renders @@ -46,7 +48,7 @@ export function useResolvedPages( const normalized = useMemo(() => normalize(pageIds), [pageIds]); const { data, isSuccess, isLoading } = useQuery({ - queryKey: ["bases", "pages", "resolve", normalized], + queryKey: ["bases", "pages", "expand", normalized], queryFn: () => resolvePages(normalized), enabled: normalized.length > 0, staleTime: 30_000, diff --git a/apps/client/src/features/base/queries/page-expand-loader.ts b/apps/client/src/features/base/queries/page-expand-loader.ts new file mode 100644 index 000000000..a1dc7b9e9 --- /dev/null +++ b/apps/client/src/features/base/queries/page-expand-loader.ts @@ -0,0 +1,73 @@ +import api from "@/lib/api-client"; +import type { ResolvedPage } from "./base-page-resolver-query"; + +/* + * Per-cell `useResolvedPages([id])` calls each mount with a different + * queryKey, so React Query can't dedupe them — a 20-row grid with a page + * column used to fire 20 requests on first paint. This loader sits under + * the hook: incoming id lists accumulate in a pending batch, a microtask + * flushes a single `POST /bases/pages/expand` for the union, and each + * caller's promise resolves with a Map containing just the ids it asked + * for. Unknown ids are absent from the map (hook treats absence as "not + * accessible", matching the old per-call semantics). + * + * Microtask window = one React commit. Cells that mount in a single + * commit batch together; cells added later (e.g. infinite-scroll page + * load) form their own batch. Good enough in practice; a setTimeout(0) + * window would widen it but adds an observable delay. + */ + +type Waiter = { + requestedIds: readonly string[]; + resolve: (m: Map) => void; + reject: (err: unknown) => void; +}; + +type PendingBatch = { + ids: Set; + waiters: Waiter[]; +}; + +let pending: PendingBatch | null = null; + +export function expandPagesBatched( + ids: readonly string[], +): Promise> { + if (ids.length === 0) return Promise.resolve(new Map()); + + return new Promise((resolve, reject) => { + if (!pending) { + pending = { ids: new Set(), waiters: [] }; + queueMicrotask(flush); + } + for (const id of ids) pending.ids.add(id); + pending.waiters.push({ requestedIds: ids, resolve, reject }); + }); +} + +async function flush(): Promise { + const batch = pending; + pending = null; + if (!batch) return; + + const unionIds = Array.from(batch.ids); + try { + const res = await api.post<{ items: ResolvedPage[] }>( + "/bases/pages/expand", + { pageIds: unionIds }, + ); + const byId = new Map(); + for (const item of res.data.items) byId.set(item.id, item); + + for (const w of batch.waiters) { + const subset = new Map(); + for (const id of w.requestedIds) { + const page = byId.get(id); + if (page) subset.set(id, page); + } + w.resolve(subset); + } + } catch (err) { + for (const w of batch.waiters) w.reject(err); + } +} diff --git a/apps/server/src/core/base/controllers/base.controller.ts b/apps/server/src/core/base/controllers/base.controller.ts index a646a7c28..b9407bac3 100644 --- a/apps/server/src/core/base/controllers/base.controller.ts +++ b/apps/server/src/core/base/controllers/base.controller.ts @@ -143,7 +143,7 @@ export class BaseController { } @HttpCode(HttpStatus.OK) - @Post('pages/resolve') + @Post('pages/expand') async resolvePages( @Body() dto: ResolvePagesDto, @AuthUser() user: User,