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>(new Map()); - return elements.map((el, index) => ({ + return elements.map(el => ({ id: el.id, - mount: (node: Element) => setNodes(prevState => prevState.map((n, i) => (i === index ? node : n))), - unmount: () => setNodes(prevState => prevState.map((n, i) => (i === index ? null : n))), - portal: () => <>{nodes[index] ? createPortal(el.component, nodes[index]) : null}, + mount: (node: Element) => setNodeMap(prev => new Map(prev).set(String(el.id), node)), + unmount: () => + setNodeMap(prev => { + const newMap = new Map(prev); + newMap.set(String(el.id), null); + return newMap; + }), + portal: () => { + const node = nodeMap.get(String(el.id)); + return node ? createPortal(el.component, node) : null; + }, })); };