mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
perf: batch home publisher hydration (#3070)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user