diff --git a/tests/perf/collections/shell-editor-reads.spec.ts b/tests/perf/collections/shell-editor-reads.spec.ts new file mode 100644 index 00000000000..7f6eebfb9a5 --- /dev/null +++ b/tests/perf/collections/shell-editor-reads.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; +import { collectionFixture, type CollectionFixture } from "./fixture"; + +let fixture: CollectionFixture; +test.beforeAll(async ({ request }) => { + fixture = await collectionFixture(request); +}); + +// Slash-command options for Markdown editors come from the company skills and +// routines lists. Mine has no editor, so it must not pay for either read. +test("Mine does not read company skills or routines", async ({ page }) => { + const companyId = fixture.company.id; + const reads: URL[] = []; + page.on("request", item => { if (item.url().includes("/api/")) reads.push(new URL(item.url())); }); + await page.goto(`/${fixture.company.issuePrefix}/issues?view=mine`); + await expect.poll(() => reads.some(url => url.pathname === `/api/companies/${companyId}/inbox/mine`)).toBe(true); + // Shell reads start in the same pass as the inbox; allow late ones to start. + await page.waitForTimeout(2_000); + expect(reads.filter(url => url.pathname === `/api/companies/${companyId}/skills`)).toEqual([]); + expect(reads.filter(url => url.pathname === `/api/companies/${companyId}/routines`)).toEqual([]); +}); diff --git a/ui/src/context/EditorAutocompleteContext.test.tsx b/ui/src/context/EditorAutocompleteContext.test.tsx new file mode 100644 index 00000000000..1c062039e03 --- /dev/null +++ b/ui/src/context/EditorAutocompleteContext.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + listSkills: vi.fn(), + listRoutines: vi.fn(), +})); + +vi.mock("../api/companySkills", () => ({ companySkillsApi: { list: mocks.listSkills } })); +vi.mock("../api/routines", () => ({ routinesApi: { list: mocks.listRoutines } })); +vi.mock("./CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) })); + +import { EditorAutocompleteProvider, useEditorAutocomplete } from "./EditorAutocompleteContext"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe("EditorAutocompleteProvider", () => { + let container: HTMLDivElement; + let root: ReturnType; + let queryClient: QueryClient; + + beforeEach(() => { + mocks.listSkills.mockReset().mockResolvedValue([ + { id: "skill-1", key: "deploy", name: "Deploy", slug: "deploy", description: null }, + ]); + mocks.listRoutines.mockReset().mockResolvedValue([ + { id: "routine-1", title: "Nightly", status: "active" }, + { id: "routine-2", title: "Old", status: "archived" }, + ]); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + queryClient.clear(); + }); + + it("does not read skills or routines until an editor asks for slash commands", async () => { + act(() => { + root.render( + + +

Mine inbox without an editor

+
+
, + ); + }); + await flush(); + + expect(mocks.listSkills).not.toHaveBeenCalled(); + expect(mocks.listRoutines).not.toHaveBeenCalled(); + }); + + it("reads both lists once when editors mount and exposes the commands", async () => { + const seen: string[][] = []; + function Editor() { + const { slashCommands } = useEditorAutocomplete(); + seen.push(slashCommands.map((command) => command.id)); + return null; + } + + act(() => { + root.render( + + + + + + , + ); + }); + await vi.waitFor(() => expect(seen.at(-1)).toEqual(["skill:skill-1", "routine:routine-1"])); + expect(mocks.listSkills).toHaveBeenCalledTimes(1); + expect(mocks.listSkills).toHaveBeenCalledWith("company-1"); + expect(mocks.listRoutines).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/context/EditorAutocompleteContext.tsx b/ui/src/context/EditorAutocompleteContext.tsx index 447d4cd4397..b05515b02a4 100644 --- a/ui/src/context/EditorAutocompleteContext.tsx +++ b/ui/src/context/EditorAutocompleteContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; import { companySkillsApi } from "../api/companySkills"; @@ -43,27 +43,38 @@ export type SlashCommandOption = SkillCommandOption | RoutineCommandOption | Act interface EditorAutocompleteContextValue { slashCommands: SlashCommandOption[]; + /** Starts the skills and routines reads. Editors call it when they mount. */ + requestSlashCommands: () => void; } const EditorAutocompleteContext = createContext({ slashCommands: [], + requestSlashCommands: () => {}, }); +/** + * Slash-command options for Markdown editors. The company skills and routines + * lists are large, and most pages have no editor, so the provider reads them + * only after the first editor mounts. Once read they stay current through the + * live-update invalidations for skills and routines. + */ export function EditorAutocompleteProvider({ children }: { children: ReactNode }) { const { selectedCompanyId } = useCompany(); + const [requested, setRequested] = useState(false); + const requestSlashCommands = useCallback(() => setRequested(true), []); const { data: companySkills = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.companySkills.list(selectedCompanyId) : ["company-skills", "__none__"], queryFn: () => companySkillsApi.list(selectedCompanyId!), - enabled: Boolean(selectedCompanyId), + enabled: requested && Boolean(selectedCompanyId), }); const { data: routines = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.routines.list(selectedCompanyId) : ["routines", "__none__", "__all-projects__"], queryFn: () => routinesApi.list(selectedCompanyId!), - enabled: Boolean(selectedCompanyId), + enabled: requested && Boolean(selectedCompanyId), }); const value = useMemo(() => ({ @@ -92,7 +103,8 @@ export function EditorAutocompleteProvider({ children }: { children: ReactNode } aliases: [`routine:${routine.title}`, routine.title, routine.id], })), ], - }), [companySkills, routines]); + requestSlashCommands, + }), [companySkills, requestSlashCommands, routines]); return ( @@ -101,6 +113,12 @@ export function EditorAutocompleteProvider({ children }: { children: ReactNode } ); } +/** Returns the slash commands and starts their reads on first use. */ export function useEditorAutocomplete() { - return useContext(EditorAutocompleteContext); + const value = useContext(EditorAutocompleteContext); + const { requestSlashCommands } = value; + useEffect(() => { + requestSlashCommands(); + }, [requestSlashCommands]); + return value; }