From b07b6a6b8d21efd9cdd8072e1fa40562cecd2751 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Jul 2025 11:20:52 -0700 Subject: [PATCH 1/6] fix(react): Resolve dynamic menu items losing icons --- .../custom-user-button/with-dynamic-items.tsx | 36 ++++++++++++ integration/templates/react-vite/src/main.tsx | 5 ++ integration/tests/custom-pages.test.ts | 55 +++++++++++++++++++ .../src/utils/useCustomElementPortal.tsx | 25 +++++++-- 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 integration/templates/react-vite/src/custom-user-button/with-dynamic-items.tsx 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..d00928bfb38 --- /dev/null +++ b/integration/templates/react-vite/src/custom-user-button/with-dynamic-items.tsx @@ -0,0 +1,36 @@ +import { UserButton } from '@clerk/clerk-react'; +import { PageContextProvider } from '../PageContext.tsx'; +import React from 'react'; + +export default function Page() { + const [showDynamicItem, setShowDynamicItem] = React.useState(false); + + return ( + + + + 🌐} + onClick={() => {}} + /> + {showDynamicItem && ( + <> + 🌍} + onClick={() => {}} + /> + 🌐} + /> + + )} + + + + + ); +} 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..dc5df7fd8ce 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/with-dynamic-items'; async function waitForMountedComponent( component: 'UserButton' | 'UserProfile', @@ -443,5 +444,59 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })( await orderSent.waitFor({ state: 'attached' }); }); }); + + test.describe('User Button with dynamic items', () => { + test('should show icons for dynamically rendered menu items', 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(); + + // Initially, only the static menu item should be visible + const pagesContainer = u.page.locator('div.cl-userButtonPopoverActions__multiSession').first(); + const initialButtons = await pagesContainer.locator('button').all(); + + // Should have at least the static "Custom action" item + expect(initialButtons.length).toBe(1); + + // Check that the static item has its icon + const staticActionButton = u.page.locator('button').filter({ hasText: 'Custom action' }).first(); + await expect(staticActionButton.locator('span')).toHaveText('🌐'); + + // Click the toggle button to show dynamic items + const toggleButton = await u.page.waitForSelector('button:has-text("Show dynamic items")'); + await toggleButton.click(); + + // Wait for the dynamic items to appear + await u.page.waitForSelector('button:has-text("Dynamic action")'); + await u.page.waitForSelector('button:has-text("Dynamic link")'); + + // Toggle the UserButton again to see the updated menu + await u.po.userButton.toggleTrigger(); + await u.po.userButton.waitForPopover(); + + // Now check that all items (static + dynamic) have their icons + const updatedButtons = await pagesContainer.locator('button').all(); + expect(updatedButtons.length).toBeGreaterThanOrEqual(3); // Static + 2 dynamic items + + // Verify static item still has icon + const updatedStaticButton = u.page.locator('button').filter({ hasText: 'Custom action' }).first(); + await expect(updatedStaticButton.locator('span')).toHaveText('🌐'); + + // Verify dynamic action item has icon + const dynamicActionButton = u.page.locator('button').filter({ hasText: 'Dynamic action' }).first(); + await expect(dynamicActionButton.locator('span')).toHaveText('🌍'); + + // Verify dynamic link item has icon + const dynamicLinkButton = u.page.locator('button').filter({ hasText: 'Dynamic link' }).first(); + await expect(dynamicLinkButton.locator('span')).toHaveText('🌐'); + }); + }); }, ); diff --git a/packages/react/src/utils/useCustomElementPortal.tsx b/packages/react/src/utils/useCustomElementPortal.tsx index 3bdc3ef35ca..e73b8f85f6c 100644 --- a/packages/react/src/utils/useCustomElementPortal.tsx +++ b/packages/react/src/utils/useCustomElementPortal.tsx @@ -16,13 +16,30 @@ 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 [nodes, setNodes] = useState<(Element | null)[]>([]); return elements.map((el, index) => ({ 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))), + mount: (node: Element) => { + setNodes(prevState => { + const newState = [...prevState]; + // Ensure array is long enough for the index + while (newState.length <= index) { + newState.push(null); + } + newState[index] = node; + return newState; + }); + }, + unmount: () => { + setNodes(prevState => { + const newState = [...prevState]; + if (index < newState.length) { + newState[index] = null; + } + return newState; + }); + }, portal: () => <>{nodes[index] ? createPortal(el.component, nodes[index]) : null}, })); }; From 4ea5caaea85a7d2d23a4f4e80a0020dd403072c8 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 30 Jul 2025 11:22:38 -0700 Subject: [PATCH 2/6] chore: add changeset --- .changeset/soft-jeans-pretend.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/soft-jeans-pretend.md 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 From 8952715f0589a80aa67592be0cbf2d742a47814d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Jul 2025 11:30:26 -0700 Subject: [PATCH 3/6] fix tests --- .../custom-user-button/with-dynamic-items.tsx | 35 +++++++++---------- integration/tests/custom-pages.test.ts | 5 ++- 2 files changed, 19 insertions(+), 21 deletions(-) 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 index d00928bfb38..d59556e55dc 100644 --- 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 @@ -1,6 +1,6 @@ import { UserButton } from '@clerk/clerk-react'; import { PageContextProvider } from '../PageContext.tsx'; -import React from 'react'; +import React, { Fragment } from 'react'; export default function Page() { const [showDynamicItem, setShowDynamicItem] = React.useState(false); @@ -10,27 +10,26 @@ export default function Page() { 🌐} - onClick={() => {}} + onClick={() => setShowDynamicItem(prev => !prev)} /> - {showDynamicItem && ( - <> - 🌍} - onClick={() => {}} - /> - 🌐} - /> - - )} + {showDynamicItem ? ( + 🌍} + onClick={() => {}} + /> + ) : null} + {showDynamicItem ? ( + 🌐} + /> + ) : null} - ); } diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts index dc5df7fd8ce..8190f6b403e 100644 --- a/integration/tests/custom-pages.test.ts +++ b/integration/tests/custom-pages.test.ts @@ -466,12 +466,11 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })( expect(initialButtons.length).toBe(1); // Check that the static item has its icon - const staticActionButton = u.page.locator('button').filter({ hasText: 'Custom action' }).first(); + const staticActionButton = u.page.locator('button').filter({ hasText: 'Show dynamic items' }).first(); await expect(staticActionButton.locator('span')).toHaveText('🌐'); // Click the toggle button to show dynamic items - const toggleButton = await u.page.waitForSelector('button:has-text("Show dynamic items")'); - await toggleButton.click(); + await staticActionButton.click(); // Wait for the dynamic items to appear await u.page.waitForSelector('button:has-text("Dynamic action")'); From cb225e169aaa6a7b4704cbb42b211c6ff0e8fe57 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Jul 2025 11:30:59 -0700 Subject: [PATCH 4/6] fix incorrect route --- integration/tests/custom-pages.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts index 8190f6b403e..9b8e4ad9a4a 100644 --- a/integration/tests/custom-pages.test.ts +++ b/integration/tests/custom-pages.test.ts @@ -9,7 +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/with-dynamic-items'; +const CUSTOM_BUTTON_DYNAMIC_ITEMS_PAGE = '/custom-user-button-dynamic-items'; async function waitForMountedComponent( component: 'UserButton' | 'UserProfile', From 258106826a475db6b54fe8a1bbcf3560c89004c3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Jul 2025 12:41:01 -0700 Subject: [PATCH 5/6] fix tests --- .../custom-user-button/with-dynamic-items.tsx | 12 +++--- integration/tests/custom-pages.test.ts | 43 ++++++------------- 2 files changed, 18 insertions(+), 37 deletions(-) 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 index d59556e55dc..5295b353e84 100644 --- 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 @@ -1,23 +1,23 @@ import { UserButton } from '@clerk/clerk-react'; import { PageContextProvider } from '../PageContext.tsx'; -import React, { Fragment } from 'react'; +import { useState } from 'react'; export default function Page() { - const [showDynamicItem, setShowDynamicItem] = React.useState(false); + const [showDynamicItem, setShowDynamicItem] = useState(false); return ( 🌐} + label='Toggle menu items' + labelIcon={'🔔'} onClick={() => setShowDynamicItem(prev => !prev)} /> {showDynamicItem ? ( 🌍} + labelIcon={'🌍'} onClick={() => {}} /> ) : null} @@ -25,7 +25,7 @@ export default function Page() { 🌐} + labelIcon={'🌐'} /> ) : null} diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts index 9b8e4ad9a4a..aa7892332f3 100644 --- a/integration/tests/custom-pages.test.ts +++ b/integration/tests/custom-pages.test.ts @@ -446,7 +446,7 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })( }); test.describe('User Button with dynamic items', () => { - test('should show icons for dynamically rendered menu items', async ({ page, context }) => { + 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(); @@ -458,43 +458,24 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })( await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); - // Initially, only the static menu item should be visible const pagesContainer = u.page.locator('div.cl-userButtonPopoverActions__multiSession').first(); - const initialButtons = await pagesContainer.locator('button').all(); - // Should have at least the static "Custom action" item - expect(initialButtons.length).toBe(1); - - // Check that the static item has its icon - const staticActionButton = u.page.locator('button').filter({ hasText: 'Show dynamic items' }).first(); - await expect(staticActionButton.locator('span')).toHaveText('🌐'); - - // Click the toggle button to show dynamic items - await staticActionButton.click(); - - // Wait for the dynamic items to appear - await u.page.waitForSelector('button:has-text("Dynamic action")'); - await u.page.waitForSelector('button:has-text("Dynamic link")'); + // 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(); - // Toggle the UserButton again to see the updated menu + // Re-open menu to see updated items await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); - // Now check that all items (static + dynamic) have their icons - const updatedButtons = await pagesContainer.locator('button').all(); - expect(updatedButtons.length).toBeGreaterThanOrEqual(3); // Static + 2 dynamic items - - // Verify static item still has icon - const updatedStaticButton = u.page.locator('button').filter({ hasText: 'Custom action' }).first(); - await expect(updatedStaticButton.locator('span')).toHaveText('🌐'); - - // Verify dynamic action item has icon - const dynamicActionButton = u.page.locator('button').filter({ hasText: 'Dynamic action' }).first(); - await expect(dynamicActionButton.locator('span')).toHaveText('🌍'); + // 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")'); - // Verify dynamic link item has icon - const dynamicLinkButton = u.page.locator('button').filter({ hasText: 'Dynamic link' }).first(); - await expect(dynamicLinkButton.locator('span')).toHaveText('🌐'); + 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('🌐'); }); }); }, From 4c77325707e9a2f9e59f702da21d8fcc5fb5fbc1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 30 Jul 2025 13:17:17 -0700 Subject: [PATCH 6/6] chore: Handle unnecessary re-renders --- .../src/utils/useCustomElementPortal.tsx | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/react/src/utils/useCustomElementPortal.tsx b/packages/react/src/utils/useCustomElementPortal.tsx index e73b8f85f6c..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,30 +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 [nodes, setNodes] = useState<(Element | null)[]>([]); + const [nodeMap, setNodeMap] = useState>(new Map()); - return elements.map((el, index) => ({ + return elements.map(el => ({ id: el.id, - mount: (node: Element) => { - setNodes(prevState => { - const newState = [...prevState]; - // Ensure array is long enough for the index - while (newState.length <= index) { - newState.push(null); - } - newState[index] = node; - return newState; - }); + 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; }, - unmount: () => { - setNodes(prevState => { - const newState = [...prevState]; - if (index < newState.length) { - newState[index] = null; - } - return newState; - }); - }, - portal: () => <>{nodes[index] ? createPortal(el.component, nodes[index]) : null}, })); };