diff --git a/.changeset/soft-jeans-pretend.md b/.changeset/soft-jeans-pretend.md
new file mode 100644
index 00000000000..5ffa90ef9c7
--- /dev/null
+++ b/.changeset/soft-jeans-pretend.md
@@ -0,0 +1,5 @@
+---
+"@clerk/clerk-react": patch
+---
+
+Resolve dynamic menu items losing icons
diff --git a/integration/templates/react-vite/src/custom-user-button/with-dynamic-items.tsx b/integration/templates/react-vite/src/custom-user-button/with-dynamic-items.tsx
new file mode 100644
index 00000000000..5295b353e84
--- /dev/null
+++ b/integration/templates/react-vite/src/custom-user-button/with-dynamic-items.tsx
@@ -0,0 +1,35 @@
+import { UserButton } from '@clerk/clerk-react';
+import { PageContextProvider } from '../PageContext.tsx';
+import { useState } from 'react';
+
+export default function Page() {
+ const [showDynamicItem, setShowDynamicItem] = useState(false);
+
+ return (
+
+
+
+ setShowDynamicItem(prev => !prev)}
+ />
+ {showDynamicItem ? (
+ {}}
+ />
+ ) : null}
+ {showDynamicItem ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx
index ada4349f033..417a0511c73 100644
--- a/integration/templates/react-vite/src/main.tsx
+++ b/integration/templates/react-vite/src/main.tsx
@@ -13,6 +13,7 @@ import UserButtonCustom from './custom-user-button';
import UserButtonCustomDynamicLabels from './custom-user-button/with-dynamic-labels.tsx';
import UserButtonCustomDynamicLabelsAndCustomPages from './custom-user-button/with-dynamic-label-and-custom-pages.tsx';
import UserButtonCustomTrigger from './custom-user-button-trigger';
+import UserButtonCustomDynamicItems from './custom-user-button/with-dynamic-items.tsx';
import UserButton from './user-button';
import Waitlist from './waitlist';
import OrganizationProfile from './organization-profile';
@@ -83,6 +84,10 @@ const router = createBrowserRouter([
path: '/custom-user-button',
element: ,
},
+ {
+ path: '/custom-user-button-dynamic-items',
+ element: ,
+ },
{
path: '/custom-user-button-dynamic-labels',
element: ,
diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts
index 9efb227c8d9..aa7892332f3 100644
--- a/integration/tests/custom-pages.test.ts
+++ b/integration/tests/custom-pages.test.ts
@@ -9,6 +9,7 @@ const CUSTOM_BUTTON_PAGE = '/custom-user-button';
const CUSTOM_BUTTON_TRIGGER_PAGE = '/custom-user-button-trigger';
const CUSTOM_BUTTON_DYNAMIC_LABELS_PAGE = '/custom-user-button-dynamic-labels';
const CUSTOM_BUTTON_DYNAMIC_LABELS_AND_CUSTOM_PAGES_PAGE = '/custom-user-button-dynamic-labels-and-custom-pages';
+const CUSTOM_BUTTON_DYNAMIC_ITEMS_PAGE = '/custom-user-button-dynamic-items';
async function waitForMountedComponent(
component: 'UserButton' | 'UserProfile',
@@ -443,5 +444,39 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
await orderSent.waitFor({ state: 'attached' });
});
});
+
+ test.describe('User Button with dynamic items', () => {
+ test('should show dynamically rendered menu items with icons', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.page.goToRelative(CUSTOM_BUTTON_DYNAMIC_ITEMS_PAGE);
+ await u.po.userButton.waitForMounted();
+ await u.po.userButton.toggleTrigger();
+ await u.po.userButton.waitForPopover();
+
+ const pagesContainer = u.page.locator('div.cl-userButtonPopoverActions__multiSession').first();
+
+ // Toggle menu items and verify static items appear with icons
+ const toggleButton = pagesContainer.locator('button', { hasText: 'Toggle menu items' });
+ await expect(toggleButton.locator('span')).toHaveText('🔔');
+ await toggleButton.click();
+
+ // Re-open menu to see updated items
+ await u.po.userButton.toggleTrigger();
+ await u.po.userButton.waitForPopover();
+
+ // Verify all custom menu items have their icons
+ await u.page.waitForSelector('button:has-text("Dynamic action")');
+ await u.page.waitForSelector('button:has-text("Dynamic link")');
+
+ await expect(u.page.locator('button', { hasText: 'Toggle menu items' }).locator('span')).toHaveText('🔔');
+ await expect(u.page.locator('button', { hasText: 'Dynamic action' }).locator('span')).toHaveText('🌍');
+ await expect(u.page.locator('button', { hasText: 'Dynamic link' }).locator('span')).toHaveText('🌐');
+ });
+ });
},
);
diff --git a/packages/react/src/utils/useCustomElementPortal.tsx b/packages/react/src/utils/useCustomElementPortal.tsx
index 3bdc3ef35ca..c0bf7e39b23 100644
--- a/packages/react/src/utils/useCustomElementPortal.tsx
+++ b/packages/react/src/utils/useCustomElementPortal.tsx
@@ -1,4 +1,5 @@
-import React, { useState } from 'react';
+import type React from 'react';
+import { useState } from 'react';
import { createPortal } from 'react-dom';
export type UseCustomElementPortalParams = {
@@ -16,13 +17,20 @@ export type UseCustomElementPortalReturn = {
// This function takes a component as prop, and returns functions that mount and unmount
// the given component into a given node
export const useCustomElementPortal = (elements: UseCustomElementPortalParams[]) => {
- const initialState = Array(elements.length).fill(null);
- const [nodes, setNodes] = useState<(Element | null)[]>(initialState);
+ const [nodeMap, setNodeMap] = useState