From 02f7b589e29664c7c1361843cbdc07df56abf378 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 12 Aug 2026 15:08:09 -0700 Subject: [PATCH] FN-9011: fix model dropdown provider toggles Keep portaled model-provider toggles interactive when hosts use document-level outside-close handlers. - Stop pointer and mouse events at the portaled dropdown boundary. - Cover outside-close, multiple-instance, modal, desktop, and mobile toggle interactions. - Add a patch changeset for the dropdown fix. Files changed: .changeset/fn-9011-model-dropdown-toggle.md | 7 ++ .../app/components/CustomModelDropdown.tsx | 17 ++++ .../__tests__/CustomModelDropdown.test.tsx | 95 +++++++++++++++++++++- 3 files changed, 118 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-9011 Fusion-Task-Lineage: 37559d4e-27df-4ad5-8b3e-4ec28d55e2e6 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9011-model-dropdown-toggle.md | 7 ++ .../app/components/CustomModelDropdown.tsx | 17 ++++ .../__tests__/CustomModelDropdown.test.tsx | 95 ++++++++++++++++++- 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-9011-model-dropdown-toggle.md diff --git a/.changeset/fn-9011-model-dropdown-toggle.md b/.changeset/fn-9011-model-dropdown-toggle.md new file mode 100644 index 0000000000..4fb9407c9a --- /dev/null +++ b/.changeset/fn-9011-model-dropdown-toggle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the collapse/expand toggle in model selection dropdowns. +category: fix +dev: Stop portal-bound pointer and mouse events before document-level outside-close handlers can unmount CustomModelDropdown. diff --git a/packages/dashboard/app/components/CustomModelDropdown.tsx b/packages/dashboard/app/components/CustomModelDropdown.tsx index 133c8611fd..cfcb1a6601 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.tsx +++ b/packages/dashboard/app/components/CustomModelDropdown.tsx @@ -518,6 +518,23 @@ export function CustomModelDropdown({ return () => document.removeEventListener("mousedown", handlePointerDown); }, [isOpen]); + /* + FNXC:ModelDropdown 2026-08-12-21:56: + Every dashboard host must treat the document.body model-menu portal as inside its model control. Stop native pointer and mouse events at the portal boundary before document-level outside-close listeners run, so provider collapse/expand remains interactive on desktop and mobile without requiring each consumer to duplicate the portal exemption. + */ + useEffect(() => { + const dropdown = dropdownRef.current; + if (!dropdown) return; + + const stopPortalOutsideClose = (event: Event) => event.stopPropagation(); + dropdown.addEventListener("pointerdown", stopPortalOutsideClose); + dropdown.addEventListener("mousedown", stopPortalOutsideClose); + return () => { + dropdown.removeEventListener("pointerdown", stopPortalOutsideClose); + dropdown.removeEventListener("mousedown", stopPortalOutsideClose); + }; + }, [dropdownPosition, isOpen]); + // Keyboard navigation const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx index ae96a456e7..8751084a51 100644 --- a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useEffect, useRef, useState } from "react"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { loadAllAppCss } from "../../test/cssFixture"; @@ -22,6 +23,26 @@ const COLLAPSIBLE_MODELS = [ { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini", reasoning: false, contextWindow: 128000 }, ]; +/** Mirrors dashboard hosts that mistake the document.body portal for an outside click. */ +function UnGuardedOutsideCloseHost() { + const [isMounted, setIsMounted] = useState(true); + const hostRef = useRef(null); + + useEffect(() => { + const closeOnOutsideMouseDown = (event: MouseEvent) => { + if (!hostRef.current?.contains(event.target as Node)) setIsMounted(false); + }; + document.addEventListener("mousedown", closeOnOutsideMouseDown); + return () => document.removeEventListener("mousedown", closeOnOutsideMouseDown); + }, []); + + return isMounted ? ( +
+ +
+ ) : null; +} + describe("CustomModelDropdown", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -111,6 +132,73 @@ describe("CustomModelDropdown", () => { } }); + it.each([ + { breakpoint: "desktop", mobile: false }, + { breakpoint: "mobile", mobile: true }, + ])("keeps the portaled provider toggle interactive in an unguarded outside-close host at $breakpoint", async ({ mobile }) => { + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: mobile && (query === "(max-width: 768px)" || query === "(max-width: 640px)"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + } as MediaQueryList)); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Model" })); + const toggle = await screen.findByTestId("model-combobox-provider-toggle-anthropic"); + + await user.click(toggle); + + expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument(); + expect(screen.getByTestId("model-combobox-provider-toggle-anthropic")).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Claude Sonnet")).toBeNull(); + + await user.click(screen.getByTestId("model-combobox-provider-toggle-anthropic")); + expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument(); + expect(screen.getByTestId("model-combobox-provider-toggle-anthropic")).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Claude Sonnet")).toBeTruthy(); + }); + + it("keeps independently mounted portaled dropdown instances interactive", async () => { + const user = userEvent.setup(); + render( + <> + + + , + ); + + await user.click(screen.getByRole("button", { name: "First model" })); + const firstPortal = await screen.findByTestId("model-combobox-portal"); + await user.click(within(firstPortal).getByTestId("model-combobox-provider-toggle-anthropic")); + expect(within(firstPortal).queryByText("Claude Sonnet")).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Second model" })); + const secondPortal = await screen.findByTestId("model-combobox-portal"); + await user.click(within(secondPortal).getByTestId("model-combobox-provider-toggle-anthropic")); + expect(within(secondPortal).queryByText("Claude Sonnet")).toBeNull(); + }); + + it("keeps the provider toggle interactive in a modal-style host", async () => { + const user = userEvent.setup(); + render( +
+ +
, + ); + + await user.click(screen.getByRole("button", { name: "Modal model" })); + await user.click(screen.getByTestId("model-combobox-provider-toggle-anthropic")); + + expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument(); + expect(screen.queryByText("Claude Sonnet")).toBeNull(); + }); + it("collapses provider rows, preserves special rows, and persists the preference", async () => { const user = userEvent.setup(); @@ -151,10 +239,15 @@ describe("CustomModelDropdown", () => { expect(screen.queryByText("Claude Sonnet")).toBeNull(); expect(screen.getByRole("button", { name: "Expand anthropic" })).toBeTruthy(); - await user.type(screen.getByPlaceholderText("Filter models…"), "sonnet"); + const filter = screen.getByPlaceholderText("Filter models…"); + await user.type(filter, "sonnet"); expect(screen.getByText("Claude Sonnet")).toBeTruthy(); expect(screen.getByRole("button", { name: "Collapse anthropic" })).toHaveAttribute("aria-expanded", "true"); + await user.clear(filter); + expect(screen.queryByText("Claude Sonnet")).toBeNull(); + expect(screen.getByRole("button", { name: "Expand anthropic" })).toHaveAttribute("aria-expanded", "false"); + view.unmount(); window.localStorage.setItem("fusion-dashboard-model-dropdown-collapsed-providers", "not-json"); render();