diff --git a/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx b/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx index be894186c5..d4cf2df5bc 100644 --- a/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx +++ b/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx @@ -21,6 +21,7 @@ interface Issue extends ComboboxItem { const ONE: Issue = { id: "1", label: "Slow sidebar", branch: "one" }; const TWO: Issue = { id: "2", label: "Crash on resume", branch: "two" }; +const THREE: Issue = { id: "3", label: "Crash on launch", branch: "three" }; interface Search { query: string; @@ -40,7 +41,8 @@ function controlledSearch() { if (!found) throw new Error(`No search for "${query}" yet`); return found; }; - return { search, forQuery, queries: () => searches.map((candidate) => candidate.query) }; + const reject = (index: number, error: Error) => searches[index]?.reject(error); + return { search, forQuery, reject, queries: () => searches.map((candidate) => candidate.query) }; } const toast = { error: vi.fn() } as unknown as ToastApi; @@ -124,8 +126,28 @@ describe("plugin Combobox", () => { await waitFor(() => expect(screen.queryByText("Slow sidebar")).toBeNull()); controlled.forQuery("c").resolve([ONE]); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(screen.getByText("Crash on resume")).toBeTruthy(); + // Searches settle in order, so once a later one has landed the stale one has been handled. + fireEvent.change(input, { target: { value: "cra" } }); + await waitFor(() => expect(controlled.queries()).toEqual(["", "c", "cr", "cra"])); + controlled.forQuery("cra").resolve([THREE]); + await screen.findByText("Crash on launch"); + expect(screen.queryByText("Slow sidebar")).toBeNull(); + }); + + it("shows the error and not the previous results when a reopened list fails to refresh", async () => { + const controlled = controlledSearch(); + renderCombobox({ search: controlled.search }); + await waitFor(() => expect(controlled.queries()).toEqual([""])); + controlled.forQuery("").resolve([ONE]); + await screen.findByText("Slow sidebar"); + + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); + fireEvent.click(screen.getByRole("button", { name: "Issue" })); + await waitFor(() => expect(controlled.queries()).toEqual(["", ""])); + controlled.reject(1, new Error("tracker is down")); + + expect(await screen.findByText("Search failed: tracker is down")).toBeTruthy(); expect(screen.queryByText("Slow sidebar")).toBeNull(); }); diff --git a/packages/app/src/plugins/react-native/composer-controls.tsx b/packages/app/src/plugins/react-native/composer-controls.tsx index 4a6688444d..eeb5d581bd 100644 --- a/packages/app/src/plugins/react-native/composer-controls.tsx +++ b/packages/app/src/plugins/react-native/composer-controls.tsx @@ -148,7 +148,8 @@ export function Combobox({ gcTime: 0, retry: false, }); - const items: readonly Item[] = results.data ?? NO_RESULTS; + // A failed search shows its error, not the results of an earlier one. + const items: readonly Item[] = results.isError ? NO_RESULTS : (results.data ?? NO_RESULTS); const item = useMemo(() => { const choices = items.map(({ id, label, description }) => ({ value: id, label, description })); return { diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index eec88acc51..b01b6b8c2f 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -81,6 +81,7 @@ Use this table as the complete registration checklist. | `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | | `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | | New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) | +| New New workspace item contribution | `client.addNewWorkspaceItem(item)` in `index.client.tsx`; see [New workspace items](reference#new-workspace-items) | | New header contribution | `client.addHeaderButton({ id, workspaceId, button })` | | `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | | New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) | diff --git a/public-docs/plugins/migration.zh-CN.md b/public-docs/plugins/migration.zh-CN.md index bd2e101fc7..216f2437b9 100644 --- a/public-docs/plugins/migration.zh-CN.md +++ b/public-docs/plugins/migration.zh-CN.md @@ -74,6 +74,7 @@ my-plugin/ | 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 | | 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` | | 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) | +| 新的“新建工作区”项贡献 | `index.client.tsx` 中的 `client.addNewWorkspaceItem(item)`;见[新建工作区项](reference#new-workspace-items) | | 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` | | 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` | | 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) |