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 (
-
+
);
};