From 72fc93dcc1a8580e1f8ab5e06cad0fad2fa46787 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:11:41 +0100 Subject: [PATCH] perf(base): batch page-cell resolution via microtask loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-cell useResolvedPages([id]) calls each mounted with a unique React Query key, so a grid with 20 page-typed cells fired 20 requests on first paint. A shared loader now accumulates incoming ids within a microtask, fires a single POST for the union, and fans the subset each caller asked for back to them. Cells keep their own cache entry + null handling; they just share the underlying network call. Also renames /bases/pages/resolve → /bases/pages/expand — the old name collided with other "resolve" semantics in the codebase. --- .../base/queries/base-page-resolver-query.ts | 16 ++-- .../base/queries/page-expand-loader.ts | 73 +++++++++++++++++++ .../core/base/controllers/base.controller.ts | 2 +- 3 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/features/base/queries/page-expand-loader.ts 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,