From aa5952ed57c77bacc1fd610ce2662d69b80d441a Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Mon, 29 Jun 2026 16:40:22 -0700 Subject: [PATCH] fix: preload URL skill search results Preload URL-query skill search results for /skills and /search, seed the client hooks from loader data, and skip duplicate loader-backed skill searches after hydration while preserving pagination state. --- src/__tests__/helpers/convexReactMocks.ts | 3 + src/__tests__/search-route.test.tsx | 48 +++++ src/__tests__/skills-index-load-more.test.tsx | 2 + src/__tests__/skills-index.test.tsx | 30 +++ src/lib/useUnifiedSearch.test.tsx | 45 ++++ src/lib/useUnifiedSearch.ts | 199 +++++++++++++----- src/routes/search.tsx | 52 +++++ src/routes/skills/-useSkillsBrowseModel.ts | 66 +++++- src/routes/skills/index.tsx | 43 ++++ 9 files changed, 434 insertions(+), 54 deletions(-) diff --git a/src/__tests__/helpers/convexReactMocks.ts b/src/__tests__/helpers/convexReactMocks.ts index 6dd54b8f..c3a5bf8e 100644 --- a/src/__tests__/helpers/convexReactMocks.ts +++ b/src/__tests__/helpers/convexReactMocks.ts @@ -6,17 +6,20 @@ export const convexReactMocks = { }; export const convexHttpMock = { + action: vi.fn(), query: vi.fn(), }; export function resetConvexReactMocks() { convexReactMocks.useAction.mockReset(); convexReactMocks.useQuery.mockReset(); + convexHttpMock.action.mockReset(); convexHttpMock.query.mockReset(); } export function setupDefaultConvexReactMocks() { convexReactMocks.useAction.mockReturnValue(() => Promise.resolve([])); convexReactMocks.useQuery.mockReturnValue(null); + convexHttpMock.action.mockResolvedValue([]); convexHttpMock.query.mockResolvedValue({ page: [], hasMore: false, nextCursor: null }); } diff --git a/src/__tests__/search-route.test.tsx b/src/__tests__/search-route.test.tsx index 9263c83c..465de364 100644 --- a/src/__tests__/search-route.test.tsx +++ b/src/__tests__/search-route.test.tsx @@ -9,16 +9,24 @@ let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "creators"; } = {}; +let loaderDataMock: unknown = null; const useUnifiedSearchMock = vi.fn(); vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({ __config: config, + useLoaderData: () => loaderDataMock, useSearch: () => searchMock, }), useNavigate: () => navigateMock, })); +vi.mock("../convex/client", () => ({ + convexHttp: { + action: vi.fn(), + }, +})); + vi.mock("../lib/useUnifiedSearch", () => ({ useUnifiedSearch: (...args: unknown[]) => useUnifiedSearchMock(...args), })); @@ -67,6 +75,7 @@ async function loadRoute() { describe("search route", () => { beforeEach(() => { searchMock = { q: "first" }; + loaderDataMock = null; navigateMock.mockReset(); useUnifiedSearchMock.mockReset(); useUnifiedSearchMock.mockReturnValue({ @@ -469,6 +478,45 @@ describe("search route", () => { }); }); + it("passes loader-backed skill results to unified search", async () => { + searchMock = { q: "japanese-reading-grader" }; + loaderDataMock = { + query: "japanese-reading-grader", + activeType: "all", + limits: { skills: 25, plugins: 25, creators: 25 }, + skillResults: [ + { + type: "skill", + skill: { + _id: "skill-japanese-reading-grader", + slug: "japanese-reading-grader", + displayName: "Japanese Reading Grader", + ownerUserId: "users:1", + stats: { downloads: 0, stars: 0 }, + updatedAt: 1, + createdAt: 1, + }, + ownerHandle: "bianmaxingkong", + score: 1, + }, + ], + pluginResults: [], + creatorResults: [], + skillHasMore: false, + pluginHasMore: false, + creatorHasMore: false, + }; + const route = await loadRoute(); + const Component = route.__config.component as ComponentType; + + render(); + + expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("japanese-reading-grader", "all", { + initialData: loaderDataMock, + limits: { skills: 25, plugins: 25, creators: 25 }, + }); + }); + it("renders creators as their own all-results section and active tab", async () => { searchMock = { q: "weather" }; useUnifiedSearchMock.mockReturnValue({ diff --git a/src/__tests__/skills-index-load-more.test.tsx b/src/__tests__/skills-index-load-more.test.tsx index e3d23ee2..8cd83a11 100644 --- a/src/__tests__/skills-index-load-more.test.tsx +++ b/src/__tests__/skills-index-load-more.test.tsx @@ -15,6 +15,7 @@ let searchMock: Record = {}; vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (_config: { component: unknown; validateSearch: unknown }) => ({ + useLoaderData: () => null, useNavigate: () => navigateMock, useSearch: () => searchMock, }), @@ -32,6 +33,7 @@ vi.mock("convex/react", () => ({ vi.mock("../../src/convex/client", () => ({ convexHttp: { + action: (...args: unknown[]) => convexHttpMock.action(...args), query: (...args: unknown[]) => convexHttpMock.query(...args), }, })); diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index dd2f22e1..88544b15 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -12,10 +12,12 @@ import { const navigateMock = vi.fn(); let searchMock: Record = {}; +let loaderDataMock: unknown = null; vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (config: { component: unknown; validateSearch: unknown }) => ({ __config: config, + useLoaderData: () => loaderDataMock, useNavigate: () => navigateMock, useSearch: () => searchMock, }), @@ -33,6 +35,7 @@ vi.mock("convex/react", () => ({ vi.mock("../../src/convex/client", () => ({ convexHttp: { + action: (...args: unknown[]) => convexHttpMock.action(...args), query: (...args: unknown[]) => convexHttpMock.query(...args), }, })); @@ -42,6 +45,7 @@ describe("SkillsIndex", () => { resetConvexReactMocks(); navigateMock.mockReset(); searchMock = {}; + loaderDataMock = null; setupDefaultConvexReactMocks(); }); @@ -338,6 +342,32 @@ describe("SkillsIndex", () => { expect(screen.queryByText(/Loading skills/)).toBeNull(); }); + it("renders URL-query skill search results from loader data without a duplicate refresh", async () => { + searchMock = { q: "japanese-conversation-scorer" }; + loaderDataMock = { + key: "japanese-conversation-scorer::0::::", + limit: 25, + results: [ + { + skill: makeListResult("japanese-conversation-scorer", "Japanese Conversation Scorer") + .skill, + version: null, + ownerHandle: "bianmaxingkong", + owner: null, + score: 1, + }, + ], + }; + const actionFn = vi.fn().mockResolvedValue([]); + convexReactMocks.useAction.mockReturnValue(actionFn); + + render(); + + expect(screen.getByText("Japanese Conversation Scorer")).toBeTruthy(); + expect(screen.queryByText("No skills found")).toBeNull(); + expect(actionFn).not.toHaveBeenCalled(); + }); + it("skips list fetch and calls search when query is set", async () => { searchMock = { q: "remind" }; const actionFn = vi.fn().mockResolvedValue([]); diff --git a/src/lib/useUnifiedSearch.test.tsx b/src/lib/useUnifiedSearch.test.tsx index 3f525c57..3a2676a5 100644 --- a/src/lib/useUnifiedSearch.test.tsx +++ b/src/lib/useUnifiedSearch.test.tsx @@ -81,6 +81,51 @@ describe("useUnifiedSearch", () => { convexQueryMock.mockReset(); }); + it("uses matching loader data without repeating the initial skill search", async () => { + searchSkillsMock.mockResolvedValue([]); + fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null }); + convexQueryMock.mockResolvedValue({ page: [], continueCursor: null, isDone: true }); + + const initialData = { + query: "japanese-reading-grader", + activeType: "all" as const, + limits: { skills: 25, plugins: 25, creators: 25 }, + skillResults: [ + { + type: "skill" as const, + ...makeSkill("japanese-reading-grader"), + }, + ], + pluginResults: [], + creatorResults: [], + skillHasMore: true, + pluginHasMore: false, + creatorHasMore: false, + }; + + const { result } = renderHook(() => + useUnifiedSearch("japanese-reading-grader", "all", { + initialData, + debounceMs: 0, + limits: { skills: 25, plugins: 25, creators: 25 }, + }), + ); + + expect(result.current.skillResults.map((entry) => entry.skill.slug)).toEqual([ + "japanese-reading-grader", + ]); + expect(result.current.results.map((entry) => entry.type)).toEqual(["skill"]); + expect(result.current.skillHasMore).toBe(true); + + await waitFor(() => { + expect(fetchPluginCatalogMock).toHaveBeenCalled(); + expect(convexQueryMock).toHaveBeenCalled(); + }); + + expect(searchSkillsMock).not.toHaveBeenCalled(); + expect(result.current.skillHasMore).toBe(true); + }); + it("requests one extra result and exposes hasMore without inflating counts", async () => { searchSkillsMock.mockResolvedValue([makeSkill("one"), makeSkill("two"), makeSkill("three")]); fetchPluginCatalogMock.mockResolvedValue({ diff --git a/src/lib/useUnifiedSearch.ts b/src/lib/useUnifiedSearch.ts index 06c3197c..eb670a34 100644 --- a/src/lib/useUnifiedSearch.ts +++ b/src/lib/useUnifiedSearch.ts @@ -43,9 +43,26 @@ export type UnifiedCreatorResult = { type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedCreatorResult; +export type UnifiedSearchInitialData = { + query: string; + activeType: UnifiedSearchType; + limits: { + skills: number; + plugins: number; + creators: number; + }; + skillResults: UnifiedSkillResult[]; + pluginResults: UnifiedPluginResult[]; + creatorResults: UnifiedCreatorResult[]; + skillHasMore: boolean; + pluginHasMore: boolean; + creatorHasMore: boolean; +}; + type UnifiedSearchOptions = { debounceMs?: number; enabled?: boolean; + initialData?: UnifiedSearchInitialData | null; limits?: { skills?: number; plugins?: number; @@ -53,26 +70,35 @@ type UnifiedSearchOptions = { }; }; +function mergeUnifiedResults( + activeType: UnifiedSearchType, + skillResults: UnifiedSkillResult[], + pluginResults: UnifiedPluginResult[], + creatorResults: UnifiedCreatorResult[], +) { + const merged: UnifiedResult[] = []; + if (activeType === "all") { + merged.push(...skillResults, ...pluginResults, ...creatorResults); + } else if (activeType === "skills") { + merged.push(...skillResults); + } else if (activeType === "plugins") { + merged.push(...pluginResults); + } else { + merged.push(...creatorResults); + } + return merged; +} + export function useUnifiedSearch( query: string, activeType: UnifiedSearchType, options: UnifiedSearchOptions = {}, ) { const searchSkills = useAction(api.search.searchSkills); - const [results, setResults] = useState([]); - const [skillResults, setSkillResults] = useState([]); - const [pluginResults, setPluginResults] = useState([]); - const [creatorResults, setCreatorResults] = useState([]); - const [skillCount, setSkillCount] = useState(0); - const [pluginCount, setPluginCount] = useState(0); - const [creatorCount, setCreatorCount] = useState(0); - const [skillHasMore, setSkillHasMore] = useState(false); - const [pluginHasMore, setPluginHasMore] = useState(false); - const [creatorHasMore, setCreatorHasMore] = useState(false); - const [isSearching, setIsSearching] = useState(false); const requestRef = useRef(0); const debounceMs = options.debounceMs ?? 300; const enabled = options.enabled ?? true; + const initialData = options.initialData ?? null; const skillLimit = Math.max(0, Math.min(options.limits?.skills ?? 25, MAX_UNIFIED_SEARCH_LIMIT)); const pluginLimit = Math.max( 0, @@ -83,10 +109,77 @@ export function useUnifiedSearch( Math.min(options.limits?.creators ?? 25, MAX_CREATOR_SEARCH_LIMIT), ); const creatorRequestLimit = Math.min(creatorLimit + 1, MAX_CREATOR_SEARCH_LIMIT); + const trimmedQuery = query.trim(); + const matchedInitialData = + initialData && + initialData.query === trimmedQuery && + initialData.activeType === activeType && + initialData.limits.skills === skillLimit && + initialData.limits.plugins === pluginLimit && + initialData.limits.creators === creatorLimit + ? initialData + : null; + const [results, setResults] = useState(() => + matchedInitialData + ? mergeUnifiedResults( + activeType, + matchedInitialData.skillResults, + matchedInitialData.pluginResults, + matchedInitialData.creatorResults, + ) + : [], + ); + const [skillResults, setSkillResults] = useState( + () => matchedInitialData?.skillResults ?? [], + ); + const [pluginResults, setPluginResults] = useState( + () => matchedInitialData?.pluginResults ?? [], + ); + const [creatorResults, setCreatorResults] = useState( + () => matchedInitialData?.creatorResults ?? [], + ); + const [skillCount, setSkillCount] = useState(() => matchedInitialData?.skillResults.length ?? 0); + const [pluginCount, setPluginCount] = useState( + () => matchedInitialData?.pluginResults.length ?? 0, + ); + const [creatorCount, setCreatorCount] = useState( + () => matchedInitialData?.creatorResults.length ?? 0, + ); + const [skillHasMore, setSkillHasMore] = useState(() => matchedInitialData?.skillHasMore ?? false); + const [pluginHasMore, setPluginHasMore] = useState( + () => matchedInitialData?.pluginHasMore ?? false, + ); + const [creatorHasMore, setCreatorHasMore] = useState( + () => matchedInitialData?.creatorHasMore ?? false, + ); + const [isSearching, setIsSearching] = useState( + () => enabled && trimmedQuery.length > 0 && !matchedInitialData, + ); useEffect(() => { - const trimmed = query.trim(); - if (!enabled || !trimmed) { + if (!matchedInitialData) return; + setSkillResults(matchedInitialData.skillResults); + setPluginResults(matchedInitialData.pluginResults); + setCreatorResults(matchedInitialData.creatorResults); + setSkillCount(matchedInitialData.skillResults.length); + setPluginCount(matchedInitialData.pluginResults.length); + setCreatorCount(matchedInitialData.creatorResults.length); + setSkillHasMore(matchedInitialData.skillHasMore); + setPluginHasMore(matchedInitialData.pluginHasMore); + setCreatorHasMore(matchedInitialData.creatorHasMore); + setResults( + mergeUnifiedResults( + activeType, + matchedInitialData.skillResults, + matchedInitialData.pluginResults, + matchedInitialData.creatorResults, + ), + ); + setIsSearching(false); + }, [activeType, matchedInitialData]); + + useEffect(() => { + if (!enabled || !trimmedQuery) { requestRef.current += 1; setResults([]); setSkillResults([]); @@ -102,6 +195,17 @@ export function useUnifiedSearch( return () => {}; } + const shouldFetchSkills = + (activeType === "all" || activeType === "skills") && !matchedInitialData; + const shouldFetchPlugins = activeType === "all" || activeType === "plugins"; + const shouldFetchCreators = activeType === "all" || activeType === "creators"; + + if (!shouldFetchSkills && !shouldFetchPlugins && !shouldFetchCreators) { + requestRef.current += 1; + setIsSearching(false); + return () => {}; + } + requestRef.current += 1; const requestId = requestRef.current; const controller = new AbortController(); @@ -116,24 +220,24 @@ export function useUnifiedSearch( Promise<{ page: PublicPublisherListItem[]; isDone?: boolean }> | null, ] = [null, null, null]; - if (activeType === "all" || activeType === "skills") { + if (shouldFetchSkills) { promises[0] = searchSkills({ - query: trimmed, + query: trimmedQuery, limit: skillLimit + 1, }); } - if (activeType === "all" || activeType === "plugins") { + if (shouldFetchPlugins) { promises[1] = fetchPluginCatalog({ - q: trimmed, + q: trimmedQuery, limit: pluginLimit + 1, signal: controller.signal, }); } - if (activeType === "all" || activeType === "creators") { + if (shouldFetchCreators) { promises[2] = convexHttp.query(api.publishers.listPublicPage, { - query: trimmed, + query: trimmedQuery, paginationOpts: { cursor: null, numItems: creatorRequestLimit }, }); } @@ -146,20 +250,22 @@ export function useUnifiedSearch( const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null; const creatorsRaw = settled[2].status === "fulfilled" ? settled[2].value : null; - const skillMatches: UnifiedSkillResult[] = ( - (skillsRaw as Array<{ - skill: UnifiedSkillResult["skill"]; - ownerHandle: string | null; - owner?: PublicPublisher | null; - score: number; - }>) ?? [] - ).map((entry) => ({ - type: "skill" as const, - skill: entry.skill, - ownerHandle: entry.ownerHandle, - owner: entry.owner ?? null, - score: entry.score, - })); + const skillMatches: UnifiedSkillResult[] = + matchedInitialData?.skillResults ?? + ( + (skillsRaw as Array<{ + skill: UnifiedSkillResult["skill"]; + ownerHandle: string | null; + owner?: PublicPublisher | null; + score: number; + }>) ?? [] + ).map((entry) => ({ + type: "skill" as const, + skill: entry.skill, + ownerHandle: entry.ownerHandle, + owner: entry.owner ?? null, + score: entry.score, + })); const nextSkillResults = skillMatches.slice(0, skillLimit); const pluginMatches: UnifiedPluginResult[] = ( @@ -181,7 +287,9 @@ export function useUnifiedSearch( setSkillCount(nextSkillResults.length); setPluginCount(nextPluginResults.length); setCreatorCount(nextCreatorResults.length); - setSkillHasMore(skillMatches.length > skillLimit); + setSkillHasMore( + matchedInitialData ? matchedInitialData.skillHasMore : skillMatches.length > skillLimit, + ); setPluginHasMore(pluginMatches.length > pluginLimit); setCreatorHasMore( creatorLimit < MAX_CREATOR_SEARCH_LIMIT && @@ -192,18 +300,14 @@ export function useUnifiedSearch( setPluginResults(nextPluginResults); setCreatorResults(nextCreatorResults); - const merged: UnifiedResult[] = []; - if (activeType === "all") { - merged.push(...nextSkillResults, ...nextPluginResults, ...nextCreatorResults); - } else if (activeType === "skills") { - merged.push(...nextSkillResults); - } else if (activeType === "plugins") { - merged.push(...nextPluginResults); - } else { - merged.push(...nextCreatorResults); - } - - setResults(merged); + setResults( + mergeUnifiedResults( + activeType, + nextSkillResults, + nextPluginResults, + nextCreatorResults, + ), + ); } catch (error) { console.error("Unified search failed:", error); if (requestId === requestRef.current) { @@ -232,7 +336,7 @@ export function useUnifiedSearch( window.clearTimeout(handle); }; }, [ - query, + trimmedQuery, activeType, searchSkills, debounceMs, @@ -241,6 +345,7 @@ export function useUnifiedSearch( pluginLimit, creatorLimit, creatorRequestLimit, + matchedInitialData, ]); return { diff --git a/src/routes/search.tsx b/src/routes/search.tsx index c0d8b79f..0c88b054 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -1,14 +1,17 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Plus, Search, X } from "lucide-react"; import { useEffect, useState } from "react"; +import { api } from "../../convex/_generated/api"; import { PluginListItem } from "../components/PluginListItem"; import { PublisherListItem } from "../components/PublisherListItem"; import { BrowseResultsSkeleton } from "../components/skeletons/BrowseResultsSkeleton"; import { SkillListItem } from "../components/SkillListItem"; import { Card } from "../components/ui/card"; +import { convexHttp } from "../convex/client"; import type { PublicSkill } from "../lib/publicUser"; import { useUnifiedSearch, + type UnifiedSearchInitialData, type UnifiedSearchType, type UnifiedCreatorResult, type UnifiedPluginResult, @@ -30,11 +33,59 @@ export const Route = createFileRoute("/search")({ ? search.type : undefined, }), + loaderDeps: ({ search }) => ({ + q: search.q, + }), + loader: async ({ deps }): Promise => + await loadInitialSearchResults(deps.q), component: UnifiedSearchPage, }); +async function loadInitialSearchResults(query: string | undefined) { + const trimmed = query?.trim(); + if (!trimmed) return null; + + try { + const skillsRaw = (await convexHttp.action(api.search.searchSkills, { + query: trimmed, + limit: SEARCH_PAGE_SIZE + 1, + })) as Array<{ + skill: UnifiedSkillResult["skill"]; + ownerHandle: string | null; + owner?: UnifiedSkillResult["owner"]; + score: number; + }>; + const skillMatches = skillsRaw.map((entry) => ({ + type: "skill" as const, + skill: entry.skill, + ownerHandle: entry.ownerHandle, + owner: entry.owner ?? null, + score: entry.score, + })); + return { + query: trimmed, + activeType: "all" as const, + limits: { + skills: SEARCH_PAGE_SIZE, + plugins: SEARCH_PAGE_SIZE, + creators: SEARCH_PAGE_SIZE, + }, + skillResults: skillMatches.slice(0, SEARCH_PAGE_SIZE), + pluginResults: [], + creatorResults: [], + skillHasMore: skillMatches.length > SEARCH_PAGE_SIZE, + pluginHasMore: false, + creatorHasMore: false, + }; + } catch (error) { + console.error("Failed to load initial search results:", error); + return null; + } +} + function UnifiedSearchPage() { const search = Route.useSearch(); + const initialSearch = Route.useLoaderData() as UnifiedSearchInitialData | null | undefined; const navigate = useNavigate(); const activeType = search.type ?? "all"; const [query, setQuery] = useState(search.q ?? ""); @@ -61,6 +112,7 @@ function UnifiedSearchPage() { creatorHasMore, isSearching, } = useUnifiedSearch(search.q ?? "", "all", { + ...(initialSearch ? { initialData: initialSearch } : null), limits: { skills: resultLimit, plugins: resultLimit, diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index 5800cf6c..183506b3 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -42,6 +42,12 @@ export type SkillsSearchState = { focus?: "search"; }; +export type InitialSkillsSearchData = { + key: string; + limit: number; + results: SkillSearchEntry[]; +} | null; + type SkillsNavigate = (options: { search: (prev: SkillsSearchState) => SkillsSearchState; replace?: boolean; @@ -49,19 +55,35 @@ type SkillsNavigate = (options: { type ListStatus = "loading" | "idle" | "loadingMore" | "done"; +export function buildSkillsSearchKey({ + categorySlug, + featuredOnly, + query, + topic, +}: { + categorySlug?: string; + featuredOnly: boolean; + query: string; + topic?: string; +}) { + const trimmed = query.trim(); + return trimmed + ? `${trimmed}::${featuredOnly ? "1" : "0"}::${categorySlug ?? ""}::${topic ?? ""}` + : ""; +} + export function useSkillsBrowseModel({ + initialSearch, search, navigate, searchInputRef, }: { + initialSearch?: InitialSkillsSearchData; search: SkillsSearchState; navigate: SkillsNavigate; searchInputRef: RefObject; }) { const [query, setQuery] = useState(search.q ?? ""); - const [searchResults, setSearchResults] = useState>([]); - const [searchLimit, setSearchLimit] = useState(pageSize); - const [isSearching, setIsSearching] = useState(false); const searchRequest = useRef(0); const loadMoreRef = useRef(null); const loadMoreInFlightRef = useRef(false); @@ -89,9 +111,22 @@ export function useSkillsBrowseModel({ : (requestedSort ?? (hasQuery ? "relevance" : "recommended")); const listSort = sort === "trending" ? undefined : toListSort(sort); const dir = sort === "relevance" ? "desc" : parseDir(search.dir, sort); - const searchKey = hasQuery - ? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeCategory?.slug ?? ""}::${activeTopic ?? ""}` - : ""; + const searchKey = buildSkillsSearchKey({ + query: trimmedQuery, + featuredOnly, + categorySlug: activeCategory?.slug, + topic: activeTopic, + }); + const matchedInitialSearch = initialSearch?.key === searchKey ? initialSearch : null; + const initialSearchMatches = matchedInitialSearch !== null; + const [searchResults, setSearchResults] = useState>(() => + matchedInitialSearch ? matchedInitialSearch.results : [], + ); + const [searchLimit, setSearchLimit] = useState(() => + matchedInitialSearch ? matchedInitialSearch.limit : pageSize, + ); + const [isSearching, setIsSearching] = useState(() => hasQuery && !initialSearchMatches); + const appliedInitialSearchKey = useRef(matchedInitialSearch ? matchedInitialSearch.key : null); // One-shot paginated fetches (no reactive subscription) const [listResults, setListResults] = useState([]); @@ -213,14 +248,30 @@ export function useSkillsBrowseModel({ if (!searchKey) { setSearchResults([]); setIsSearching(false); + appliedInitialSearchKey.current = null; return; } + if (matchedInitialSearch && appliedInitialSearchKey.current !== matchedInitialSearch.key) { + setSearchResults(matchedInitialSearch.results); + setSearchLimit(matchedInitialSearch.limit); + setIsSearching(false); + appliedInitialSearchKey.current = matchedInitialSearch.key; + } + if (matchedInitialSearch) return; setSearchResults([]); setSearchLimit(pageSize); - }, [searchKey]); + setIsSearching(true); + appliedInitialSearchKey.current = null; + }, [matchedInitialSearch, searchKey]); useEffect(() => { if (!hasQuery) return () => {}; + if (matchedInitialSearch && searchLimit === matchedInitialSearch.limit) { + searchRequest.current += 1; + setIsSearching(false); + return () => {}; + } + searchRequest.current += 1; const requestId = searchRequest.current; setIsSearching(true); @@ -248,6 +299,7 @@ export function useSkillsBrowseModel({ activeTopic, hasQuery, featuredOnly, + matchedInitialSearch, searchLimit, searchSkills, trimmedQuery, diff --git a/src/routes/skills/index.tsx b/src/routes/skills/index.tsx index 9293234f..491a83e8 100644 --- a/src/routes/skills/index.tsx +++ b/src/routes/skills/index.tsx @@ -17,6 +17,7 @@ import { BrowseViewToggle, useBrowseSearchDisclosure, } from "../../components/BrowseControls"; +import { convexHttp } from "../../convex/client"; import { formatBrowseCount } from "../../lib/browseCount"; import { parseBrowseTopicFromSearchInput, @@ -26,7 +27,10 @@ import { resolveSkillBrowseCategorySlug, SKILL_CATEGORIES } from "../../lib/cate import { useBrowseTopicSearch } from "../../lib/useBrowseTopicSearch"; import { parseDir, parseSort } from "./-params"; import { SkillsResults } from "./-SkillsResults"; +import type { SkillSearchEntry } from "./-types"; import { + buildSkillsSearchKey, + type InitialSkillsSearchData, normalizeSkillsView, useSkillsBrowseModel, type SkillsSearchState, @@ -45,6 +49,7 @@ const SKILLS_SORT_OPTIONS = [ { value: "newest", label: "Newest" }, { value: "name", label: "Name" }, ]; +const SKILLS_INITIAL_SEARCH_LIMIT = 25; function parseSkillCategorySlug(value: unknown) { return typeof value === "string" ? resolveSkillBrowseCategorySlug(value) : undefined; @@ -70,16 +75,54 @@ export const Route = createFileRoute("/skills/")({ focus: search.focus === "search" ? "search" : undefined, }; }, + loaderDeps: ({ search }) => ({ + q: search.q, + featured: search.featured, + highlighted: search.highlighted, + category: search.category, + topic: search.topic, + }), + loader: async ({ deps }): Promise => await loadInitialSkillsSearch(deps), component: SkillsIndex, }); +async function loadInitialSkillsSearch( + search: SkillsSearchState, +): 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, { + 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; + } +} + export function SkillsIndex() { const navigate = Route.useNavigate(); const routeSearch = Route.useSearch(); + const initialSearch = Route.useLoaderData() as InitialSkillsSearchData | undefined; const { search, activeTopic } = useBrowseTopicSearch(routeSearch, navigate); const searchInputRef = useRef(null); const model = useSkillsBrowseModel({ + initialSearch, navigate, search, searchInputRef,