fix(web): bound canonical skills SSR loading (#3399)

This commit is contained in:
Vyctor H. Brzezowski
2026-08-05 14:22:47 -03:00
committed by GitHub
parent 82e73637ed
commit dc9da89d3b
3 changed files with 325 additions and 39 deletions
+156 -1
View File
@@ -2,7 +2,11 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Route as SkillsRoute, SkillsIndex } from "../routes/skills/index";
import {
Route as SkillsRoute,
SKILLS_INITIAL_PAGE_TIMEOUT_MS,
SkillsIndex,
} from "../routes/skills/index";
import {
convexHttpMock,
convexReactMocks,
@@ -12,6 +16,7 @@ import {
const navigateMock = vi.fn();
const fetchCatalogDiscoveryCapabilitiesMock = vi.fn();
const fetchCanonicalTrendingPageMock = vi.fn();
let searchMock: Record<string, unknown> = {};
let loaderDataMock: unknown = null;
@@ -20,6 +25,14 @@ vi.mock("../lib/catalogDiscoveryCapabilities", () => ({
fetchCatalogDiscoveryCapabilitiesMock(...args),
}));
vi.mock("../lib/trendingApi", async (importOriginal) => {
const original = await importOriginal<typeof import("../lib/trendingApi")>();
return {
...original,
fetchCanonicalTrendingPage: (...args: unknown[]) => fetchCanonicalTrendingPageMock(...args),
};
});
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component: unknown; validateSearch: unknown }) => ({
__config: config,
@@ -60,6 +73,18 @@ describe("SkillsIndex", () => {
apiVersion: 1,
canonicalTrendingEnabled: true,
});
fetchCanonicalTrendingPageMock.mockReset();
fetchCanonicalTrendingPageMock.mockResolvedValue({
kind: "skills",
snapshotId: "snapshot-1",
snapshotCursor: "snapshot-cursor",
generatedAt: "2026-08-04T00:00:00.000Z",
windowHours: 24,
rankingVersion: "skills-trending-v1",
totalItems: 0,
items: [],
nextCursor: null,
});
});
afterEach(() => {
@@ -101,6 +126,108 @@ describe("SkillsIndex", () => {
);
});
it("loads the canonical first page on the server and excludes view-only state", async () => {
const routeConfig = (
SkillsRoute as unknown as {
__config: {
loaderDeps: (args: { search: Record<string, unknown> }) => Record<string, unknown>;
loader: (args: {
deps: Record<string, unknown>;
abortController: AbortController;
}) => unknown;
validateSearch: (search: Record<string, unknown>) => Record<string, unknown>;
};
}
).__config;
const canonicalSearch = routeConfig.validateSearch({});
const controller = new AbortController();
const firstPage = {
kind: "skills",
snapshotId: "snapshot-1",
snapshotCursor: "snapshot-cursor",
generatedAt: "2026-08-04T00:00:00.000Z",
windowHours: 24,
rankingVersion: "skills-trending-v1",
totalItems: 1,
items: [makeTrendingResult("server-skill", "Server Skill")],
nextCursor: "cursor-2",
};
fetchCanonicalTrendingPageMock.mockResolvedValue(firstPage);
const canonicalDeps = routeConfig.loaderDeps({ search: canonicalSearch });
expect(
routeConfig.loaderDeps({ search: routeConfig.validateSearch({ view: "grid" }) }),
).toEqual(canonicalDeps);
expect(
routeConfig.loaderDeps({ search: routeConfig.validateSearch({ tab: "new" }) }),
).not.toEqual(canonicalDeps);
await expect(
routeConfig.loader({ deps: canonicalDeps, abortController: controller }),
).resolves.toEqual({
kind: "canonical",
results: [{ trending: firstPage.items[0] }],
nextCursor: "cursor-2",
trendingState: "available",
});
expect(fetchCanonicalTrendingPageMock).toHaveBeenCalledTimes(1);
expect(fetchCanonicalTrendingPageMock).toHaveBeenCalledWith({
cursor: null,
limit: 20,
signal: expect.any(AbortSignal),
});
});
it("releases the initial response when the canonical page exceeds its latency budget", async () => {
vi.useFakeTimers();
const routeConfig = (
SkillsRoute as unknown as {
__config: {
loader: (args: {
deps: Record<string, unknown>;
abortController: AbortController;
}) => Promise<unknown>;
loaderDeps: (args: { search: Record<string, unknown> }) => Record<string, unknown>;
validateSearch: (search: Record<string, unknown>) => Record<string, unknown>;
};
}
).__config;
let requestSignal: AbortSignal | undefined;
fetchCanonicalTrendingPageMock.mockImplementation(
({ signal }: { signal: AbortSignal }) =>
new Promise((_, reject) => {
requestSignal = signal;
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
}),
);
const deps = routeConfig.loaderDeps({ search: routeConfig.validateSearch({}) });
const result = routeConfig.loader({ deps, abortController: new AbortController() });
await vi.advanceTimersByTimeAsync(SKILLS_INITIAL_PAGE_TIMEOUT_MS);
await expect(result).resolves.toBeNull();
expect(requestSignal?.aborted).toBe(true);
expect(requestSignal?.reason).toEqual(expect.objectContaining({ name: "TimeoutError" }));
});
it("renders canonical loader data without a duplicate first-page request", async () => {
searchMock = {};
loaderDataMock = {
kind: "canonical",
results: [{ trending: makeTrendingResult("server-skill", "Server Skill") }],
nextCursor: "cursor-2",
trendingState: "available",
};
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByText("Server Skill")).toBeTruthy();
expect(screen.getByRole("button", { name: "Load more" })).toBeTruthy();
expect(fetchCatalogDiscoveryCapabilitiesMock).not.toHaveBeenCalled();
expect(fetchCanonicalTrendingPageMock).not.toHaveBeenCalled();
});
it("requests the first skills page", async () => {
render(<SkillsIndex />);
await act(async () => {});
@@ -1186,3 +1313,31 @@ function makeSearchEntry(params: {
if (entry.native) entry.native.skill.stats.stars = params.stars;
return entry;
}
function makeTrendingResult(slug: string, displayName: string) {
return {
id: `clawhub:${slug}`,
source: "clawhub" as const,
slug,
displayName,
summary: `${displayName} summary`,
canonicalUrl: `/owner/${slug}`,
publisher: {
kind: "user" as const,
handle: "owner",
displayName: "Owner",
image: null,
official: false,
},
official: false,
featured: false,
metrics: {
trending24hDownloads: null,
trending24hInstalls: 1,
trending24hBookmarks: null,
lifetimeInstalls: 100,
lifetimeInstallsPeriod: "lifetime" as const,
updatedAt: 1,
},
};
}
+45 -11
View File
@@ -18,7 +18,7 @@ import {
type SkillSearchEntry,
} from "./-types";
const pageSize = 20;
export const SKILLS_PAGE_SIZE = 20;
const featuredPageSize = 40;
const newWindowMs = 14 * 24 * 60 * 60 * 1_000;
const maxConsecutiveEmptyPagesPerFetch = 3;
@@ -72,6 +72,13 @@ export type InitialSkillsSearchData = {
results: SkillSearchEntry[];
} | null;
export type InitialSkillsListData = {
kind: "canonical";
results: SkillListEntry[];
nextCursor: string | null;
trendingState: TrendingFeedState;
};
type SkillsNavigate = (options: {
search: (prev: SkillsSearchState) => SkillsSearchState;
replace?: boolean;
@@ -97,11 +104,13 @@ export function buildSkillsSearchKey({
}
export function useSkillsBrowseModel({
initialList,
initialSearch,
search,
navigate,
searchInputRef,
}: {
initialList?: InitialSkillsListData;
initialSearch?: InitialSkillsSearchData;
search: SkillsSearchState;
navigate: SkillsNavigate;
@@ -161,18 +170,28 @@ export function useSkillsBrowseModel({
matchedInitialSearch ? matchedInitialSearch.results : [],
);
const [searchLimit, setSearchLimit] = useState(() =>
matchedInitialSearch ? matchedInitialSearch.limit : pageSize,
matchedInitialSearch ? matchedInitialSearch.limit : SKILLS_PAGE_SIZE,
);
const [isSearching, setIsSearching] = useState(() => hasQuery && !initialSearchMatches);
const appliedInitialSearchKey = useRef(matchedInitialSearch ? matchedInitialSearch.key : null);
// One-shot paginated fetches (no reactive subscription)
const [listResults, setListResults] = useState<SkillListEntry[]>([]);
const [listCursor, setListCursor] = useState<string | null>(null);
const [listStatus, setListStatus] = useState<ListStatus>("loading");
const [trendingState, setTrendingState] = useState<TrendingFeedState | undefined>();
const matchedInitialList = !hasQuery && requestedCatalogTab === "trending" ? initialList : null;
const [listResults, setListResults] = useState<SkillListEntry[]>(
() => matchedInitialList?.results ?? [],
);
const [listCursor, setListCursor] = useState<string | null>(
() => matchedInitialList?.nextCursor ?? null,
);
const [listStatus, setListStatus] = useState<ListStatus>(() =>
matchedInitialList?.nextCursor ? "idle" : matchedInitialList ? "done" : "loading",
);
const [trendingState, setTrendingState] = useState<TrendingFeedState | undefined>(
() => matchedInitialList?.trendingState,
);
const [, setListAutoLoadPaused] = useState(false);
const fetchGeneration = useRef(0);
const appliedInitialList = useRef(matchedInitialList);
const newCutoff = useMemo(() => Date.now() - newWindowMs, [catalogTab]);
const fetchPage = useCallback(
@@ -197,7 +216,7 @@ export function useSkillsBrowseModel({
const result = await fetchCanonicalTrendingPage({
cursor: pageCursor,
limit: pageSize,
limit: SKILLS_PAGE_SIZE,
});
if (generation !== fetchGeneration.current) return;
const entries = result.items.map((trending) => ({ trending }));
@@ -216,7 +235,7 @@ export function useSkillsBrowseModel({
while (true) {
const result = await convexHttp.query(api.skills.listPublicPageV4, {
cursor: pageCursor ?? undefined,
numItems: catalogTab === "featured" ? featuredPageSize : pageSize,
numItems: catalogTab === "featured" ? featuredPageSize : SKILLS_PAGE_SIZE,
...(listSort ? { sort: listSort } : {}),
dir,
highlightedOnly: catalogTab === "featured" ? true : undefined,
@@ -302,6 +321,21 @@ export function useSkillsBrowseModel({
}
fetchGeneration.current += 1;
const generation = fetchGeneration.current;
if (matchedInitialList) {
if (appliedInitialList.current !== matchedInitialList) {
setCanonicalTrendingUnavailable(false);
setListResults(matchedInitialList.results);
setListCursor(matchedInitialList.nextCursor);
setListAutoLoadPaused(false);
setTrendingState(matchedInitialList.trendingState);
setListStatus(matchedInitialList.nextCursor ? "idle" : "done");
appliedInitialList.current = matchedInitialList;
}
return () => {
fetchGeneration.current += 1;
};
}
appliedInitialList.current = null;
setListResults([]);
setListCursor(null);
setListAutoLoadPaused(false);
@@ -311,7 +345,7 @@ export function useSkillsBrowseModel({
return () => {
fetchGeneration.current += 1;
};
}, [hasQuery, fetchPage]);
}, [hasQuery, fetchPage, matchedInitialList]);
const isLoadingList = listStatus === "loading";
const canLoadMoreList = listStatus === "idle";
@@ -344,7 +378,7 @@ export function useSkillsBrowseModel({
}
if (matchedInitialSearch) return;
setSearchResults([]);
setSearchLimit(pageSize);
setSearchLimit(SKILLS_PAGE_SIZE);
setIsSearching(true);
appliedInitialSearchKey.current = null;
}, [matchedInitialSearch, searchKey]);
@@ -485,7 +519,7 @@ export function useSkillsBrowseModel({
loadMoreInFlightRef.current = true;
setListAutoLoadPaused(false);
if (hasQuery) {
setSearchLimit((value) => value + pageSize);
setSearchLimit((value) => value + SKILLS_PAGE_SIZE);
} else {
setListStatus("loadingMore");
void fetchPage(listCursor, fetchGeneration.current);
+124 -27
View File
@@ -23,16 +23,20 @@ import {
parseBrowseTopicFromSearchInput,
sanitizeBrowseTopicSearch,
} from "../../lib/browseTopicSearch";
import { fetchCatalogDiscoveryCapabilities } from "../../lib/catalogDiscoveryCapabilities";
import { resolveSkillBrowseCategorySlug, SKILL_CATEGORIES } from "../../lib/categories";
import { fetchCanonicalTrendingPage } from "../../lib/trendingApi";
import { useBrowseTopicSearch } from "../../lib/useBrowseTopicSearch";
import { parseSort } from "./-params";
import { SkillsResults } from "./-SkillsResults";
import type { SkillSearchEntry } from "./-types";
import {
buildSkillsSearchKey,
type InitialSkillsListData,
type InitialSkillsSearchData,
normalizeSkillsView,
normalizeSkillsCatalogTab,
SKILLS_PAGE_SIZE,
useSkillsBrowseModel,
type SkillsSearchState,
} from "./-useSkillsBrowseModel";
@@ -44,6 +48,9 @@ const SKILLS_VIEW_OPTIONS = [
{ value: "new", label: "New" },
];
const SKILLS_INITIAL_SEARCH_LIMIT = 25;
export const SKILLS_INITIAL_PAGE_TIMEOUT_MS = 250;
type InitialSkillsLoaderData = InitialSkillsSearchData | InitialSkillsListData;
function parseSkillCategorySlug(value: unknown) {
return typeof value === "string" ? resolveSkillBrowseCategorySlug(value) : undefined;
@@ -81,53 +88,143 @@ export const Route = createFileRoute("/skills/")({
}),
};
},
loaderDeps: ({ search }) => ({
q: search.q,
featured: search.featured,
highlighted: search.highlighted,
category: search.category,
topic: search.topic,
}),
loader: async ({ deps }): Promise<InitialSkillsSearchData> => await loadInitialSkillsSearch(deps),
loaderDeps: ({ search }) => {
const hasQuery = Boolean(search.q?.trim());
return {
q: search.q,
featured: search.featured,
highlighted: search.highlighted,
category: search.category,
topic: search.topic,
tab: hasQuery ? undefined : search.tab,
sort: hasQuery ? undefined : search.sort,
dir: hasQuery ? undefined : search.dir,
};
},
loader: async ({ deps, abortController }): Promise<InitialSkillsLoaderData> =>
isCanonicalSkillsBrowse(deps)
? await loadInitialSkillsDataWithinBudget(deps, abortController.signal)
: await loadInitialSkillsData(deps, abortController.signal),
component: SkillsIndex,
});
async function loadInitialSkillsSearch(
export async function loadInitialSkillsData(
search: SkillsSearchState,
): Promise<InitialSkillsSearchData> {
signal?: AbortSignal,
): Promise<InitialSkillsLoaderData> {
const query = search.q?.trim();
if (!query) return null;
const featuredOnly = search.featured ?? search.highlighted ?? false;
const key = buildSkillsSearchKey({
query,
featuredOnly,
categorySlug: search.category,
topic: search.topic,
});
try {
const results = (await convexHttp.action(api.search.searchSkills, {
if (query) {
const featuredOnly = search.featured ?? search.highlighted ?? false;
const key = buildSkillsSearchKey({
query,
highlightedOnly: featuredOnly,
featuredOnly,
categorySlug: search.category,
topic: search.topic,
limit: SKILLS_INITIAL_SEARCH_LIMIT,
})) as SkillSearchEntry[];
return { key, limit: SKILLS_INITIAL_SEARCH_LIMIT, results };
});
try {
const results = (await convexHttp.action(api.search.searchSkills, {
query,
highlightedOnly: featuredOnly,
categorySlug: search.category,
topic: search.topic,
limit: SKILLS_INITIAL_SEARCH_LIMIT,
})) as SkillSearchEntry[];
return { key, limit: SKILLS_INITIAL_SEARCH_LIMIT, results };
} catch (error) {
console.error("Failed to load initial skills search:", error);
return null;
}
}
if (!isCanonicalSkillsBrowse(search)) return null;
try {
const capabilities = await fetchCatalogDiscoveryCapabilities();
if (signal?.aborted) throw signal.reason;
if (!capabilities.canonicalTrendingEnabled) return null;
const result = await fetchCanonicalTrendingPage({
cursor: null,
limit: SKILLS_PAGE_SIZE,
signal,
});
return {
kind: "canonical",
results: result.items.map((trending) => ({ trending })),
nextCursor: result.nextCursor,
trendingState: result.items.length > 0 || result.nextCursor ? "available" : "empty",
};
} catch (error) {
console.error("Failed to load initial skills search:", error);
if (signal?.aborted) throw error;
console.error("Failed to load initial skills page:", error);
return null;
}
}
async function loadInitialSkillsDataWithinBudget(
search: SkillsSearchState,
navigationSignal: AbortSignal,
): Promise<InitialSkillsLoaderData> {
if (navigationSignal.aborted) throw navigationSignal.reason;
const requestController = new AbortController();
let rejectOnNavigationAbort: (reason: unknown) => void = () => {};
const navigationAbort = new Promise<never>((_, reject) => {
rejectOnNavigationAbort = reject;
});
const abortFromNavigation = () => {
requestController.abort(navigationSignal.reason);
rejectOnNavigationAbort(navigationSignal.reason);
};
navigationSignal.addEventListener("abort", abortFromNavigation, { once: true });
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<null>((resolve) => {
timeoutId = setTimeout(() => {
resolve(null);
requestController.abort(
new DOMException("Initial Skills catalog request timed out", "TimeoutError"),
);
}, SKILLS_INITIAL_PAGE_TIMEOUT_MS);
});
try {
// Slow catalog dependencies must not hold the document response open.
// Hydration falls back to the existing client fetch after this budget.
return await Promise.race([
loadInitialSkillsData(search, requestController.signal),
timeout,
navigationAbort,
]);
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
navigationSignal.removeEventListener("abort", abortFromNavigation);
}
}
function isCanonicalSkillsBrowse(search: SkillsSearchState) {
return (
search.tab === "trending" &&
search.sort === undefined &&
search.dir === undefined &&
search.featured === undefined &&
search.highlighted === undefined &&
search.category === undefined &&
search.topic === undefined
);
}
export function SkillsIndex() {
const navigate = Route.useNavigate();
const routeSearch = Route.useSearch();
const initialSearch = Route.useLoaderData() as InitialSkillsSearchData | undefined;
const initialData = Route.useLoaderData() as InitialSkillsLoaderData | undefined;
const initialList = initialData && "kind" in initialData ? initialData : undefined;
const initialSearch = initialData && !("kind" in initialData) ? initialData : undefined;
const { search, activeTopic } = useBrowseTopicSearch(routeSearch, navigate);
const searchInputRef = useRef<HTMLInputElement>(null);
const model = useSkillsBrowseModel({
initialList,
initialSearch,
navigate,
search,