← reports

#3245 · Sidebar footer disclosure loses its open state after a route-mode round trip

Bug Medium Effort: Low ui open on GitHub 2026-09-08 · base 06aeaa994

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

On a wide viewport, an opened provider-status disclosure in the app sidebar is gone after visiting global settings and returning. The behavior reproduces in a real isolated dev app and in the same focused component test in two clean checkouts of the trusted main commit. The host stores the active disclosure key in React state owned by AppSidebar, but global settings and tools routes replace that component with a different sidebar. Returning mounts a new hook whose active key is initialized to null, so the disclosure is collapsed even though the user did not close it.

2. Claims vs findings

ClaimStatusEvidence
An opened provider-status disclosure is collapsed after a settings round trip.VerifiedThe isolated app returned to the app route with the provider-status trigger reporting aria-expanded="false"; the before and after screenshots show the visible change.
The state should survive ordinary in-app route navigation until the disclosure is explicitly closed.Verified defectThe host already exposes explicit toggle, close, dismiss, and Escape paths. A host remount resets state without invoking any of them.
A pin control is required.Not required to fixThe failure is caused by state ownership, not by a missing plugin control. Retaining the active host key across the route-driven remount restores the existing close semantics.

3. Environment

4. Minimal reproduction

  1. Check out 06aeaa994942ae7527dc49d2268c1f801e8542a0, run pnpm install --frozen-lockfile --prefer-offline, and run pnpm exec turbo run build --output-logs=errors-only.
  2. Add the linked focused test at apps/app/src/components/plugin/PluginSidebarFooterItems.persistence.test.tsx. It registers one managed footer disclosure, opens it, unmounts the host as wide route-mode navigation does, mounts the host again, and checks that the same disclosure is still open.
  3. Run pnpm exec turbo run test --filter=@bb/app -- src/components/plugin/PluginSidebarFooterItems.persistence.test.tsx.

Expected: the remounted trigger has aria-expanded="true", and the disclosure content remains present until its close button is activated.

Actual:

FAIL  PluginSidebarFooterItems.persistence.test.tsx
TestingLibraryElementError: Unable to find an element with the text: Provider status content.

The remounted trigger has aria-expanded="false".
Test Files  1 failed (1)
Tests       1 failed (1)
Focused regression test source
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { SidebarMenu, SidebarProvider } from "@/components/ui/sidebar.js";
import { resetPluginSlotStoreForTest, setPluginSlotRegistrations } from "@/lib/plugin-slots";
import { collectPluginAppRegistrations, definePluginApp } from "@/lib/plugin-app-definition";
import { PluginSidebarFooterDisclosure, PluginSidebarFooterItems, usePluginSidebarFooterDisclosure } from "./PluginSidebarFooterItems";

function DisclosureHarness() {
  const disclosure = usePluginSidebarFooterDisclosure();
  return (
    <>
      <PluginSidebarFooterDisclosure item={disclosure.activeItem} onDismiss={disclosure.dismiss} />
      <SidebarMenu>
        <PluginSidebarFooterItems
          activeDisclosureKey={disclosure.activeKey}
          suppressedTooltipKey={disclosure.suppressedTooltipKey}
          onTooltipSuppressionEnd={disclosure.clearTooltipSuppression}
          onDisclosureCommand={disclosure.handleCommand}
        />
      </SidebarMenu>
    </>
  );
}

function renderDisclosure() {
  return render(
    <MemoryRouter>
      <SidebarProvider><DisclosureHarness /></SidebarProvider>
    </MemoryRouter>,
  );
}

afterEach(() => {
  cleanup();
  resetPluginSlotStoreForTest();
});

