fix(web): defer detail histories until needed (#3072)

This commit is contained in:
Vyctor H. Brzezowski
2026-07-13 11:36:35 -03:00
committed by GitHub
parent b2673d3ca8
commit cba061d490
6 changed files with 330 additions and 43 deletions
+152 -23
View File
@@ -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 () => {
+109
View File
@@ -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,
+1
View File
@@ -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();
+26 -4
View File
@@ -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">
+6 -5
View File
@@ -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",
+36 -11
View File
@@ -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}