perf: batch home publisher hydration (#3070)

This commit is contained in:
Vyctor H. Brzezowski
2026-07-13 11:37:15 -03:00
committed by GitHub
parent cba061d490
commit 4eb4dabc70
5 changed files with 319 additions and 27 deletions
+149
View File
@@ -14,6 +14,7 @@ import {
listPublic,
listMine,
getMyProfileHandle,
getHomePublisherSummaries,
getProfileByHandle,
createMemberInvite,
declineMemberInvite,
@@ -304,6 +305,10 @@ const getProfileByHandleHandler = (
getProfileByHandle as unknown as WrappedHandler<{ handle: string }>
)._handler;
const getHomePublisherSummariesHandler = (
getHomePublisherSummaries as unknown as WrappedHandler<{ handles: string[] }>
)._handler;
const getOgMetaByHandleHandler = (
getOgMetaByHandle as unknown as WrappedHandler<
{ handle: string },
@@ -775,6 +780,150 @@ function makeResolvePublishTargetCtx(options: {
};
}
function makeHomeSummaryPublisher(handle: string, overrides: Record<string, unknown> = {}) {
return {
_id: `publishers:${handle}`,
_creationTime: 1,
kind: "org",
handle,
displayName: handle,
publishedSkills: 2,
publishedPackages: 1,
totalInstalls: 3,
totalDownloads: 4,
totalStars: 5,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}
function makeHomePublisherSummariesCtx(
publishers: Array<ReturnType<typeof makeHomeSummaryPublisher>>,
users: Record<string, Record<string, unknown> | null> = {},
) {
const publishersByHandle = new Map(publishers.map((publisher) => [publisher.handle, publisher]));
const publisherReads: string[] = [];
const query = vi.fn((table: string) => {
if (table !== "publishers") throw new Error(`unexpected table ${table}`);
return {
withIndex: vi.fn(
(
indexName: string,
buildQuery: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
if (indexName !== "by_handle") throw new Error(`unexpected index ${indexName}`);
let handle: string | undefined;
const q = {
eq(field: string, value: unknown) {
if (field === "handle") handle = String(value);
return q;
},
};
buildQuery(q);
return {
unique: vi.fn(async () => {
if (!handle) return null;
publisherReads.push(handle);
return publishersByHandle.get(handle) ?? null;
}),
};
},
),
};
});
const get = vi.fn(async (id: string) => users[id] ?? null);
return { ctx: { db: { query, get } }, publisherReads, query, get };
}
describe("home publisher summaries", () => {
it("rejects more than ten input handles before reading the database", async () => {
const query = vi.fn();
const get = vi.fn();
await expect(
getHomePublisherSummariesHandler({ db: { query, get } } as never, {
handles: Array.from({ length: 11 }, () => "duplicate"),
}),
).rejects.toThrow("at most 10");
expect(query).not.toHaveBeenCalled();
expect(get).not.toHaveBeenCalled();
});
it("normalizes and deduplicates handles while preserving first-seen order", async () => {
const bravo = makeHomeSummaryPublisher("bravo", {
kind: "user",
linkedUserId: "users:bravo",
displayName: "Publisher fallback",
});
const alpha = makeHomeSummaryPublisher("alpha");
const { ctx, publisherReads, get } = makeHomePublisherSummariesCtx([bravo, alpha], {
"users:bravo": {
_id: "users:bravo",
displayName: "Bravo Builder",
image: "https://example.test/bravo.png",
bio: "Builds useful tools.",
},
});
const summaries = await getHomePublisherSummariesHandler(ctx as never, {
handles: [" @Bravo ", "alpha", "BRAVO"],
});
expect(summaries).toEqual([
expect.objectContaining({
handle: "bravo",
displayName: "Bravo Builder",
image: "https://example.test/bravo.png",
bio: "Builds useful tools.",
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
}),
expect.objectContaining({
handle: "alpha",
displayName: "alpha",
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
}),
]);
expect(publisherReads).toEqual(["bravo", "alpha"]);
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith("users:bravo");
});
it("omits incomplete or invisible summaries without reading catalog or membership tables", async () => {
const publishers = [
makeHomeSummaryPublisher("incomplete", { totalStars: undefined }),
makeHomeSummaryPublisher("deactivated", { deactivatedAt: 2 }),
makeHomeSummaryPublisher("deleted", { deletedAt: 2 }),
makeHomeSummaryPublisher("missing-user", {
kind: "user",
linkedUserId: "users:missing",
}),
makeHomeSummaryPublisher("deleted-user", {
kind: "user",
linkedUserId: "users:deleted",
}),
makeHomeSummaryPublisher("deactivated-user", {
kind: "user",
linkedUserId: "users:deactivated",
}),
makeHomeSummaryPublisher("legacy-unlinked", { kind: "user" }),
];
const { ctx, query, get } = makeHomePublisherSummariesCtx(publishers, {
"users:deleted": { _id: "users:deleted", deletedAt: 2 },
"users:deactivated": { _id: "users:deactivated", deactivatedAt: 2 },
});
await expect(
getHomePublisherSummariesHandler(ctx as never, {
handles: [...publishers.map((publisher) => publisher.handle), "not-found"],
}),
).resolves.toEqual([]);
expect(query).toHaveBeenCalledTimes(8);
expect(get).toHaveBeenCalledTimes(3);
});
});
describe("publishers membership controls", () => {
it("lets an org owner delete an org and cascade owned resources", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
+45
View File
@@ -60,6 +60,7 @@ const MAX_PENDING_PUBLISHER_INVITES = 100;
const PUBLISHER_OG_AFFILIATION_LIMIT = 5;
const PUBLISHER_OG_MEMBERSHIP_PAGE_SIZE = 64;
const PUBLISHER_OG_MEMBERSHIP_SCAN_LIMIT = 512;
const MAX_HOME_PUBLISHER_SUMMARIES = 10;
const publisherRoleValidator = v.union(
v.literal("owner"),
v.literal("admin"),
@@ -659,6 +660,27 @@ async function toVisiblePublisherListSummary(
};
}
async function toHomePublisherSummary(
ctx: Pick<QueryCtx, "db">,
publisher: Doc<"publishers"> | null,
) {
if (!publisher || !isPublisherActive(publisher) || !hasPublisherStats(publisher)) return null;
if (publisher.kind === "user" && !publisher.linkedUserId) return null;
const visibility = await getPublicPublisherVisibility(ctx, publisher);
if (!visibility) return null;
const publicPublisher = toPublicPublisher(publisher);
if (!publicPublisher) return null;
return {
...publicPublisher,
displayName: resolvePublisherDisplayName(publisher, visibility.linkedUser),
image: publicPublisher.image ?? visibility.linkedUser?.image,
bio: publicPublisher.bio ?? visibility.linkedUser?.bio,
stats: getPublisherDenormalizedStats(publisher),
};
}
function hasPublisherListContent(summary: PublisherListSummary) {
return summary.item.stats.skills + summary.item.stats.packages > 0;
}
@@ -2257,6 +2279,29 @@ export const getProfileByHandle = query({
},
});
export const getHomePublisherSummaries = query({
args: { handles: v.array(v.string()) },
handler: async (ctx, args) => {
if (args.handles.length > MAX_HOME_PUBLISHER_SUMMARIES) {
throw new ConvexError(`Expected at most ${MAX_HOME_PUBLISHER_SUMMARIES} publisher handles`);
}
const handles = [
...new Set(
args.handles
.map((handle) => normalizePublisherHandle(handle))
.filter((handle): handle is string => Boolean(handle)),
),
];
const summaries = await Promise.all(
handles.map(async (handle) =>
toHomePublisherSummary(ctx, await getPublisherByHandle(ctx, handle)),
),
);
return summaries.filter((summary): summary is NonNullable<typeof summary> => Boolean(summary));
},
});
export const getOgMetaByHandle = query({
args: { handle: v.string() },
handler: async (ctx, args) => {
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const convexQueryMock = vi.fn();
@@ -10,7 +10,7 @@ vi.mock("../convex/client", () => ({
}));
vi.mock("../../convex/_generated/api", () => ({
api: { publishers: { getProfileByHandle: "publishers:getProfileByHandle" } },
api: { publishers: { getHomePublisherSummaries: "publishers:getHomePublisherSummaries" } },
}));
vi.mock("@tanstack/react-router", () => ({
@@ -42,9 +42,88 @@ vi.mock("@tanstack/react-router", () => ({
import { HomePopularPublishersSection } from "../components/HomePopularPublishersSection";
describe("HomePopularPublishersSection", () => {
let intersectionCallback: IntersectionObserverCallback;
beforeEach(() => {
convexQueryMock.mockReset();
convexQueryMock.mockResolvedValue(null);
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
takeRecords = vi.fn(() => []);
root = null;
rootMargin = "600px 0px";
thresholds = [0];
},
);
});
const enterPublisherSection = async () => {
await act(async () => {
intersectionCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
);
await Promise.resolve();
});
};
it("loads all pinned publisher summaries once when the section nears the viewport", async () => {
convexQueryMock.mockResolvedValue([
{
_id: "publishers:openclaw",
_creationTime: 1,
handle: "openclaw",
displayName: "OpenClaw Registry",
kind: "org",
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
},
]);
render(<HomePopularPublishersSection />);
expect(convexQueryMock).not.toHaveBeenCalled();
expect(screen.getAllByText("Explore creator")).toHaveLength(10);
await enterPublisherSection();
await waitFor(() => expect(convexQueryMock).toHaveBeenCalledTimes(1));
expect(convexQueryMock).toHaveBeenCalledWith("publishers:getHomePublisherSummaries", {
handles: [
"openclaw",
"nvidia",
"steipete",
"mvanhorn",
"wscats",
"ivangdavila",
"byungkyu",
"pskoett",
"1kalin",
"spclaudehome",
],
});
expect(screen.getByRole("link", { name: "OpenClaw Registry, @openclaw" })).toBeTruthy();
expect(screen.getByText("Explore 3 items")).toBeTruthy();
await enterPublisherSection();
expect(convexQueryMock).toHaveBeenCalledTimes(1);
});
it("keeps static publisher cards when summary loading fails", async () => {
convexQueryMock.mockRejectedValue(new Error("offline"));
render(<HomePopularPublishersSection />);
await enterPublisherSection();
await waitFor(() => expect(convexQueryMock).toHaveBeenCalledTimes(1));
expect(screen.getByRole("link", { name: "OpenClaw, @openclaw" })).toBeTruthy();
expect(screen.getAllByText("Explore creator")).toHaveLength(10);
});
it("keeps creator cards clickable until the pointer actually drags", () => {
+39 -23
View File
@@ -4,7 +4,7 @@ import { type PointerEvent, useEffect, useRef, useState } from "react";
import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisherListItem } from "../lib/publicUser";
import type { PublicPublisherSummary } from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
type PinnedPublisher = {
@@ -31,12 +31,12 @@ function PopularPublisherCard({
publisher,
}: {
pinned: PinnedPublisher;
publisher?: PublicPublisherListItem;
publisher?: PublicPublisherSummary;
}) {
const name = publisher?.displayName?.trim() || pinned.name;
const bio = publisher?.bio?.trim() || "Publisher on ClawHub.";
const kind = publisher?.kind ?? pinned.kind;
const itemCount = (publisher?.stats?.skills ?? 0) + (publisher?.stats?.packages ?? 0);
const itemCount = publisher ? publisher.stats.skills + publisher.stats.packages : null;
return (
<Link
@@ -58,7 +58,9 @@ function PopularPublisherCard({
<div className="home-v2-popular-publisher-copy">
<p className="home-v2-popular-publisher-bio">{bio}</p>
<span className="home-v2-popular-publisher-stats">
Explore {formatCompactStat(itemCount)} {itemCount === 1 ? "item" : "items"}
{itemCount === null
? "Explore creator"
: `Explore ${formatCompactStat(itemCount)} ${itemCount === 1 ? "item" : "items"}`}
<ArrowRight size={13} aria-hidden="true" />
</span>
</div>
@@ -70,37 +72,51 @@ export function HomePopularPublishersSection() {
const viewportRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ pointerId: -1, startX: 0, scrollLeft: 0, moved: false });
const [dragging, setDragging] = useState(false);
const requestedPublishersRef = useRef(false);
const [publishersByHandle, setPublishersByHandle] = useState<
Record<string, PublicPublisherListItem>
Record<string, PublicPublisherSummary>
>({});
useEffect(() => {
let cancelled = false;
const hydratePublishers = async () => {
// These profile queries compute catalog totals. Keep them serial so the
// homepage does not starve auth and navigation queries on smaller deployments.
for (const pinned of PINNED_PUBLISHERS) {
try {
const publisher = (await convexHttp.query(api.publishers.getProfileByHandle, {
handle: pinned.handle,
})) as PublicPublisherListItem | null;
if (cancelled) return;
if (publisher) {
setPublishersByHandle((current) => ({
...current,
[pinned.handle]: publisher,
}));
}
} catch {
// Static card metadata remains usable when a profile cannot be hydrated.
}
if (requestedPublishersRef.current) return;
requestedPublishersRef.current = true;
try {
const publishers = (await convexHttp.query(api.publishers.getHomePublisherSummaries, {
handles: PINNED_PUBLISHERS.map((publisher) => publisher.handle),
})) as PublicPublisherSummary[];
if (cancelled) return;
setPublishersByHandle(
Object.fromEntries(publishers.map((publisher) => [publisher.handle, publisher])),
);
} catch {
// Static card metadata remains usable when summaries cannot be loaded.
}
};
void hydratePublishers();
const viewport = viewportRef.current;
if (!viewport || typeof IntersectionObserver === "undefined") {
void hydratePublishers();
return () => {
cancelled = true;
};
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
void hydratePublishers();
},
{ rootMargin: "600px 0px" },
);
observer.observe(viewport);
return () => {
cancelled = true;
observer.disconnect();
};
}, []);
+5 -2
View File
@@ -18,6 +18,10 @@ type PublicPublisherStats = {
stars: number;
};
export type PublicPublisherSummary = PublicPublisher & {
stats: PublicPublisherStats;
};
export type PublicPublisherPublishedItem = {
kind: "skill" | "plugin";
displayName: string;
@@ -31,8 +35,7 @@ export type PublicPublisherPublishedItem = {
downloads?: number;
};
export type PublicPublisherListItem = PublicPublisher & {
stats: PublicPublisherStats;
export type PublicPublisherListItem = PublicPublisherSummary & {
publishedItems: PublicPublisherPublishedItem[];
starredCount?: number;
affiliations?: Array<{