mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix(web): bound canonical skills SSR loading (#3399)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user