mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix(web): defer detail histories until needed (#3072)
This commit is contained in:
@@ -31,7 +31,7 @@ let pathnameMock = "/plugins/demo-plugin";
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
versions: Awaited<ReturnType<typeof fetchPackageVersions>> | null;
|
||||
versions: Awaited<ReturnType<typeof fetchPackageVersions>> | null | undefined;
|
||||
readme: string | null;
|
||||
rateLimited: {
|
||||
scope: "detail" | "metadata";
|
||||
@@ -394,6 +394,117 @@ describe("plugin detail route", () => {
|
||||
expect(screen.queryByText("Download .zip")).toBeNull();
|
||||
});
|
||||
|
||||
it("defers the first release-history request until Versions opens", async () => {
|
||||
loaderDataMock = { ...loaderDataMock, versions: undefined };
|
||||
vi.mocked(fetchPackageVersions).mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Loaded on demand",
|
||||
distTags: ["latest"],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(fetchPackageVersions).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Versions" }));
|
||||
|
||||
await waitFor(() => expect(fetchPackageVersions).toHaveBeenCalledTimes(1));
|
||||
expect(fetchPackageVersions).toHaveBeenCalledWith("demo-plugin", {
|
||||
limit: 20,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Show changelog for v1.0.0" }));
|
||||
expect(await screen.findByText("Loaded on demand")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("retries an unavailable first release-history request", async () => {
|
||||
loaderDataMock = { ...loaderDataMock, versions: undefined };
|
||||
vi.mocked(fetchPackageVersions)
|
||||
.mockRejectedValueOnce(new Error("versions unavailable"))
|
||||
.mockResolvedValueOnce(emptyVersions);
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Versions" }));
|
||||
|
||||
expect(await screen.findByText("Release history is temporarily unavailable.")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
expect(await screen.findByText("No active releases are available.")).toBeTruthy();
|
||||
expect(fetchPackageVersions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("aborts and discards a stale first release-history request after navigation", async () => {
|
||||
let resolveFirstPage!: (page: Awaited<ReturnType<typeof fetchPackageVersions>>) => void;
|
||||
const firstPage = new Promise<Awaited<ReturnType<typeof fetchPackageVersions>>>((resolve) => {
|
||||
resolveFirstPage = resolve;
|
||||
});
|
||||
loaderDataMock = { ...loaderDataMock, versions: undefined };
|
||||
vi.mocked(fetchPackageVersions)
|
||||
.mockReturnValueOnce(firstPage)
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
version: "2.0.0",
|
||||
createdAt: 2,
|
||||
changelog: "Second plugin release",
|
||||
distTags: ["latest"],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const { PluginDetailPage } = await import("../routes/plugins/$name");
|
||||
const { rerender } = render(
|
||||
<PluginDetailPage name="demo-plugin" loaderData={loaderDataMock} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Versions" }));
|
||||
await waitFor(() => expect(fetchPackageVersions).toHaveBeenCalledTimes(1));
|
||||
const firstSignal = vi.mocked(fetchPackageVersions).mock.calls[0]?.[1]?.signal;
|
||||
|
||||
loaderDataMock = {
|
||||
...loaderDataMock,
|
||||
detail: {
|
||||
package: {
|
||||
...loaderDataMock.detail.package!,
|
||||
name: "second-plugin",
|
||||
displayName: "Second Plugin",
|
||||
},
|
||||
owner: null,
|
||||
},
|
||||
versions: undefined,
|
||||
};
|
||||
window.location.hash = "#versions";
|
||||
rerender(<PluginDetailPage name="second-plugin" loaderData={loaderDataMock} />);
|
||||
|
||||
await waitFor(() => expect(fetchPackageVersions).toHaveBeenCalledTimes(2));
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
resolveFirstPage({
|
||||
items: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Stale first plugin release",
|
||||
distTags: [],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
openRelease("2.0.0");
|
||||
expect(screen.getByText("Second plugin release")).toBeTruthy();
|
||||
expect(screen.queryByText("Stale first plugin release")).toBeNull();
|
||||
});
|
||||
|
||||
it("loads and appends the next active release page", async () => {
|
||||
loaderDataMock = {
|
||||
...loaderDataMock,
|
||||
@@ -433,6 +544,7 @@ describe("plugin detail route", () => {
|
||||
expect(fetchPackageVersions).toHaveBeenCalledWith("demo-plugin", {
|
||||
cursor: "versions:next",
|
||||
limit: 20,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
openRelease("2.0.0");
|
||||
expect(screen.getByText("Current page")).toBeTruthy();
|
||||
@@ -470,7 +582,7 @@ describe("plugin detail route", () => {
|
||||
expect(fetchPackageVersions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets shared detail state when navigating between scoped plugins without a hash", async () => {
|
||||
it("resets shared detail state when the resolved package changes", async () => {
|
||||
loaderDataMock = {
|
||||
...loaderDataMock,
|
||||
detail: {
|
||||
@@ -507,7 +619,7 @@ describe("plugin detail route", () => {
|
||||
});
|
||||
const { PluginDetailPage } = await import("../routes/plugins/$name");
|
||||
const { rerender } = render(
|
||||
<PluginDetailPage name="@scope/plugin-a" loaderData={loaderDataMock} />,
|
||||
<PluginDetailPage name="shared-plugin" loaderData={loaderDataMock} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Versions" }));
|
||||
|
||||
@@ -542,7 +654,7 @@ describe("plugin detail route", () => {
|
||||
nextCursor: null,
|
||||
},
|
||||
};
|
||||
rerender(<PluginDetailPage name="@scope/plugin-b" loaderData={loaderDataMock} />);
|
||||
rerender(<PluginDetailPage name="shared-plugin" loaderData={loaderDataMock} />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "README.md" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
@@ -754,6 +866,7 @@ describe("plugin detail route", () => {
|
||||
expect(fetchPackageVersions).toHaveBeenLastCalledWith("demo-plugin", {
|
||||
cursor: "versions:next",
|
||||
limit: 20,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
openRelease("1.0.0");
|
||||
expect(screen.getByText("Loaded after retry")).toBeTruthy();
|
||||
@@ -808,6 +921,8 @@ describe("plugin detail route", () => {
|
||||
nextCursor: null,
|
||||
},
|
||||
};
|
||||
paramsMock = { name: "second-plugin" };
|
||||
pathnameMock = "/plugins/second-plugin";
|
||||
rerender(<Component />);
|
||||
|
||||
await act(async () => {
|
||||
@@ -833,18 +948,19 @@ describe("plugin detail route", () => {
|
||||
window.location.hash = "#versions";
|
||||
loaderDataMock = {
|
||||
...loaderDataMock,
|
||||
versions: {
|
||||
items: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
},
|
||||
versions: undefined,
|
||||
};
|
||||
vi.mocked(fetchPackageVersions).mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
@@ -854,6 +970,7 @@ describe("plugin detail route", () => {
|
||||
expect(screen.getByRole("tab", { name: "Versions" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(fetchPackageVersions).toHaveBeenCalledTimes(1);
|
||||
openRelease("1.0.0");
|
||||
expect(screen.getByText("Initial release")).toBeTruthy();
|
||||
});
|
||||
@@ -1279,7 +1396,20 @@ describe("plugin detail route", () => {
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
expect(
|
||||
useQueryMock.mock.calls
|
||||
.filter(([query]) => getFunctionName(query as never) === "packages:canDeleteVersions")
|
||||
.at(-1)?.[1],
|
||||
).toBe("skip");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Versions" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
useQueryMock.mock.calls.some(
|
||||
([query, args]) =>
|
||||
getFunctionName(query as never) === "packages:canDeleteVersions" && args !== "skip",
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete version 1.0.0" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete version" }));
|
||||
|
||||
@@ -1936,7 +2066,8 @@ describe("plugin detail route", () => {
|
||||
expect(result.detail.package?.name).toBe("demo-plugin");
|
||||
expect(result.readme).toBeNull();
|
||||
expect(result.version).toBeNull();
|
||||
expect(result.versions).toBeNull();
|
||||
expect(result.versions).toBeUndefined();
|
||||
expect(fetchPackageVersions).not.toHaveBeenCalled();
|
||||
expect(result.rateLimited).toEqual({
|
||||
scope: "metadata",
|
||||
retryAfterSeconds: 11,
|
||||
@@ -1960,13 +2091,12 @@ describe("plugin detail route", () => {
|
||||
});
|
||||
vi.mocked(fetchPackageVersion).mockResolvedValueOnce({ package: null, version: null });
|
||||
vi.mocked(fetchPackageReadme).mockResolvedValueOnce("README");
|
||||
vi.mocked(fetchPackageVersions).mockRejectedValueOnce(new Error("versions unavailable"));
|
||||
|
||||
const result = await loader({ params: { name: "demo-plugin" } });
|
||||
|
||||
expect(result.readme).toBe("README");
|
||||
expect(result.version).toEqual({ package: null, version: null });
|
||||
expect(result.versions).toBeNull();
|
||||
expect(result.versions).toBeUndefined();
|
||||
expect(fetchPackageVersions).not.toHaveBeenCalled();
|
||||
expect(result.rateLimited).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1988,13 +2118,12 @@ describe("plugin detail route", () => {
|
||||
});
|
||||
vi.mocked(fetchPackageVersion).mockResolvedValueOnce(latestVersion);
|
||||
vi.mocked(fetchPackageReadme).mockResolvedValueOnce("README");
|
||||
vi.mocked(fetchPackageVersions).mockRejectedValueOnce({ status: 429, retryAfterSeconds: 11 });
|
||||
|
||||
const result = await loader({ params: { name: "demo-plugin" } });
|
||||
|
||||
expect(result.version).toBe(latestVersion);
|
||||
expect(result.readme).toBe("README");
|
||||
expect(result.versions).toBeNull();
|
||||
expect(result.versions).toBeUndefined();
|
||||
expect(fetchPackageVersions).not.toHaveBeenCalled();
|
||||
expect(result.rateLimited).toBeNull();
|
||||
});
|
||||
|
||||
@@ -2037,7 +2166,7 @@ describe("plugin detail route", () => {
|
||||
expect(fetchPackageDetailMock).toHaveBeenCalledWith("@openclaw/matrix");
|
||||
expect(fetchPackageReadmeMock).toHaveBeenCalledWith("@openclaw/matrix");
|
||||
expect(fetchPackageVersionMock).toHaveBeenCalledWith("@openclaw/matrix", "2026.3.22");
|
||||
expect(fetchPackageVersions).toHaveBeenCalledWith("@openclaw/matrix", { limit: 20 });
|
||||
expect(fetchPackageVersions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses extension npm config for short plugin route candidates", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { SkillDetailPage } from "../components/SkillDetailPage";
|
||||
import type { SkillPageInitialData } from "../lib/skillPage";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const routerInvalidateMock = vi.fn();
|
||||
@@ -79,6 +80,47 @@ describe("SkillDetailPage", () => {
|
||||
const versionId = "skillVersions:1" as Id<"skillVersions">;
|
||||
const storageId = "storage:1" as Id<"_storage">;
|
||||
|
||||
function makeInitialData(githubBacked = false): SkillPageInitialData {
|
||||
return {
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: githubBacked ? "github-skill" : "weather",
|
||||
displayName: githubBacked ? "GitHub Skill" : "Weather",
|
||||
summary: githubBacked ? "Loaded from GitHub." : "Get current weather.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
...(githubBacked ? { installKind: "github" as const } : {}),
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: { stars: 0, downloads: 0, installs: 0, versions: 2, comments: 0 },
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: null,
|
||||
latestVersion: githubBacked
|
||||
? null
|
||||
: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "2.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Current release",
|
||||
parsed: { frontmatter: {} },
|
||||
files: [],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: githubBacked ? null : "# Weather",
|
||||
readmeError: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.location.hash = "";
|
||||
useQueryMock.mockReset();
|
||||
@@ -193,6 +235,73 @@ describe("SkillDetailPage", () => {
|
||||
expect(screen.queryByRole("button", { name: "Diff" })).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds version eligibility reads and loads full history only in Versions", async () => {
|
||||
useQueryMock.mockImplementation((query: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (
|
||||
getFunctionName(query as never) === "skills:listVersions" &&
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"limit" in args &&
|
||||
args.limit === 2
|
||||
) {
|
||||
return [
|
||||
{ _id: "skillVersions:1", version: "2.0.0", files: [] },
|
||||
{ _id: "skillVersions:2", version: "1.0.0", files: [] },
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(<SkillDetailPage slug="weather" initialData={makeInitialData()} />);
|
||||
|
||||
expect(
|
||||
useQueryMock.mock.calls.some(
|
||||
([query, args]) =>
|
||||
getFunctionName(query as never) === "skills:listVersions" &&
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"limit" in args &&
|
||||
args.limit === 2,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
useQueryMock.mock.calls.some(
|
||||
([query, args]) =>
|
||||
getFunctionName(query as never) === "skills:listVersions" &&
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"limit" in args &&
|
||||
args.limit === 50,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Versions" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
useQueryMock.mock.calls.some(
|
||||
([query, args]) =>
|
||||
getFunctionName(query as never) === "skills:listVersions" &&
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"limit" in args &&
|
||||
args.limit === 50,
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips archive version reads for GitHub-backed skills", () => {
|
||||
render(<SkillDetailPage slug="github-skill" initialData={makeInitialData(true)} />);
|
||||
|
||||
const versionCalls = useQueryMock.mock.calls.filter(
|
||||
([query]) => getFunctionName(query as never) === "skills:listVersions",
|
||||
);
|
||||
expect(versionCalls.length).toBeGreaterThan(0);
|
||||
expect(versionCalls.every(([, args]) => args === "skip")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps loader-backed skill content visible while staff live query resolves", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
|
||||
@@ -534,6 +534,7 @@ describe("version Delete UI", () => {
|
||||
expect(fetchPackageVersions).toHaveBeenCalledWith("demo-plugin", {
|
||||
cursor: "versions:next",
|
||||
limit: 20,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Delete version 0.9.0" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
|
||||
@@ -21,6 +21,7 @@ type PluginVersionsPanelProps = {
|
||||
latestVersion: string | null;
|
||||
canDeleteVersions: boolean;
|
||||
onVersionDeleted?: () => void | Promise<void>;
|
||||
onRetry?: () => void;
|
||||
panelId?: string;
|
||||
labelledBy?: string;
|
||||
hidden?: boolean;
|
||||
@@ -39,11 +40,13 @@ export function PluginVersionsPanel({
|
||||
latestVersion,
|
||||
canDeleteVersions,
|
||||
onVersionDeleted,
|
||||
onRetry,
|
||||
panelId,
|
||||
labelledBy,
|
||||
hidden = false,
|
||||
}: PluginVersionsPanelProps) {
|
||||
const isUnavailable = versions == null;
|
||||
const isLoading = versions === undefined;
|
||||
const isUnavailable = versions === null;
|
||||
const deleteOwnedRelease = useMutation(api.packages.deleteOwnedRelease);
|
||||
const [releases, setReleases] = useState(versions?.items ?? []);
|
||||
const [nextCursor, setNextCursor] = useState(versions?.nextCursor ?? null);
|
||||
@@ -53,9 +56,11 @@ export function PluginVersionsPanel({
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [expandedVersions, setExpandedVersions] = useState<Set<string>>(() => new Set());
|
||||
const loadMoreInFlightRef = useRef(false);
|
||||
const loadMoreAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const requestGenerationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreAbortControllerRef.current?.abort();
|
||||
requestGenerationRef.current += 1;
|
||||
loadMoreInFlightRef.current = false;
|
||||
setReleases(versions?.items ?? []);
|
||||
@@ -65,6 +70,7 @@ export function PluginVersionsPanel({
|
||||
setDeletingVersion(null);
|
||||
setIsDeleting(false);
|
||||
setExpandedVersions(new Set());
|
||||
return () => loadMoreAbortControllerRef.current?.abort();
|
||||
}, [packageName, versions]);
|
||||
|
||||
const toggleVersion = (version: string) => {
|
||||
@@ -83,6 +89,8 @@ export function PluginVersionsPanel({
|
||||
if (!nextCursor || loadMoreInFlightRef.current) return;
|
||||
const cursor = nextCursor;
|
||||
const requestGeneration = requestGenerationRef.current;
|
||||
const controller = new AbortController();
|
||||
loadMoreAbortControllerRef.current = controller;
|
||||
loadMoreInFlightRef.current = true;
|
||||
setIsLoadingMore(true);
|
||||
setLoadMoreError(null);
|
||||
@@ -90,17 +98,21 @@ export function PluginVersionsPanel({
|
||||
const page = await fetchPackageVersions(packageName, {
|
||||
cursor,
|
||||
limit: PLUGIN_VERSIONS_PAGE_SIZE,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (requestGeneration !== requestGenerationRef.current) return;
|
||||
setReleases((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch {
|
||||
if (requestGeneration !== requestGenerationRef.current) return;
|
||||
if (requestGeneration !== requestGenerationRef.current || controller.signal.aborted) return;
|
||||
setLoadMoreError("Could not load more releases. Try again.");
|
||||
} finally {
|
||||
if (requestGeneration === requestGenerationRef.current) {
|
||||
setIsLoadingMore(false);
|
||||
loadMoreInFlightRef.current = false;
|
||||
if (loadMoreAbortControllerRef.current === controller) {
|
||||
loadMoreAbortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -143,10 +155,20 @@ export function PluginVersionsPanel({
|
||||
<div className="skill-versions-header">
|
||||
<h2>Versions</h2>
|
||||
</div>
|
||||
{isUnavailable ? (
|
||||
{isLoading ? (
|
||||
<div className="stat p-4" role="status">
|
||||
Loading release history...
|
||||
</div>
|
||||
) : isUnavailable ? (
|
||||
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
|
||||
<p className="empty-state-title">Release history is temporarily unavailable.</p>
|
||||
<p className="empty-state-body">Try again later.</p>
|
||||
{onRetry ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
) : (
|
||||
<p className="empty-state-body">Try again later.</p>
|
||||
)}
|
||||
</div>
|
||||
) : releases.length > 0 || nextCursor ? (
|
||||
<div className="skill-versions-scroll">
|
||||
|
||||
@@ -255,6 +255,9 @@ export function SkillDetailPage({
|
||||
const skill = result?.skill;
|
||||
const owner = result?.owner ?? null;
|
||||
const latestVersion = (result?.latestVersion ?? null) as SkillDetailVersion | null;
|
||||
const latestVersionId = latestVersion?._id ?? null;
|
||||
const githubBackedFields = skill as GitHubBackedSkillFields | null | undefined;
|
||||
const isGitHubBackedSkill = githubBackedFields?.installKind === "github" && !latestVersionId;
|
||||
const modInfo = result?.moderationInfo ?? null;
|
||||
const relatedCategory = useMemo(() => (skill ? getSkillCategoryForSkill(skill) : null), [skill]);
|
||||
const relatedCategories = useMemo(
|
||||
@@ -282,7 +285,9 @@ export function SkillDetailPage({
|
||||
|
||||
const versions = useQuery(
|
||||
api.skills.listVersions,
|
||||
skill ? { skillId: skill._id, limit: 50 } : "skip",
|
||||
skill && !isGitHubBackedSkill
|
||||
? { skillId: skill._id, limit: activeTab === "versions" ? 50 : 2 }
|
||||
: "skip",
|
||||
) as Doc<"skillVersions">[] | undefined;
|
||||
const shouldLoadDiffVersions = Boolean(
|
||||
skill && (activeTab === "compare" || shouldPrefetchCompare),
|
||||
@@ -454,8 +459,6 @@ export function SkillDetailPage({
|
||||
})
|
||||
: null;
|
||||
|
||||
const latestVersionId = latestVersion?._id ?? null;
|
||||
|
||||
const clawdis = (latestVersion?.parsed as { clawdis?: ClawdisSkillMetadata } | undefined)
|
||||
?.clawdis;
|
||||
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]);
|
||||
@@ -467,8 +470,6 @@ export function SkillDetailPage({
|
||||
: null;
|
||||
const cliHelp = clawdis?.cliHelp;
|
||||
const hasPluginBundle = Boolean(nixSnippet || configRequirements || cliHelp);
|
||||
const githubBackedFields = skill as GitHubBackedSkillFields | null | undefined;
|
||||
const isGitHubBackedSkill = githubBackedFields?.installKind === "github" && !latestVersionId;
|
||||
const githubReadme = useQuery(
|
||||
api.skills.getGitHubSkillContent,
|
||||
isGitHubBackedSkill && skill ? { skillId: skill._id, kind: "readme" } : "skip",
|
||||
|
||||
@@ -148,7 +148,7 @@ type PluginInspectorValidationSummary = {
|
||||
export type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
versions: Awaited<ReturnType<typeof fetchPackageVersions>> | null;
|
||||
versions: Awaited<ReturnType<typeof fetchPackageVersions>> | null | undefined;
|
||||
readme: string | null;
|
||||
rateLimited: PluginDetailRateLimitState;
|
||||
};
|
||||
@@ -168,7 +168,7 @@ export async function loadPluginDetail(requestedName: string): Promise<PluginDet
|
||||
return {
|
||||
detail: { package: null, owner: null },
|
||||
version: null,
|
||||
versions: null,
|
||||
versions: undefined,
|
||||
readme: null,
|
||||
rateLimited: {
|
||||
scope: "detail",
|
||||
@@ -187,25 +187,24 @@ export async function loadPluginDetail(requestedName: string): Promise<PluginDet
|
||||
}
|
||||
|
||||
if (!detail.package) {
|
||||
return { detail, version: null, versions: null, readme: null, rateLimited: null };
|
||||
return { detail, version: null, versions: undefined, readme: null, rateLimited: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const [version, versions, readme] = await Promise.all([
|
||||
const [version, readme] = await Promise.all([
|
||||
detail.package.latestVersion
|
||||
? fetchPackageVersion(resolvedName, detail.package.latestVersion)
|
||||
: Promise.resolve(null),
|
||||
fetchPackageVersions(resolvedName, { limit: PLUGIN_VERSIONS_PAGE_SIZE }).catch(() => null),
|
||||
fetchPackageReadme(resolvedName),
|
||||
]);
|
||||
|
||||
return { detail, version, versions, readme, rateLimited: null };
|
||||
return { detail, version, versions: undefined, readme, rateLimited: null };
|
||||
} catch (error) {
|
||||
if (isRateLimitedPackageApiError(error)) {
|
||||
return {
|
||||
detail,
|
||||
version: null,
|
||||
versions: null,
|
||||
versions: undefined,
|
||||
readme: null,
|
||||
rateLimited: {
|
||||
scope: "metadata",
|
||||
@@ -1030,7 +1029,9 @@ type PluginDetailPageProps = {
|
||||
};
|
||||
|
||||
export function PluginDetailPage(props: PluginDetailPageProps) {
|
||||
return <PluginDetailPageContent key={props.name} {...props} />;
|
||||
return (
|
||||
<PluginDetailPageContent key={props.loaderData.detail.package?.name ?? props.name} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
@@ -1038,6 +1039,8 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
const router = useRouter();
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const { me } = useAuthStatus();
|
||||
const [activeTab, setActiveTab] = useState<PluginDetailTab>("readme");
|
||||
const [loadedVersions, setLoadedVersions] = useState(versions);
|
||||
const isNestedPluginRoute =
|
||||
pathname.includes("/security/") || pathname.endsWith("/security-audit");
|
||||
const manageCandidateNames = getOpenClawPackageCandidateNames(name);
|
||||
@@ -1050,7 +1053,7 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
);
|
||||
const canDeleteVersions = useQuery(
|
||||
api.packages.canDeleteVersions,
|
||||
me && !isNestedPluginRoute && detail.package
|
||||
me && !isNestedPluginRoute && detail.package && activeTab === "versions"
|
||||
? { name: manageLookupName, candidateNames: manageCandidateNames }
|
||||
: "skip",
|
||||
);
|
||||
@@ -1078,7 +1081,6 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
authorInspectorFindings && shouldShowDevValidationFindingMocks()
|
||||
? [...authorInspectorFindings, ...buildDevValidationFindingMocks(authorInspectorFindings[0])]
|
||||
: authorInspectorFindings;
|
||||
const [activeTab, setActiveTab] = useState<PluginDetailTab>("readme");
|
||||
const [mobileDetailPanel, setMobileDetailPanel] = useState<"content" | "stats">("content");
|
||||
const isMobileDetailLayout = useMediaQuery("(max-width: 900px)");
|
||||
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
|
||||
@@ -1090,6 +1092,28 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
syncTabFromHash();
|
||||
return () => window.removeEventListener("hashchange", syncTabFromHash);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const packageName = detail.package?.name;
|
||||
if (activeTab !== "versions" || loadedVersions !== undefined || !packageName) return undefined;
|
||||
|
||||
const controller = new AbortController();
|
||||
let stale = false;
|
||||
void fetchPackageVersions(packageName, {
|
||||
limit: PLUGIN_VERSIONS_PAGE_SIZE,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!stale) setLoadedVersions(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale && !controller.signal.aborted) setLoadedVersions(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
stale = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [activeTab, detail.package?.name, loadedVersions]);
|
||||
if (isNestedPluginRoute) {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -1187,10 +1211,11 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
const versionsPanel = (hidden: boolean) => (
|
||||
<PluginVersionsPanel
|
||||
packageName={pkg.name}
|
||||
versions={versions}
|
||||
versions={loadedVersions}
|
||||
latestVersion={pkg.latestVersion ?? null}
|
||||
canDeleteVersions={canDeleteVersions === true}
|
||||
onVersionDeleted={() => router.invalidate()}
|
||||
onRetry={() => setLoadedVersions(undefined)}
|
||||
panelId="plugin-tabpanel-versions"
|
||||
labelledBy="plugin-tab-versions"
|
||||
hidden={hidden}
|
||||
|
||||
Reference in New Issue
Block a user