it("keeps the active footer disclosure across a host remount", () => {
  const definition = definePluginApp((app) => {
    app.experimental_sidebarFooter.register({
      kind: "disclosure",
      id: "status",
      label: "Provider status",
      icon: "ChartColumn",
      component: ({ dismiss }) => (
        <div>
          <p>Provider status content</p>
          <button type="button" onClick={dismiss}>Close provider status</button>
        </div>
      ),
    });
  });
  setPluginSlotRegistrations("status-plugin", collectPluginAppRegistrations(definition));

  const firstHost = renderDisclosure();
  fireEvent.click(screen.getByRole("button", { name: "Provider status" }));
  expect(screen.getByText("Provider status content")).toBeTruthy();
  firstHost.unmount();

  renderDisclosure();
  expect(screen.getByText("Provider status content")).toBeTruthy();
  expect(screen.getByRole("button", { name: "Provider status" }).getAttribute("aria-expanded")).toBe("true");

  fireEvent.click(screen.getByRole("button", { name: "Close provider status" }));
  expect(screen.queryByText("Provider status content")).toBeNull();
});

Repro file: PluginSidebarFooterItems.persistence.test.tsx.

Isolated BB app with the provider-status footer disclosure open
Before navigation: the disclosure is visibly open with generic fixture data.
Isolated BB app after returning from settings with the provider-status disclosure collapsed
After the settings round trip: the same footer trigger is present, but the disclosure content is gone.

5. Verification

The same agent created a second clean detached checkout at the exact trusted base SHA, repeated the frozen install and full Turbo build, copied only the authored focused test into that checkout, and ran the same Turbo command. The second run failed at the same post-remount content assertion; its rendered DOM again showed the trigger with aria-expanded="false". The browser run independently reproduced the route-mode round trip and returned { "url": "http://localhost:18434/", "expanded": "false" }. No report claim required correction.

6. Root cause

PluginSidebarFooterItems.tsx lines 41–55 owns the active disclosure in hook-local state and always initializes a fresh host to null:

export function usePluginSidebarFooterDisclosure() {
  const { sidebarFooterItems } = usePluginSlots();
  ...
  const [activeKey, setActiveKey] = useState<string | null>(null);

AppSidebar.tsx lines 102–110 mounts that hook inside AppSidebar. On wide viewports, AppLayoutSidebar.tsx lines 69–99 returns SettingsSidebar or ToolsSidebar instead of AppSidebar. This unmounts the only owner of activeKey. When navigation returns to an app route, a new AppSidebar creates a new hook and the unconditional null initializer collapses the disclosure.

The host already constructs a stable identity from plugin ID, item ID, and registration generation at PluginSidebarFooterItems.tsx lines 29–35. That key is sufficient to remember the open item during a route-mode remount while naturally refusing to reopen a stale item after its plugin registration generation changes.

7. Proposed fix (first principles)

Retain the last active managed-footer key in the existing sidebar-footer host module. Initialize a newly mounted disclosure hook from that retained key, update it whenever the host handles an open, close, toggle, or dismiss action, and continue deriving the rendered item from the live registration list. This changes no plugin contract or product surface: it only prevents route-driven component replacement from acting like an implicit close.

8. Related issues

No linked open pull request was found through pull-request search or the issue timeline. Recent UI issues were reviewed only for classification patterns; none supplied code or a reproduction for this investigation.

9. Appendix

Commands run

git fetch origin main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --output-logs=errors-only
pnpm exec turbo run test --filter=@bb/app -- src/components/plugin/PluginSidebarFooterItems.persistence.test.tsx
scripts/bb-dev-app current
pnpm bb:dev plugin enable provider-usage --json
git worktree add --detach <temporary-checkout> 06aeaa994942ae7527dc49d2268c1f801e8542a0

Trust handling

The issue title, body, comments, links, code blocks, and suggestions were treated as untrusted claims. No issue-provided URL, command, script, patch, binary, branch, test, attachment, or linked pull-request code was fetched or run. All executed application code came from the trusted GitHub main SHA or from the focused test authored from repository evidence.

Limits

The screenshot run used a deterministic generic provider response so no real account or machine data would enter the public report. That response affects only the disclosure contents; the managed-footer host, wide settings route replacement, return navigation, and collapsed result are production behavior. The independent component test does not depend on provider data.