Skip to content

Commit 241bbbd

Browse files
authored
fix(clerk-react): Resolve dynamic menu items losing icons (#6443)
1 parent e8d816a commit 241bbbd

File tree

5 files changed

+95
-7
lines changed

5 files changed

+95
-7
lines changed

.changeset/soft-jeans-pretend.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/clerk-react": patch
3+
---
4+
5+
Resolve dynamic menu items losing icons
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { UserButton } from '@clerk/clerk-react';
2+
import { PageContextProvider } from '../PageContext.tsx';
3+
import { useState } from 'react';
4+
5+
export default function Page() {
6+
const [showDynamicItem, setShowDynamicItem] = useState(false);
7+
8+
return (
9+
<PageContextProvider>
10+
<UserButton>
11+
<UserButton.MenuItems>
12+
<UserButton.Action
13+
label='Toggle menu items'
14+
labelIcon={'🔔'}
15+
onClick={() => setShowDynamicItem(prev => !prev)}
16+
/>
17+
{showDynamicItem ? (
18+
<UserButton.Action
19+
label='Dynamic action'
20+
labelIcon={'🌍'}
21+
onClick={() => {}}
22+
/>
23+
) : null}
24+
{showDynamicItem ? (
25+
<UserButton.Link
26+
href={'/user'}
27+
label='Dynamic link'
28+
labelIcon={'🌐'}
29+
/>
30+
) : null}
31+
</UserButton.MenuItems>
32+
</UserButton>
33+
</PageContextProvider>
34+
);
35+
}

integration/templates/react-vite/src/main.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import UserButtonCustom from './custom-user-button';
1313
import UserButtonCustomDynamicLabels from './custom-user-button/with-dynamic-labels.tsx';
1414
import UserButtonCustomDynamicLabelsAndCustomPages from './custom-user-button/with-dynamic-label-and-custom-pages.tsx';
1515
import UserButtonCustomTrigger from './custom-user-button-trigger';
16+
import UserButtonCustomDynamicItems from './custom-user-button/with-dynamic-items.tsx';
1617
import UserButton from './user-button';
1718
import Waitlist from './waitlist';
1819
import OrganizationProfile from './organization-profile';
@@ -83,6 +84,10 @@ const router = createBrowserRouter([
8384
path: '/custom-user-button',
8485
element: <UserButtonCustom />,
8586
},
87+
{
88+
path: '/custom-user-button-dynamic-items',
89+
element: <UserButtonCustomDynamicItems />,
90+
},
8691
{
8792
path: '/custom-user-button-dynamic-labels',
8893
element: <UserButtonCustomDynamicLabels />,

integration/tests/custom-pages.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const CUSTOM_BUTTON_PAGE = '/custom-user-button';
99
const CUSTOM_BUTTON_TRIGGER_PAGE = '/custom-user-button-trigger';
1010
const CUSTOM_BUTTON_DYNAMIC_LABELS_PAGE = '/custom-user-button-dynamic-labels';
1111
const CUSTOM_BUTTON_DYNAMIC_LABELS_AND_CUSTOM_PAGES_PAGE = '/custom-user-button-dynamic-labels-and-custom-pages';
12+
const CUSTOM_BUTTON_DYNAMIC_ITEMS_PAGE = '/custom-user-button-dynamic-items';
1213

1314
async function waitForMountedComponent(
1415
component: 'UserButton' | 'UserProfile',
@@ -443,5 +444,39 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
443444
await orderSent.waitFor({ state: 'attached' });
444445
});
445446
});
447+
448+
test.describe('User Button with dynamic items', () => {
449+
test('should show dynamically rendered menu items with icons', async ({ page, context }) => {
450+
const u = createTestUtils({ app, page, context });
451+
await u.po.signIn.goTo();
452+
await u.po.signIn.waitForMounted();
453+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
454+
await u.po.expect.toBeSignedIn();
455+
456+
await u.page.goToRelative(CUSTOM_BUTTON_DYNAMIC_ITEMS_PAGE);
457+
await u.po.userButton.waitForMounted();
458+
await u.po.userButton.toggleTrigger();
459+
await u.po.userButton.waitForPopover();
460+
461+
const pagesContainer = u.page.locator('div.cl-userButtonPopoverActions__multiSession').first();
462+
463+
// Toggle menu items and verify static items appear with icons
464+
const toggleButton = pagesContainer.locator('button', { hasText: 'Toggle menu items' });
465+
await expect(toggleButton.locator('span')).toHaveText('🔔');
466+
await toggleButton.click();
467+
468+
// Re-open menu to see updated items
469+
await u.po.userButton.toggleTrigger();
470+
await u.po.userButton.waitForPopover();
471+
472+
// Verify all custom menu items have their icons
473+
await u.page.waitForSelector('button:has-text("Dynamic action")');
474+
await u.page.waitForSelector('button:has-text("Dynamic link")');
475+
476+
await expect(u.page.locator('button', { hasText: 'Toggle menu items' }).locator('span')).toHaveText('🔔');
477+
await expect(u.page.locator('button', { hasText: 'Dynamic action' }).locator('span')).toHaveText('🌍');
478+
await expect(u.page.locator('button', { hasText: 'Dynamic link' }).locator('span')).toHaveText('🌐');
479+
});
480+
});
446481
},
447482
);

packages/react/src/utils/useCustomElementPortal.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useState } from 'react';
1+
import type React from 'react';
2+
import { useState } from 'react';
23
import { createPortal } from 'react-dom';
34

45
export type UseCustomElementPortalParams = {
@@ -16,13 +17,20 @@ export type UseCustomElementPortalReturn = {
1617
// This function takes a component as prop, and returns functions that mount and unmount
1718
// the given component into a given node
1819
export const useCustomElementPortal = (elements: UseCustomElementPortalParams[]) => {
19-
const initialState = Array(elements.length).fill(null);
20-
const [nodes, setNodes] = useState<(Element | null)[]>(initialState);
20+
const [nodeMap, setNodeMap] = useState<Map<string, Element | null>>(new Map());
2121

22-
return elements.map((el, index) => ({
22+
return elements.map(el => ({
2323
id: el.id,
24-
mount: (node: Element) => setNodes(prevState => prevState.map((n, i) => (i === index ? node : n))),
25-
unmount: () => setNodes(prevState => prevState.map((n, i) => (i === index ? null : n))),
26-
portal: () => <>{nodes[index] ? createPortal(el.component, nodes[index]) : null}</>,
24+
mount: (node: Element) => setNodeMap(prev => new Map(prev).set(String(el.id), node)),
25+
unmount: () =>
26+
setNodeMap(prev => {
27+
const newMap = new Map(prev);
28+
newMap.set(String(el.id), null);
29+
return newMap;
30+
}),
31+
portal: () => {
32+
const node = nodeMap.get(String(el.id));
33+
return node ? createPortal(el.component, node) : null;
34+
},
2735
}));
2836
};

0 commit comments

Comments
 (0)