mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 01:37:05 +08:00
perf(base): batch page-cell resolution via microtask loader
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import api from "@/lib/api-client";
|
import { expandPagesBatched } from "./page-expand-loader";
|
||||||
|
|
||||||
export type ResolvedPage = {
|
export type ResolvedPage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,11 +13,13 @@ export type ResolvedPage = {
|
|||||||
|
|
||||||
async function resolvePages(pageIds: string[]): Promise<ResolvedPage[]> {
|
async function resolvePages(pageIds: string[]): Promise<ResolvedPage[]> {
|
||||||
if (pageIds.length === 0) return [];
|
if (pageIds.length === 0) return [];
|
||||||
const res = await api.post<{ items: ResolvedPage[] }>(
|
const map = await expandPagesBatched(pageIds);
|
||||||
"/bases/pages/resolve",
|
const out: ResolvedPage[] = [];
|
||||||
{ pageIds },
|
for (const id of pageIds) {
|
||||||
);
|
const p = map.get(id);
|
||||||
return res.data.items;
|
if (p) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable, sorted, deduped list so the query key is consistent across renders
|
// 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 normalized = useMemo(() => normalize(pageIds), [pageIds]);
|
||||||
|
|
||||||
const { data, isSuccess, isLoading } = useQuery({
|
const { data, isSuccess, isLoading } = useQuery({
|
||||||
queryKey: ["bases", "pages", "resolve", normalized],
|
queryKey: ["bases", "pages", "expand", normalized],
|
||||||
queryFn: () => resolvePages(normalized),
|
queryFn: () => resolvePages(normalized),
|
||||||
enabled: normalized.length > 0,
|
enabled: normalized.length > 0,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|||||||
@@ -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<string, ResolvedPage>) => void;
|
||||||
|
reject: (err: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingBatch = {
|
||||||
|
ids: Set<string>;
|
||||||
|
waiters: Waiter[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let pending: PendingBatch | null = null;
|
||||||
|
|
||||||
|
export function expandPagesBatched(
|
||||||
|
ids: readonly string[],
|
||||||
|
): Promise<Map<string, ResolvedPage>> {
|
||||||
|
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<void> {
|
||||||
|
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<string, ResolvedPage>();
|
||||||
|
for (const item of res.data.items) byId.set(item.id, item);
|
||||||
|
|
||||||
|
for (const w of batch.waiters) {
|
||||||
|
const subset = new Map<string, ResolvedPage>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ export class BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('pages/resolve')
|
@Post('pages/expand')
|
||||||
async resolvePages(
|
async resolvePages(
|
||||||
@Body() dto: ResolvePagesDto,
|
@Body() dto: ResolvePagesDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
|
|||||||
Reference in New Issue
Block a user