Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-jeans-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/clerk-react": patch
---

Resolve dynamic menu items losing icons
Original file line number Diff line number Diff line change
@@ -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 (
<PageContextProvider>
<UserButton>
<UserButton.MenuItems>
<UserButton.Action
label='Custom action'
labelIcon={<span>🌐</span>}
onClick={() => {}}
/>
{showDynamicItem && (
<>
<UserButton.Action
label='Dynamic action'
labelIcon={<span>🌍</span>}
onClick={() => {}}
/>
<UserButton.Link
href={'/user'}
label='Dynamic link'
labelIcon={<span>🌐</span>}
/>
</>
)}
</UserButton.MenuItems>
</UserButton>
<button onClick={() => setShowDynamicItem(prev => !prev)}>Show dynamic items</button>
</PageContextProvider>
);
}
5 changes: 5 additions & 0 deletions integration/templates/react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,6 +84,10 @@ const router = createBrowserRouter([
path: '/custom-user-button',
element: <UserButtonCustom />,
},
{
path: '/custom-user-button-dynamic-items',
element: <UserButtonCustomDynamicItems />,
},
{
path: '/custom-user-button-dynamic-labels',
element: <UserButtonCustomDynamicLabels />,
Expand Down
55 changes: 55 additions & 0 deletions integration/tests/custom-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

async function waitForMountedComponent(
component: 'UserButton' | 'UserProfile',
Expand Down Expand Up @@ -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('🌐');
});
});
},
);
25 changes: 21 additions & 4 deletions packages/react/src/utils/useCustomElementPortal.tsx
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this update, the useCustomElementPortal function initialized its internal nodes array with a fixed length based on the initial elements.length. When new elements were added dynamically, the array didn't expand, causing portal creation to fail for the new elements.

Original file line number Diff line number Diff line change
Expand Up @@ -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}</>,
}));
};
Loading