diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index b3437fb8..6d5f1c14 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -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 = {}; let loaderDataMock: unknown = null; @@ -20,6 +25,14 @@ vi.mock("../lib/catalogDiscoveryCapabilities", () => ({ fetchCatalogDiscoveryCapabilitiesMock(...args), })); +vi.mock("../lib/trendingApi", async (importOriginal) => { + const original = await importOriginal(); + 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 }) => Record; + loader: (args: { + deps: Record; + abortController: AbortController; + }) => unknown; + validateSearch: (search: Record) => Record; + }; + } + ).__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; + abortController: AbortController; + }) => Promise; + loaderDeps: (args: { search: Record }) => Record; + validateSearch: (search: Record) => Record; + }; + } + ).__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(); + 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(); 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, + }, + }; +} diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index 62bfce62..11410e26 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -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([]); - const [listCursor, setListCursor] = useState(null); - const [listStatus, setListStatus] = useState("loading"); - const [trendingState, setTrendingState] = useState(); + const matchedInitialList = !hasQuery && requestedCatalogTab === "trending" ? initialList : null; + const [listResults, setListResults] = useState( + () => matchedInitialList?.results ?? [], + ); + const [listCursor, setListCursor] = useState( + () => matchedInitialList?.nextCursor ?? null, + ); + const [listStatus, setListStatus] = useState(() => + matchedInitialList?.nextCursor ? "idle" : matchedInitialList ? "done" : "loading", + ); + const [trendingState, setTrendingState] = useState( + () => 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); diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index 6710bb4b..11417535 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -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 => 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 => + isCanonicalSkillsBrowse(deps) + ? await loadInitialSkillsDataWithinBudget(deps, abortController.signal) + : await loadInitialSkillsData(deps, abortController.signal), component: SkillsIndex, }); -async function loadInitialSkillsSearch( +export async function loadInitialSkillsData( search: SkillsSearchState, -): Promise { + signal?: AbortSignal, +): Promise { 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 { + if (navigationSignal.aborted) throw navigationSignal.reason; + + const requestController = new AbortController(); + let rejectOnNavigationAbort: (reason: unknown) => void = () => {}; + const navigationAbort = new Promise((_, reject) => { + rejectOnNavigationAbort = reject; + }); + const abortFromNavigation = () => { + requestController.abort(navigationSignal.reason); + rejectOnNavigationAbort(navigationSignal.reason); + }; + navigationSignal.addEventListener("abort", abortFromNavigation, { once: true }); + + let timeoutId: ReturnType | undefined; + const timeout = new Promise((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(null); const model = useSkillsBrowseModel({ + initialList, initialSearch, navigate, search,