Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions apps/app/src/components/layout/AppLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import {
} from "@/lib/plugin-slots";
import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount";
import { makePluginRegistrationSet } from "@/test/fixtures/plugins";
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
import {
useEffect,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { Link, MemoryRouter, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppCommandProvider } from "@/components/commands/AppCommandProvider";
Expand Down Expand Up @@ -223,19 +227,23 @@ function RouteContent() {
);
}

function renderLayout(initialPath = "/", children: ReactNode = null) {
return render(
function layoutTree(initialPath: string, children: ReactNode) {
return (
<MemoryRouter initialEntries={[initialPath]}>
<AppCommandProvider>
<AppLayout>
<RouteContent />
{children}
</AppLayout>
</AppCommandProvider>
</MemoryRouter>,
</MemoryRouter>
);
}

function renderLayout(initialPath = "/", children: ReactNode = null) {
return render(layoutTree(initialPath, children));
}

beforeEach(() => {
sidebarThreadsMock.threads = [];
window.localStorage.clear();
Expand Down Expand Up @@ -282,6 +290,31 @@ describe("canonical thread routes", () => {
);
});

it("keeps the layout mounted while redirecting to the thread's project route", async () => {
let mounts = 0;
function MountCounter() {
useEffect(() => {
mounts += 1;
}, []);
return null;
}
const view = renderLayout("/threads/thr_one", <MountCounter />);
useThreadDetailBootstrapMock.mockReturnValue({
data: { projectId: "proj_actual" },
isError: false,
isSuccess: true,
});

view.rerender(layoutTree("/threads/thr_one", <MountCounter />));

await waitFor(() =>
expect(screen.getByTestId("location").textContent).toBe(
"/projects/proj_actual/threads/thr_one",
),
);
expect(mounts).toBe(1);
});

it("keeps an already canonical thread route", () => {
useThreadDetailBootstrapMock.mockReturnValue({
data: { projectId: "proj_one" },
Expand Down
23 changes: 10 additions & 13 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -811,26 +811,23 @@ function AppLayoutContent({ children }: AppLayoutProps) {
document.title = documentTitle;
}, [documentTitle]);

if (canonicalThreadRoutePath !== null) {
return (
<Navigate
to={{
pathname: canonicalThreadRoutePath,
search: location.search,
hash: location.hash,
}}
replace
/>
);
}

return (
<SidebarThreadEntriesProvider navigation={sidebarNavigationQuery.data}>
<TooltipProvider delayDuration={300} disableHoverableContent>
<ProjectActionsProvider>
<ThreadTitleMentionResourcesProvider {...titleMentionResources}>
<ThreadActionsProvider>
<SidebarStateBridge usesDesktopChrome={usesDesktopChrome}>
{canonicalThreadRoutePath !== null ? (
<Navigate
to={{
pathname: canonicalThreadRoutePath,
search: location.search,
hash: location.hash,
}}
replace
/>
) : null}
{backToAppRoutePath !== null && !isSidebarResizing ? (
<BackToAppCommandHandler routePath={backToAppRoutePath} />
) : null}
Expand Down
40 changes: 40 additions & 0 deletions apps/app/src/lib/plugin-frontend-boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// @vitest-environment jsdom

import { renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { usePluginFrontendLoadFailed } from "./plugin-frontend-boot-state";
import {
bootPluginFrontends,
schedulePluginFrontendReconcile,
} from "./plugin-frontend-lazy";

afterEach(() => {
vi.unstubAllGlobals();
});

it("reports a failed plugin frontend boot as failed until a retry succeeds", async () => {
const fetchMock = vi.fn(async () =>
fetchMock.mock.calls.length === 1
? new Response(JSON.stringify({ error: "bad request" }), {
status: 400,
headers: { "content-type": "application/json" },
})
: new Response(JSON.stringify({ plugins: [] }), {
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "warn").mockImplementation(() => {});
const loadFailed = renderHook(() => usePluginFrontendLoadFailed());

await bootPluginFrontends();
loadFailed.rerender();
expect(loadFailed.result.current).toBe(true);

schedulePluginFrontendReconcile();
await vi.waitFor(() => {
loadFailed.rerender();
expect(loadFailed.result.current).toBe(false);
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
2 changes: 2 additions & 0 deletions apps/app/src/lib/plugin-frontend-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { requestToaster } from "@/components/ui/app-toast-runtime";
import {
markPluginFrontendBootStarted,
markPluginFrontendsSettled,
setPluginFrontendLoadFailed,
} from "./plugin-frontend-boot-state";

type PluginFrontendModule = typeof import("./plugin-frontend");
Expand Down Expand Up @@ -37,6 +38,7 @@ export async function bootPluginFrontends(): Promise<void> {
const pluginFrontend = await loadPluginFrontend();
await pluginFrontend.bootPluginFrontends();
} catch (error) {
setPluginFrontendLoadFailed(true);
console.warn(
`plugin runtime load failed: ${error instanceof Error ? error.message : String(error)}`,
);
Expand Down
67 changes: 66 additions & 1 deletion apps/app/src/lib/plugin-frontend-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { HOST_TAILWIND_VERSION } from "@bb/plugin-build/host-tailwind-version";
import type { PluginComposerThreadRowStatus } from "@get-bb/plugin-sdk";
import { QueryClient } from "@tanstack/react-query";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
Expand Down Expand Up @@ -46,6 +46,7 @@ import { PLUGIN_PANEL_ROUTE_PATH } from "./route-paths";
import { applyAppThemeCss } from "./themes";
import { PluginPanelView } from "@/views/PluginPanelView";
import { makeInstalledPlugin } from "@/test/fixtures/plugins";
import { pluginListQueryOptions } from "@/hooks/queries/plugin-settings-queries";

function candidate(
pluginId: string,
Expand Down Expand Up @@ -211,6 +212,70 @@ describe("reconcilePluginFrontends", () => {
},
);

it.each([
{
cancelledBy: "its last observer unmounting",
start: (queryClient: QueryClient) => {
const observer = new QueryObserver(
queryClient,
pluginListQueryOptions({ enabled: true }),
);
const unsubscribe = observer.subscribe(() => {});
return () => unsubscribe();
},
},
{
cancelledBy: "cancelQueries",
start: (queryClient: QueryClient) => () =>
void queryClient.cancelQueries(),
},
])(
"fetches frontend candidates again when the shared plugin list fetch is cancelled by $cancelledBy",
async ({ start }) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const fetchMock = vi.fn(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
if (fetchMock.mock.calls.length === 1) {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("Aborted", "AbortError")),
);
return;
}
resolve(
new Response(
JSON.stringify({
plugins: [
makeInstalledPlugin({
id: "account-pool",
app: {
hasApp: true,
bundle: candidate("account-pool", "v1").bundle,
},
}),
],
}),
{ headers: { "content-type": "application/json" } },
),
);
}),
);
vi.stubGlobal("fetch", fetchMock);

const cancel = start(queryClient);
const candidates = fetchFrontendCandidates(queryClient);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
cancel();

await expect(candidates).resolves.toEqual([
candidate("account-pool", "v1"),
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
},
);

it("re-imports a plugin exactly once when its bundle hash changes, replacing registrations wholesale", async () => {
const state = createPluginFrontendReconcileState();
const deps = makeDeps([
Expand Down
29 changes: 23 additions & 6 deletions apps/app/src/lib/plugin-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import * as classVarianceAuthority from "class-variance-authority";
import * as sharedUiIcon from "@bb/shared-ui/icon";
import { createDebouncedCallbackScheduler } from "@bb/domain";
import { BbHttpError } from "@bb/sdk/browser";
import type { QueryClient } from "@tanstack/react-query";
import { isCancelledError, type QueryClient } from "@tanstack/react-query";
import { markEnabledPluginListStale } from "@/hooks/cache-owners/plugin-cache-owner";
import { pluginListQueryOptions } from "@/hooks/queries/plugin-settings-queries";
import { createPluginToast } from "@/lib/notifications/plugin-toast";
Expand Down Expand Up @@ -268,14 +268,24 @@ const lazyRuntimeGroups = [
load: () => Promise<Partial<BbPluginRuntime>>;
}[];

async function fetchEnabledPluginList(queryClient: QueryClient) {
for (;;) {
try {
return await queryClient.fetchQuery(
pluginListQueryOptions({ enabled: true }),
);
} catch (error) {
if (!isCancelledError(error)) throw error;
}
}
}

export async function fetchFrontendCandidates(
queryClient: QueryClient = appQueryClient,
): Promise<PluginFrontendCandidate[]> {
let plugins;
try {
plugins = await queryClient.fetchQuery(
pluginListQueryOptions({ enabled: true }),
);
plugins = await fetchEnabledPluginList(queryClient);
} catch (error) {
setServerPluginsStarting(false);
if (
Expand Down Expand Up @@ -1045,11 +1055,15 @@ let browserDiagnosticsSnapshot: ReadonlyMap<string, PluginFrontendDiagnostic> =
new Map();
const browserDiagnosticsListeners = new Set<() => void>();

function publishBrowserDiagnostics(): void {
browserDiagnosticsSnapshot = new Map(state.diagnostics);
function publishLoadFailed(): void {
setPluginFrontendLoadFailed(
[...state.records.values()].some((record) => record.status !== "loaded"),
);
}

function publishBrowserDiagnostics(): void {
browserDiagnosticsSnapshot = new Map(state.diagnostics);
publishLoadFailed();
for (const listener of browserDiagnosticsListeners) listener();
}

Expand Down Expand Up @@ -1136,7 +1150,9 @@ export function bootPluginFrontends(): Promise<void> {
installPluginRuntime();
installPluginFrontendPageLifecycle();
await reconcilePluginFrontends(state, browserReconcileDeps);
publishLoadFailed();
})().catch((error: unknown) => {
setPluginFrontendLoadFailed(true);
console.warn(
`plugin frontend boot failed: ${error instanceof Error ? error.message : String(error)}`,
);
Expand All @@ -1158,6 +1174,7 @@ async function runLiveReconcile(): Promise<void> {
await reconcilePluginFrontends(state, browserReconcileDeps, () =>
setPluginFrontendReconcilePending(true),
);
publishLoadFailed();
} catch (error) {
console.warn(
`plugin frontend reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
Expand Down
Loading