Preliminary Checks
Description
Icons passed via labelIcon on OrganizationSwitcher.OrganizationProfilePage (and UserButton.UserProfilePage, same code path) don't render. The cl-navbarButtonIcon__custom-page-N divs are in the DOM but completely empty. Built-in pages (General, Members) work fine.
We traced this through the Clerk source and it's a React 19 ref collision in ExternalElementMounter.
Root cause
Here's the rendering chain for custom page icons:
makeCustomizable is a forwardRef HOC. It wraps Primitives.Icon, creates the cl-navbarButtonIcon wrapper div, and forwards a ref down.
Primitives.Icon destructures { icon: Icon, ...rest } and renders <Icon {...filterProps(rest)} css={...} />. rest contains the forwarded ref.
- For custom pages,
Icon is the arrow function (props) => <ExternalElementMounter mount={mountIcon} unmount={unmountIcon} {...props} />. It passes everything through, including ref.
ExternalElementMounter destructures { mount, unmount, ...rest } and renders <div ref={nodeRef} {...rest} />.
In React 19, ref is a regular prop. <div ref={nodeRef} {...rest} /> compiles to createElement('div', { ref: nodeRef, ...rest }). The spread overwrites nodeRef with the forwarded ref. nodeRef.current stays null. The useEffect guard if (nodeRef.current) fails. mountIcon() is never called. The host tree's portal nodeMap stays empty. No icon renders.
In React 18 this worked because React refused to pass ref into the arrow function (not wrapped in forwardRef), silently dropped it, and logged a warning. So rest never contained ref.
Built-in icons are unaffected because they're SVG components rendered directly by Primitives.Icon, no ExternalElementMounter in the chain.
Proof
We verified this with three test cases against the Clerk source at @clerk/nextjs@7.2.3:
| Scenario |
mount called? |
| React 19, no fix |
0 times (broken) |
| React 19, with fix |
1 time (works) |
| React 18, no fix |
1 time (works, with "Function components cannot be given refs" warning) |
Suggested fix
In packages/ui/src/utils/ExternalElementMounter.tsx, extract ref from rest before spreading:
export const ExternalElementMounter = ({ mount, unmount, ref: _forwardedRef, ...rest }: ExternalElementMounterProps) => {
const nodeRef = useRef(null);
useEffect(() => {
let elRef: HTMLDivElement | undefined;
if (nodeRef.current) {
elRef = nodeRef.current;
mount(nodeRef.current);
}
return () => {
unmount(elRef);
};
}, [nodeRef.current]);
return (
<div
ref={nodeRef}
{...rest}
/>
);
};
Related
#8447 reports the same symptom for UserButton.UserProfilePage with ui provided. Same root cause.
Environment
@clerk/nextjs: 7.2.3
@clerk/react: 6.4.2
react: 19.1.0
react-dom: 19.1.0
Next.js: 15.3.1
Preliminary Checks
Description
Icons passed via
labelIcononOrganizationSwitcher.OrganizationProfilePage(andUserButton.UserProfilePage, same code path) don't render. Thecl-navbarButtonIcon__custom-page-Ndivs are in the DOM but completely empty. Built-in pages (General, Members) work fine.We traced this through the Clerk source and it's a React 19 ref collision in
ExternalElementMounter.Root cause
Here's the rendering chain for custom page icons:
makeCustomizableis aforwardRefHOC. It wrapsPrimitives.Icon, creates thecl-navbarButtonIconwrapper div, and forwards arefdown.Primitives.Icondestructures{ icon: Icon, ...rest }and renders<Icon {...filterProps(rest)} css={...} />.restcontains the forwardedref.Iconis the arrow function(props) => <ExternalElementMounter mount={mountIcon} unmount={unmountIcon} {...props} />. It passes everything through, includingref.ExternalElementMounterdestructures{ mount, unmount, ...rest }and renders<div ref={nodeRef} {...rest} />.In React 19,
refis a regular prop.<div ref={nodeRef} {...rest} />compiles tocreateElement('div', { ref: nodeRef, ...rest }). The spread overwritesnodeRefwith the forwarded ref.nodeRef.currentstaysnull. The useEffect guardif (nodeRef.current)fails.mountIcon()is never called. The host tree's portalnodeMapstays empty. No icon renders.In React 18 this worked because React refused to pass
refinto the arrow function (not wrapped inforwardRef), silently dropped it, and logged a warning. Sorestnever containedref.Built-in icons are unaffected because they're SVG components rendered directly by
Primitives.Icon, noExternalElementMounterin the chain.Proof
We verified this with three test cases against the Clerk source at
@clerk/nextjs@7.2.3:mountcalled?Suggested fix
In
packages/ui/src/utils/ExternalElementMounter.tsx, extractreffrom rest before spreading:Related
#8447 reports the same symptom for
UserButton.UserProfilePagewithuiprovided. Same root cause.Environment