diff --git a/apps/docs/content/components/(voice)/mic-selector.mdx b/apps/docs/content/components/(voice)/mic-selector.mdx index 9f8da299..e226413c 100644 --- a/apps/docs/content/components/(voice)/mic-selector.mdx +++ b/apps/docs/content/components/(voice)/mic-selector.mdx @@ -263,6 +263,8 @@ The component implements a two-stage permission approach: 1. **Without Permission**: Initially loads devices without requesting permission. Device labels may show as generic names (e.g., "Microphone 1"). 2. **With Permission**: When the popover is opened and permission hasn't been granted, automatically requests microphone access and displays actual device names. +At most one automatic permission request is made per popover open. If the user denies permission, the error is surfaced through `useAudioDevices` and no further requests are made until the popover is reopened or `loadDevices` is called explicitly. + ### Device Label Parsing The `MicSelectorLabel` component intelligently parses device names that include hardware IDs in the format `(XXXX:XXXX)`. It splits the label into the device name and ID, styling the ID with muted text for better readability. diff --git a/packages/elements/__tests__/mic-selector.test.tsx b/packages/elements/__tests__/mic-selector.test.tsx index 5da920bf..a24c4ffe 100644 --- a/packages/elements/__tests__/mic-selector.test.tsx +++ b/packages/elements/__tests__/mic-selector.test.tsx @@ -637,6 +637,71 @@ describe("micSelector", () => { expect(onValueChange).toHaveBeenCalledWith("device-2"); }); }); + + it("keeps getUserMedia bounded after permission denial", async () => { + setupMocks(); + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(vi.fn()); + mockGetUserMedia.mockClear(); + mockGetUserMedia + .mockRejectedValueOnce(new Error("Permission denied")) + .mockRejectedValueOnce(new Error("Permission denied")); + + const user = userEvent.setup(); + + render( + + + + + + + + {(devices) => + devices.map((device) => ( + + {device.label} + + )) + } + + + + + ); + + await user.click(screen.getByRole("button")); + + await waitFor(() => { + expect(mockGetUserMedia).toHaveBeenCalledOnce(); + }); + + // Wait beyond the first rejection: further render turns must not + // trigger another automatic permission request. + const firstFlush = Promise.withResolvers(); + setTimeout(firstFlush.resolve, 100); + await firstFlush.promise; + + expect(mockGetUserMedia).toHaveBeenCalledOnce(); + + // Reopening the popover is the defined retry boundary: exactly one + // more automatic request. + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button")); + + await waitFor(() => { + expect(mockGetUserMedia).toHaveBeenCalledTimes(2); + }); + + const secondFlush = Promise.withResolvers(); + setTimeout(secondFlush.resolve, 100); + await secondFlush.promise; + + expect(mockGetUserMedia).toHaveBeenCalledTimes(2); + + consoleErrorSpy.mockRestore(); + }); }); describe("micSelectorTrigger", () => { diff --git a/packages/elements/src/mic-selector.tsx b/packages/elements/src/mic-selector.tsx index 44528226..55a93589 100644 --- a/packages/elements/src/mic-selector.tsx +++ b/packages/elements/src/mic-selector.tsx @@ -1,5 +1,7 @@ "use client"; +import type { ComponentProps, ReactNode } from "react"; + import { useControllableState } from "@radix-ui/react-use-controllable-state"; import { Button } from "@repo/shadcn-ui/components/ui/button"; import { @@ -16,7 +18,6 @@ import { } from "@repo/shadcn-ui/components/ui/popover"; import { cn } from "@repo/shadcn-ui/lib/utils"; import { ChevronsUpDownIcon } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; import { createContext, useCallback, @@ -54,9 +55,11 @@ export const useAudioDevices = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hasPermission, setHasPermission] = useState(false); + const loadingRef = useRef(true); const loadDevicesWithoutPermission = useCallback(async () => { try { + loadingRef.current = true; setLoading(true); setError(null); @@ -75,16 +78,18 @@ export const useAudioDevices = () => { setError(message); console.error("Error getting audio devices:", message); } finally { + loadingRef.current = false; setLoading(false); } }, []); const loadDevicesWithPermission = useCallback(async () => { - if (loading) { + if (loadingRef.current) { return; } try { + loadingRef.current = true; setLoading(true); setError(null); @@ -112,9 +117,10 @@ export const useAudioDevices = () => { setError(message); console.error("Error getting audio devices:", message); } finally { + loadingRef.current = false; setLoading(false); } - }, [loading]); + }, []); useEffect(() => { loadDevicesWithoutPermission(); @@ -176,13 +182,18 @@ export const MicSelector = ({ prop: controlledOpen, }); const [width, setWidth] = useState(200); - const { devices, loading, hasPermission, loadDevices } = useAudioDevices(); + const { devices, hasPermission, loadDevices } = useAudioDevices(); - useEffect(() => { - if (open && !hasPermission && !loading) { - loadDevices(); - } - }, [open, hasPermission, loading, loadDevices]); + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + onOpenChange(nextOpen); + + if (nextOpen && !hasPermission) { + loadDevices(); + } + }, + [onOpenChange, hasPermission, loadDevices] + ); const contextValue = useMemo( () => ({ @@ -199,7 +210,7 @@ export const MicSelector = ({ return ( - + ); };