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.
This commit is contained in:
Patrick Erichsen
2026-06-29 16:40:22 -07:00
committed by GitHub
parent 958df1ec43
commit aa5952ed57
9 changed files with 434 additions and 54 deletions
@@ -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 });
}
+48
View File
@@ -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(<Component />);
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({
@@ -15,6 +15,7 @@ let searchMock: Record<string, unknown> = {};
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),
},
}));
+30
View File
@@ -12,10 +12,12 @@ import {
const navigateMock = vi.fn();
let searchMock: Record<string, unknown> = {};
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(<SkillsIndex />);
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([]);
+45
View File
@@ -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({
+152 -47
View File
@@ -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<UnifiedResult[]>([]);
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
const [creatorResults, setCreatorResults] = useState<UnifiedCreatorResult[]>([]);
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<UnifiedResult[]>(() =>
matchedInitialData
? mergeUnifiedResults(
activeType,
matchedInitialData.skillResults,
matchedInitialData.pluginResults,
matchedInitialData.creatorResults,
)
: [],
);
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>(
() => matchedInitialData?.skillResults ?? [],
);
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>(
() => matchedInitialData?.pluginResults ?? [],
);
const [creatorResults, setCreatorResults] = useState<UnifiedCreatorResult[]>(
() => 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 {
+52
View File
@@ -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<UnifiedSearchInitialData | null> =>
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,
+59 -7
View File
@@ -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<HTMLInputElement | null>;
}) {
const [query, setQuery] = useState(search.q ?? "");
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([]);
const [searchLimit, setSearchLimit] = useState(pageSize);
const [isSearching, setIsSearching] = useState(false);
const searchRequest = useRef(0);
const loadMoreRef = useRef<HTMLDivElement | null>(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<Array<SkillSearchEntry>>(() =>
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<SkillListEntry[]>([]);
@@ -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,
+43
View File
@@ -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<InitialSkillsSearchData> => await loadInitialSkillsSearch(deps),
component: SkillsIndex,
});
async function loadInitialSkillsSearch(
search: SkillsSearchState,
): Promise<InitialSkillsSearchData> {
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<HTMLInputElement>(null);
const model = useSkillsBrowseModel({
initialSearch,
navigate,
search,
searchInputRef,