Skip to content

Commit ce40ff6

Browse files
panteliselefLauraBeatrisLekoArts
authoredOct 16, 2024··
feat(clerk-js,types): Standalone UserButton and OrganizationSwitcher (#4042)
Co-authored-by: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Co-authored-by: Lennart <lekoarts@gmail.com>
1 parent 2102052 commit ce40ff6

File tree

25 files changed

+663
-124
lines changed

25 files changed

+663
-124
lines changed
 

‎.changeset/clean-mugs-wave.md

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@clerk/clerk-react": minor
3+
---
4+
5+
Introducing experimental `asProvider`, `asStandalone`, and `<X.Outlet />` for `<UserButton />` and `<OrganizationSwitcher />` components.
6+
- `asProvider` converts `<UserButton />` and `<OrganizationSwitcher />` to a provider that defers rendering until `<Outlet />` is mounted.
7+
- `<Outlet />` also accepts a `asStandalone` prop. It will skip the trigger of these components and display only the UI which was previously inside the popover. This allows developers to create their own triggers.
8+
9+
Example usage:
10+
```tsx
11+
<UserButton __experimental_asProvider afterSignOutUrl='/'>
12+
<UserButton.UserProfilePage label="Custom Page" url="/custom-page">
13+
<h1> This is my page available to all children </h1>
14+
</UserButton.UserProfilePage>
15+
<UserButton.__experimental_Outlet __experimental_asStandalone />
16+
</UserButton>
17+
```
18+
19+
```tsx
20+
<OrganizationSwitcher __experimental_asProvider afterSignOutUrl='/'>
21+
<OrganizationSwitcher.OrganizationProfilePage label="Custom Page" url="/custom-page">
22+
<h1> This is my page available to all children </h1>
23+
</OrganizationSwitcher.OrganizationProfilePage>
24+
<OrganizationSwitcher.__experimental_Outlet __experimental_asStandalone />
25+
</OrganizationSwitcher>
26+
```

‎.changeset/shaggy-kids-fail.md

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Add experimental standalone mode for `<UserButton />` and `<OrganizationSwitcher />`.
7+
When `__experimental_asStandalone: true` the component will not render its trigger, and instead it will render only the contents of the popover in place.
8+
9+
APIs that changed:
10+
- (For internal usage) Added `__experimental_prefetchOrganizationSwitcher` as a way to mount an internal component that will render the `useOrganizationList()` hook and prefetch the necessary data for the popover of `<OrganizationSwitcher />`. This enhances the UX since no loading state will be visible and keeps CLS to the minimum.
11+
- New property for `mountOrganizationSwitcher(node, { __experimental_asStandalone: true })`
12+
- New property for `mountUserButton(node, { __experimental_asStandalone: true })`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { UserButton } from '@clerk/clerk-react';
2+
import { PropsWithChildren, useContext, useState } from 'react';
3+
import { PageContext, PageContextProvider } from '../PageContext.tsx';
4+
5+
function Page1() {
6+
const { counter, setCounter } = useContext(PageContext);
7+
8+
return (
9+
<>
10+
<h1 data-page={1}>Page 1</h1>
11+
<p data-page={1}>Counter: {counter}</p>
12+
<button
13+
data-page={1}
14+
onClick={() => setCounter(a => a + 1)}
15+
>
16+
Update
17+
</button>
18+
</>
19+
);
20+
}
21+
22+
function ToggleChildren(props: PropsWithChildren) {
23+
const [isMounted, setMounted] = useState(false);
24+
25+
return (
26+
<>
27+
<button
28+
data-toggle-btn
29+
onClick={() => setMounted(v => !v)}
30+
>
31+
Toggle
32+
</button>
33+
{isMounted ? props.children : null}
34+
</>
35+
);
36+
}
37+
38+
export default function Page() {
39+
return (
40+
<PageContextProvider>
41+
<UserButton __experimental_asProvider>
42+
<UserButton.UserProfilePage
43+
label={'Page 1'}
44+
labelIcon={<p data-label-icon={'page-1'}>🙃</p>}
45+
url='page-1'
46+
>
47+
<Page1 />
48+
</UserButton.UserProfilePage>
49+
<UserButton.UserProfilePage label={'security'} />
50+
<UserButton.UserProfilePage
51+
label={'Page 2'}
52+
labelIcon={<p data-label-icon={'page-2'}>🙃</p>}
53+
url='page-2'
54+
>
55+
<h1>Page 2</h1>
56+
</UserButton.UserProfilePage>
57+
<p data-leaked-child>This is leaking</p>
58+
<UserButton.UserProfileLink
59+
url={'https://clerk.com'}
60+
label={'Visit Clerk'}
61+
labelIcon={<p data-label-icon={'page-3'}>🌐</p>}
62+
/>
63+
<UserButton.MenuItems>
64+
<UserButton.Action
65+
label={'page-1'}
66+
labelIcon={<span>🙃</span>}
67+
open={'page-1'}
68+
/>
69+
<UserButton.Action label={'manageAccount'} />
70+
<UserButton.Action label={'signOut'} />
71+
<UserButton.Link
72+
href={'http://clerk.com'}
73+
label={'Visit Clerk'}
74+
labelIcon={<span>🌐</span>}
75+
/>
76+
77+
<UserButton.Link
78+
href={'/user'}
79+
label={'Visit User page'}
80+
labelIcon={<span>🌐</span>}
81+
/>
82+
83+
<UserButton.Action
84+
label={'Custom Alert'}
85+
labelIcon={<span>🔔</span>}
86+
onClick={() => alert('custom-alert')}
87+
/>
88+
</UserButton.MenuItems>
89+
<UserButton.UserProfileLink
90+
url={'/user'}
91+
label={'Visit User page'}
92+
labelIcon={<p data-label-icon={'page-4'}>🌐</p>}
93+
/>
94+
<ToggleChildren>
95+
<UserButton.__experimental_Outlet __experimental_asStandalone />
96+
</ToggleChildren>
97+
</UserButton>
98+
</PageContextProvider>
99+
);
100+
}

‎integration/templates/react-vite/src/custom-user-button/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export default function Page() {
3838
>
3939
<h1>Page 2</h1>
4040
</UserButton.UserProfilePage>
41-
🌐
41+
<p data-leaked-child>This is leaking</p>
4242
<UserButton.UserProfileLink
4343
url={'https://clerk.com'}
4444
label={'Visit Clerk'}

‎integration/templates/react-vite/src/custom-user-profile/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export default function Page() {
3838
>
3939
<h1>Page 2</h1>
4040
</UserProfile.Page>
41-
🌐
41+
<p data-leaked-child>This is leaking</p>
4242
<UserProfile.Link
4343
url={'https://clerk.com'}
4444
label={'Visit Clerk'}

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

+5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import SignUp from './sign-up';
1010
import UserProfile from './user';
1111
import UserProfileCustom from './custom-user-profile';
1212
import UserButtonCustom from './custom-user-button';
13+
import UserButtonCustomTrigger from './custom-user-button-trigger';
1314

1415
const Root = () => {
1516
const navigate = useNavigate();
@@ -64,6 +65,10 @@ const router = createBrowserRouter([
6465
path: '/custom-user-button',
6566
element: <UserButtonCustom />,
6667
},
68+
{
69+
path: '/custom-user-button-trigger',
70+
element: <UserButtonCustomTrigger />,
71+
},
6772
],
6873
},
6974
]);

‎integration/tests/custom-pages.test.ts

+67-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createTestUtils, testAgainstRunningApps } from '../testUtils';
66

77
const CUSTOM_PROFILE_PAGE = '/custom-user-profile';
88
const CUSTOM_BUTTON_PAGE = '/custom-user-button';
9+
const CUSTOM_BUTTON_TRIGGER_PAGE = '/custom-user-button-trigger';
910

1011
async function waitForMountedComponent(
1112
component: 'UserButton' | 'UserProfile',
@@ -106,11 +107,29 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
106107
await u.page.waitForSelector('p[data-page="1"]', { state: 'attached' });
107108

108109
await expect(u.page.locator('p[data-page="1"]')).toHaveText('Counter: 0');
109-
u.page.locator('button[data-page="1"]').click();
110+
await u.page.locator('button[data-page="1"]').click();
110111

111112
await expect(u.page.locator('p[data-page="1"]')).toHaveText('Counter: 1');
112113
});
113114

115+
test('renders only custom pages and does not display unrelated child components', async ({ page, context }) => {
116+
const u = createTestUtils({ app, page, context });
117+
await u.po.signIn.goTo();
118+
await u.po.signIn.waitForMounted();
119+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
120+
await u.po.expect.toBeSignedIn();
121+
122+
await waitForMountedComponent(component, u);
123+
124+
const buttons = await u.page.locator('button.cl-navbarButton__custom-page-0').all();
125+
expect(buttons.length).toBe(1);
126+
const [profilePage] = buttons;
127+
await expect(profilePage.locator('div.cl-navbarButtonIcon__custom-page-0')).toHaveText('🙃');
128+
await profilePage.click();
129+
130+
await expect(u.page.locator('p[data-leaked-child]')).toBeHidden();
131+
});
132+
114133
test('user profile custom external absolute link', async ({ page, context }) => {
115134
const u = createTestUtils({ app, page, context });
116135
await u.po.signIn.goTo();
@@ -149,6 +168,53 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
149168
});
150169
});
151170

171+
test.describe('User Button with experimental asStandalone and asProvider', () => {
172+
test('items at the specified order', async ({ page, context }) => {
173+
const u = createTestUtils({ app, page, context });
174+
await u.po.signIn.goTo();
175+
await u.po.signIn.waitForMounted();
176+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
177+
await u.po.expect.toBeSignedIn();
178+
179+
await u.page.goToRelative(CUSTOM_BUTTON_TRIGGER_PAGE);
180+
const toggleButton = await u.page.waitForSelector('button[data-toggle-btn]');
181+
await toggleButton.click();
182+
183+
await u.po.userButton.waitForPopover();
184+
await u.po.userButton.triggerManageAccount();
185+
await u.po.userProfile.waitForMounted();
186+
187+
const pagesContainer = u.page.locator('div.cl-navbarButtons').first();
188+
189+
const buttons = await pagesContainer.locator('button').all();
190+
191+
expect(buttons.length).toBe(6);
192+
193+
const expectedTexts = ['Profile', '🙃Page 1', 'Security', '🙃Page 2', '🌐Visit Clerk', '🌐Visit User page'];
194+
for (let i = 0; i < buttons.length; i++) {
195+
await expect(buttons[i]).toHaveText(expectedTexts[i]);
196+
}
197+
});
198+
199+
test('children should be leaking when used with asProvider', async ({ page, context }) => {
200+
const u = createTestUtils({ app, page, context });
201+
await u.po.signIn.goTo();
202+
await u.po.signIn.waitForMounted();
203+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
204+
await u.po.expect.toBeSignedIn();
205+
206+
await u.page.goToRelative(CUSTOM_BUTTON_TRIGGER_PAGE);
207+
const toggleButton = await u.page.waitForSelector('button[data-toggle-btn]');
208+
await toggleButton.click();
209+
210+
await u.po.userButton.waitForPopover();
211+
await u.po.userButton.triggerManageAccount();
212+
await u.po.userProfile.waitForMounted();
213+
214+
await expect(u.page.locator('p[data-leaked-child]')).toBeVisible();
215+
});
216+
});
217+
152218
test.describe('User Button custom items', () => {
153219
test('items at the specified order', async ({ page, context }) => {
154220
const u = createTestUtils({ app, page, context });

‎packages/clerk-js/bundlewatch.config.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.browser.js", "maxSize": "65kB" },
3+
{ "path": "./dist/clerk.browser.js", "maxSize": "64.8kB" },
44
{ "path": "./dist/clerk.headless.js", "maxSize": "43kB" },
55
{ "path": "./dist/ui-common*.js", "maxSize": "86KB" },
66
{ "path": "./dist/vendors*.js", "maxSize": "70KB" },

‎packages/clerk-js/src/core/clerk.ts

+7
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,13 @@ export class Clerk implements ClerkInterface {
667667
void this.#componentControls?.ensureMounted().then(controls => controls.unmountComponent({ node }));
668668
};
669669

670+
public __experimental_prefetchOrganizationSwitcher = () => {
671+
this.assertComponentsReady(this.#componentControls);
672+
void this.#componentControls
673+
?.ensureMounted({ preloadHint: 'OrganizationSwitcher' })
674+
.then(controls => controls.prefetch('organizationSwitcher'));
675+
};
676+
670677
public mountOrganizationList = (node: HTMLDivElement, props?: OrganizationListProps) => {
671678
this.assertComponentsReady(this.#componentControls);
672679
if (disabledOrganizationsFeature(this, this.environment)) {

‎packages/clerk-js/src/ui/Components.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
LazyModalRenderer,
3838
LazyOneTapRenderer,
3939
LazyProviders,
40+
OrganizationSwitcherPrefetch,
4041
} from './lazyModules/providers';
4142
import type { AvailableComponentProps } from './types';
4243

@@ -88,6 +89,7 @@ export type ComponentControls = {
8889
notify?: boolean;
8990
},
9091
) => void;
92+
prefetch: (component: 'organizationSwitcher') => void;
9193
// Special case, as the impersonation fab mounts automatically
9294
mountImpersonationFab: () => void;
9395
};
@@ -116,6 +118,7 @@ interface ComponentsState {
116118
userVerificationModal: null | __experimental_UserVerificationProps;
117119
organizationProfileModal: null | OrganizationProfileProps;
118120
createOrganizationModal: null | CreateOrganizationProps;
121+
organizationSwitcherPrefetch: boolean;
119122
nodes: Map<HTMLDivElement, HtmlNodeOptions>;
120123
impersonationFab: boolean;
121124
}
@@ -193,6 +196,7 @@ const Components = (props: ComponentsProps) => {
193196
userVerificationModal: null,
194197
organizationProfileModal: null,
195198
createOrganizationModal: null,
199+
organizationSwitcherPrefetch: false,
196200
nodes: new Map(),
197201
impersonationFab: false,
198202
});
@@ -301,6 +305,10 @@ const Components = (props: ComponentsProps) => {
301305
setState(s => ({ ...s, impersonationFab: true }));
302306
};
303307

308+
componentsControls.prefetch = component => {
309+
setState(s => ({ ...s, [`${component}Prefetch`]: true }));
310+
};
311+
304312
props.onComponentsMounted();
305313
}, []);
306314

@@ -452,6 +460,8 @@ const Components = (props: ComponentsProps) => {
452460
<ImpersonationFab />
453461
</LazyImpersonationFabProvider>
454462
)}
463+
464+
<Suspense>{state.organizationSwitcherPrefetch && <OrganizationSwitcherPrefetch />}</Suspense>
455465
</LazyProviders>
456466
</Suspense>
457467
);
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useId } from 'react';
1+
import type { ReactElement } from 'react';
2+
import { cloneElement, useId } from 'react';
23

34
import { AcceptedInvitationsProvider, useOrganizationSwitcherContext, withCoreUserGuard } from '../../contexts';
45
import { Flow } from '../../customizables';
@@ -7,8 +8,9 @@ import { usePopover } from '../../hooks';
78
import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover';
89
import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger';
910

10-
const _OrganizationSwitcher = withFloatingTree(() => {
11+
const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => {
1112
const { defaultOpen } = useOrganizationSwitcherContext();
13+
1214
const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({
1315
defaultOpen,
1416
placement: 'bottom-start',
@@ -17,34 +19,50 @@ const _OrganizationSwitcher = withFloatingTree(() => {
1719

1820
const switcherButtonMenuId = useId();
1921

22+
return (
23+
<>
24+
<OrganizationSwitcherTrigger
25+
ref={reference}
26+
onClick={toggle}
27+
isOpen={isOpen}
28+
aria-controls={isOpen ? switcherButtonMenuId : undefined}
29+
aria-expanded={isOpen}
30+
/>
31+
<Popover
32+
nodeId={nodeId}
33+
context={context}
34+
isOpen={isOpen}
35+
>
36+
{cloneElement(children, {
37+
id: switcherButtonMenuId,
38+
close: toggle,
39+
ref: floating,
40+
style: styles,
41+
})}
42+
</Popover>
43+
</>
44+
);
45+
});
46+
47+
const _OrganizationSwitcher = () => {
48+
const { __experimental_asStandalone } = useOrganizationSwitcherContext();
49+
2050
return (
2151
<Flow.Root
2252
flow='organizationSwitcher'
2353
sx={{ display: 'inline-flex' }}
2454
>
2555
<AcceptedInvitationsProvider>
26-
<OrganizationSwitcherTrigger
27-
ref={reference}
28-
onClick={toggle}
29-
isOpen={isOpen}
30-
aria-controls={isOpen ? switcherButtonMenuId : undefined}
31-
aria-expanded={isOpen}
32-
/>
33-
<Popover
34-
nodeId={nodeId}
35-
context={context}
36-
isOpen={isOpen}
37-
>
38-
<OrganizationSwitcherPopover
39-
id={switcherButtonMenuId}
40-
close={toggle}
41-
ref={floating}
42-
style={{ ...styles }}
43-
/>
44-
</Popover>
56+
{__experimental_asStandalone ? (
57+
<OrganizationSwitcherPopover />
58+
) : (
59+
<OrganizationSwitcherWithFloatingTree>
60+
<OrganizationSwitcherPopover />
61+
</OrganizationSwitcherWithFloatingTree>
62+
)}
4563
</AcceptedInvitationsProvider>
4664
</Flow.Root>
4765
);
48-
});
66+
};
4967

5068
export const OrganizationSwitcher = withCoreUserGuard(withCardStateProvider(_OrganizationSwitcher));

‎packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ import { useRouter } from '../../router';
2020
import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem';
2121
import { OrganizationActionList } from './OtherOrganizationActions';
2222

23-
type OrganizationSwitcherPopoverProps = { close: () => void } & PropsOfComponent<typeof PopoverCard.Root>;
23+
type OrganizationSwitcherPopoverProps = {
24+
close?: (open: boolean | ((prevState: boolean) => boolean)) => void;
25+
} & PropsOfComponent<typeof PopoverCard.Root>;
2426

2527
export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, OrganizationSwitcherPopoverProps>(
2628
(props, ref) => {
27-
const { close, ...rest } = props;
29+
const { close: unsafeClose, ...rest } = props;
30+
const close = () => unsafeClose?.(false);
2831
const card = useCardState();
32+
const { __experimental_asStandalone } = useOrganizationSwitcherContext();
2933
const { openOrganizationProfile, openCreateOrganization } = useClerk();
3034
const { organization: currentOrg } = useOrganization();
3135
const { isLoaded, setActive } = useOrganizationList();
@@ -191,6 +195,7 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
191195
ref={ref}
192196
role='dialog'
193197
aria-label={`${currentOrg?.name} is active`}
198+
shouldEntryAnimate={!__experimental_asStandalone}
194199
{...rest}
195200
>
196201
<PopoverCard.Content elementDescriptor={descriptors.organizationSwitcherPopoverMain}>

‎packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MembershipRole } from '@clerk/types';
22
import { describe } from '@jest/globals';
3+
import { waitFor } from '@testing-library/react';
34

45
import { act, render } from '../../../../testUtils';
56
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
@@ -119,10 +120,25 @@ describe('OrganizationSwitcher', () => {
119120

120121
props.setProps({ hidePersonal: true });
121122
const { getByText, getByRole, userEvent } = render(<OrganizationSwitcher />, { wrapper });
122-
await userEvent.click(getByRole('button'));
123+
await userEvent.click(getByRole('button', { name: 'Open organization switcher' }));
123124
expect(getByText('Create organization')).toBeInTheDocument();
124125
});
125126

127+
it('renders organization switcher popover as standalone', async () => {
128+
const { wrapper, props } = await createFixtures(f => {
129+
f.withOrganizations();
130+
f.withUser({ email_addresses: ['test@clerk.com'], create_organization_enabled: true });
131+
});
132+
props.setProps({
133+
__experimental_asStandalone: true,
134+
});
135+
const { getByText, queryByRole } = render(<OrganizationSwitcher />, { wrapper });
136+
await waitFor(() => {
137+
expect(queryByRole('button', { name: 'Open organization switcher' })).toBeNull();
138+
expect(getByText('Personal account')).toBeInTheDocument();
139+
});
140+
});
141+
126142
it('lists all organizations the user belongs to', async () => {
127143
const { wrapper, props, fixtures } = await createFixtures(f => {
128144
f.withOrganizations();
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useId } from 'react';
1+
import { cloneElement, type ReactElement, useId } from 'react';
22

33
import { useUserButtonContext, withCoreUserGuard } from '../../contexts';
44
import { Flow } from '../../customizables';
@@ -7,8 +7,9 @@ import { usePopover } from '../../hooks';
77
import { UserButtonPopover } from './UserButtonPopover';
88
import { UserButtonTrigger } from './UserButtonTrigger';
99

10-
const _UserButton = withFloatingTree(() => {
10+
const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => {
1111
const { defaultOpen } = useUserButtonContext();
12+
1213
const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({
1314
defaultOpen,
1415
placement: 'bottom-end',
@@ -18,10 +19,7 @@ const _UserButton = withFloatingTree(() => {
1819
const userButtonMenuId = useId();
1920

2021
return (
21-
<Flow.Root
22-
flow='userButton'
23-
sx={{ display: 'inline-flex' }}
24-
>
22+
<>
2523
<UserButtonTrigger
2624
ref={reference}
2725
onClick={toggle}
@@ -34,15 +32,34 @@ const _UserButton = withFloatingTree(() => {
3432
context={context}
3533
isOpen={isOpen}
3634
>
37-
<UserButtonPopover
38-
id={userButtonMenuId}
39-
close={toggle}
40-
ref={floating}
41-
style={{ ...styles }}
42-
/>
35+
{cloneElement(children, {
36+
id: userButtonMenuId,
37+
close: toggle,
38+
ref: floating,
39+
style: styles,
40+
})}
4341
</Popover>
44-
</Flow.Root>
42+
</>
4543
);
4644
});
4745

46+
const _UserButton = () => {
47+
const { __experimental_asStandalone } = useUserButtonContext();
48+
49+
return (
50+
<Flow.Root
51+
flow='userButton'
52+
sx={{ display: 'inline-flex' }}
53+
>
54+
{__experimental_asStandalone ? (
55+
<UserButtonPopover />
56+
) : (
57+
<UserButtonWithFloatingTree>
58+
<UserButtonPopover />
59+
</UserButtonWithFloatingTree>
60+
)}
61+
</Flow.Root>
62+
);
63+
};
64+
4865
export const UserButton = withCoreUserGuard(withCardStateProvider(_UserButton));

‎packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import type { PropsOfComponent } from '../../styledSystem';
99
import { MultiSessionActions, SignOutAllActions, SingleSessionActions } from './SessionActions';
1010
import { useMultisessionActions } from './useMultisessionActions';
1111

12-
type UserButtonPopoverProps = { close: () => void } & PropsOfComponent<typeof PopoverCard.Root>;
12+
type UserButtonPopoverProps = { close?: () => void } & PropsOfComponent<typeof PopoverCard.Root>;
1313

1414
export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopoverProps>((props, ref) => {
15-
const { close, ...rest } = props;
15+
const { close: unsafeClose, ...rest } = props;
16+
const close = () => unsafeClose?.();
1617
const { session } = useSession() as { session: ActiveSessionResource };
18+
const { __experimental_asStandalone } = useUserButtonContext();
1719
const { authConfig } = useEnvironment();
1820
const { user } = useUser();
1921
const {
@@ -33,6 +35,7 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
3335
ref={ref}
3436
role='dialog'
3537
aria-label='User button popover'
38+
shouldEntryAnimate={!__experimental_asStandalone}
3639
{...rest}
3740
>
3841
<PopoverCard.Content elementDescriptor={descriptors.userButtonPopoverMain}>

‎packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx

+12
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ describe('UserButton', () => {
2121
expect(queryByRole('button')).not.toBeNull();
2222
});
2323

24+
it('renders popover as standalone when there is a user', async () => {
25+
const { wrapper, props } = await createFixtures(f => {
26+
f.withUser({ email_addresses: ['test@clerk.com'] });
27+
});
28+
props.setProps({
29+
__experimental_asStandalone: true,
30+
});
31+
const { getByText, queryByRole } = render(<UserButton />, { wrapper });
32+
expect(queryByRole('button', { name: 'Open user button' })).toBeNull();
33+
getByText('Manage account');
34+
});
35+
2436
it('opens the user button popover when clicked', async () => {
2537
const { wrapper } = await createFixtures(f => {
2638
f.withUser({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { useOrganizationList } from '@clerk/shared/react';
2+
3+
import { organizationListParams } from './OrganizationSwitcher/utils';
4+
5+
export function OrganizationSwitcherPrefetch() {
6+
useOrganizationList(organizationListParams);
7+
return null;
8+
}

‎packages/clerk-js/src/ui/elements/PopoverCard.tsx

+25-11
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,41 @@ import React from 'react';
33
import { useEnvironment } from '../contexts';
44
import { Col, descriptors, Flex, Flow, useAppearance } from '../customizables';
55
import type { ElementDescriptor } from '../customizables/elementDescriptors';
6-
import type { PropsOfComponent } from '../styledSystem';
6+
import type { PropsOfComponent, ThemableCssProp } from '../styledSystem';
77
import { animations, common } from '../styledSystem';
88
import { colors } from '../utils';
99
import { Card } from '.';
1010

11-
const PopoverCardRoot = React.forwardRef<HTMLDivElement, PropsOfComponent<typeof Card.Content>>((props, ref) => {
12-
const { elementDescriptor, ...rest } = props;
11+
const PopoverCardRoot = React.forwardRef<
12+
HTMLDivElement,
13+
PropsOfComponent<typeof Card.Content> & {
14+
shouldEntryAnimate?: boolean;
15+
}
16+
>((props, ref) => {
17+
const { elementDescriptor, shouldEntryAnimate = true, ...rest } = props;
18+
19+
const withAnimation: ThemableCssProp = t => ({
20+
animation: shouldEntryAnimate
21+
? `${animations.dropdownSlideInScaleAndFade} ${t.transitionDuration.$fast}`
22+
: undefined,
23+
});
24+
1325
return (
1426
<Flow.Part part='popover'>
1527
<Card.Root
1628
elementDescriptor={[descriptors.popoverBox, elementDescriptor as ElementDescriptor]}
1729
{...rest}
1830
ref={ref}
19-
sx={t => ({
20-
width: t.sizes.$94,
21-
maxWidth: `calc(100vw - ${t.sizes.$8})`,
22-
zIndex: t.zIndices.$modal,
23-
borderRadius: t.radii.$xl,
24-
animation: `${animations.dropdownSlideInScaleAndFade} ${t.transitionDuration.$fast}`,
25-
outline: 'none',
26-
})}
31+
sx={[
32+
t => ({
33+
width: t.sizes.$94,
34+
maxWidth: `calc(100vw - ${t.sizes.$8})`,
35+
zIndex: t.zIndices.$modal,
36+
borderRadius: t.radii.$xl,
37+
outline: 'none',
38+
}),
39+
withAnimation,
40+
]}
2741
>
2842
{props.children}
2943
</Card.Root>

‎packages/clerk-js/src/ui/lazyModules/providers.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ const Portal = lazy(() => import('./../portal').then(m => ({ default: m.Portal }
1616
const VirtualBodyRootPortal = lazy(() => import('./../portal').then(m => ({ default: m.VirtualBodyRootPortal })));
1717
const FlowMetadataProvider = lazy(() => import('./../elements').then(m => ({ default: m.FlowMetadataProvider })));
1818
const Modal = lazy(() => import('./../elements').then(m => ({ default: m.Modal })));
19+
const OrganizationSwitcherPrefetch = lazy(() =>
20+
import(/* webpackChunkName: "prefetchorganizationlist" */ '../components/prefetch-organization-list').then(m => ({
21+
default: m.OrganizationSwitcherPrefetch,
22+
})),
23+
);
1924

2025
type LazyProvidersProps = React.PropsWithChildren<{ clerk: any; environment: any; options: any; children: any }>;
2126

@@ -155,3 +160,5 @@ export const LazyOneTapRenderer = (props: LazyOneTapRendererProps) => {
155160
</AppearanceProvider>
156161
);
157162
};
163+
164+
export { OrganizationSwitcherPrefetch };

‎packages/react/src/components/uiComponents.tsx

+170-40
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
Without,
1414
} from '@clerk/types';
1515
import type { PropsWithChildren } from 'react';
16-
import React, { createElement } from 'react';
16+
import React, { createContext, createElement, useContext } from 'react';
1717

1818
import {
1919
organizationProfileLinkRenderedError,
@@ -25,6 +25,7 @@ import {
2525
userProfilePageRenderedError,
2626
} from '../errors/messages';
2727
import type {
28+
CustomPortalsRendererProps,
2829
MountProps,
2930
OpenProps,
3031
OrganizationProfileLinkProps,
@@ -35,7 +36,12 @@ import type {
3536
UserProfilePageProps,
3637
WithClerkProp,
3738
} from '../types';
38-
import { useOrganizationProfileCustomPages, useUserButtonCustomMenuItems, useUserProfileCustomPages } from '../utils';
39+
import {
40+
useOrganizationProfileCustomPages,
41+
useSanitizedChildren,
42+
useUserButtonCustomMenuItems,
43+
useUserProfileCustomPages,
44+
} from '../utils';
3945
import { withClerk } from './withClerk';
4046

4147
type UserProfileExportType = typeof _UserProfile & {
@@ -49,10 +55,26 @@ type UserButtonExportType = typeof _UserButton & {
4955
MenuItems: typeof MenuItems;
5056
Action: typeof MenuAction;
5157
Link: typeof MenuLink;
58+
/**
59+
* The `<Outlet />` component can be used in conjunction with `asProvider` in order to control rendering
60+
* of the `<OrganizationSwitcher />` without affecting its configuration or any custom pages
61+
* that could be mounted
62+
* @experimental This API is experimental and may change at any moment.
63+
*/
64+
__experimental_Outlet: typeof UserButtonOutlet;
5265
};
5366

54-
type UserButtonPropsWithoutCustomPages = Without<UserButtonProps, 'userProfileProps'> & {
67+
type UserButtonPropsWithoutCustomPages = Without<
68+
UserButtonProps,
69+
'userProfileProps' | '__experimental_asStandalone'
70+
> & {
5571
userProfileProps?: Pick<UserProfileProps, 'additionalOAuthScopes' | 'appearance'>;
72+
/**
73+
* Adding `asProvider` will defer rendering until the `<Outlet />` component is mounted.
74+
* @experimental This API is experimental and may change at any moment.
75+
* @default undefined
76+
*/
77+
__experimental_asProvider?: boolean;
5678
};
5779

5880
type OrganizationProfileExportType = typeof _OrganizationProfile & {
@@ -63,10 +85,34 @@ type OrganizationProfileExportType = typeof _OrganizationProfile & {
6385
type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & {
6486
OrganizationProfilePage: typeof OrganizationProfilePage;
6587
OrganizationProfileLink: typeof OrganizationProfileLink;
88+
/**
89+
* The `<Outlet />` component can be used in conjunction with `asProvider` in order to control rendering
90+
* of the `<OrganizationSwitcher />` without affecting its configuration or any custom pages
91+
* that could be mounted
92+
* @experimental This API is experimental and may change at any moment.
93+
*/
94+
__experimental_Outlet: typeof OrganizationSwitcherOutlet;
6695
};
6796

68-
type OrganizationSwitcherPropsWithoutCustomPages = Without<OrganizationSwitcherProps, 'organizationProfileProps'> & {
97+
type OrganizationSwitcherPropsWithoutCustomPages = Without<
98+
OrganizationSwitcherProps,
99+
'organizationProfileProps' | '__experimental_asStandalone'
100+
> & {
69101
organizationProfileProps?: Pick<OrganizationProfileProps, 'appearance'>;
102+
/**
103+
* Adding `asProvider` will defer rendering until the `<Outlet />` component is mounted.
104+
* @experimental This API is experimental and may change at any moment.
105+
* @default undefined
106+
*/
107+
__experimental_asProvider?: boolean;
108+
};
109+
110+
const isMountProps = (props: any): props is MountProps => {
111+
return 'mount' in props;
112+
};
113+
114+
const isOpenProps = (props: any): props is OpenProps => {
115+
return 'open' in props;
70116
};
71117

72118
// README: <Portal/> should be a class pure component in order for mount and unmount
@@ -98,15 +144,9 @@ type OrganizationSwitcherPropsWithoutCustomPages = Without<OrganizationSwitcherP
98144

99145
// Portal.displayName = 'ClerkPortal';
100146

101-
const isMountProps = (props: any): props is MountProps => {
102-
return 'mount' in props;
103-
};
104-
105-
const isOpenProps = (props: any): props is OpenProps => {
106-
return 'open' in props;
107-
};
108-
109-
class Portal extends React.PureComponent<MountProps | OpenProps> {
147+
class Portal extends React.PureComponent<
148+
PropsWithChildren<(MountProps | OpenProps) & { hideRootHtmlElement?: boolean }>
149+
> {
110150
private portalRef = React.createRef<HTMLDivElement>();
111151

112152
componentDidUpdate(_prevProps: Readonly<MountProps | OpenProps>) {
@@ -122,8 +162,11 @@ class Portal extends React.PureComponent<MountProps | OpenProps> {
122162
// instead, we simply use the length of customPages to determine if it changed or not
123163
const customPagesChanged = prevProps.customPages?.length !== newProps.customPages?.length;
124164
const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length;
165+
125166
if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) {
126-
this.props.updateProps({ node: this.portalRef.current, props: this.props.props });
167+
if (this.portalRef.current) {
168+
this.props.updateProps({ node: this.portalRef.current, props: this.props.props });
169+
}
127170
}
128171
}
129172

@@ -151,18 +194,25 @@ class Portal extends React.PureComponent<MountProps | OpenProps> {
151194
}
152195

153196
render() {
197+
const { hideRootHtmlElement = false } = this.props;
154198
return (
155199
<>
156-
<div ref={this.portalRef} />
157-
{isMountProps(this.props) &&
158-
this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))}
159-
{isMountProps(this.props) &&
160-
this.props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))}
200+
{!hideRootHtmlElement && <div ref={this.portalRef} />}
201+
{this.props.children}
161202
</>
162203
);
163204
}
164205
}
165206

207+
const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => {
208+
return (
209+
<>
210+
{props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))}
211+
{props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))}
212+
</>
213+
);
214+
};
215+
166216
export const SignIn = withClerk(({ clerk, ...props }: WithClerkProp<SignInProps>) => {
167217
return (
168218
<Portal
@@ -204,8 +254,9 @@ const _UserProfile = withClerk(
204254
unmount={clerk.unmountUserProfile}
205255
updateProps={(clerk as any).__unstable__updateProps}
206256
props={{ ...props, customPages }}
207-
customPagesPortals={customPagesPortals}
208-
/>
257+
>
258+
<CustomPortalsRenderer customPagesPortals={customPagesPortals} />
259+
</Portal>
209260
);
210261
},
211262
'UserProfile',
@@ -216,21 +267,43 @@ export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, {
216267
Link: UserProfileLink,
217268
});
218269

270+
const UserButtonContext = createContext<MountProps>({
271+
mount: () => {},
272+
unmount: () => {},
273+
updateProps: () => {},
274+
});
275+
219276
const _UserButton = withClerk(
220277
({ clerk, ...props }: WithClerkProp<PropsWithChildren<UserButtonPropsWithoutCustomPages>>) => {
221-
const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children);
278+
const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children, {
279+
allowForAnyChildren: !!props.__experimental_asProvider,
280+
});
222281
const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages });
223282
const { customMenuItems, customMenuItemsPortals } = useUserButtonCustomMenuItems(props.children);
283+
const sanitizedChildren = useSanitizedChildren(props.children);
284+
285+
const passableProps = {
286+
mount: clerk.mountUserButton,
287+
unmount: clerk.unmountUserButton,
288+
updateProps: (clerk as any).__unstable__updateProps,
289+
props: { ...props, userProfileProps, customMenuItems },
290+
};
291+
const portalProps = {
292+
customPagesPortals: customPagesPortals,
293+
customMenuItemsPortals: customMenuItemsPortals,
294+
};
224295

225296
return (
226-
<Portal
227-
mount={clerk.mountUserButton}
228-
unmount={clerk.unmountUserButton}
229-
updateProps={(clerk as any).__unstable__updateProps}
230-
props={{ ...props, userProfileProps, customMenuItems }}
231-
customPagesPortals={customPagesPortals}
232-
customMenuItemsPortals={customMenuItemsPortals}
233-
/>
297+
<UserButtonContext.Provider value={passableProps}>
298+
<Portal
299+
{...passableProps}
300+
hideRootHtmlElement={!!props.__experimental_asProvider}
301+
>
302+
{/*This mimics the previous behaviour before asProvider existed*/}
303+
{props.__experimental_asProvider ? sanitizedChildren : null}
304+
<CustomPortalsRenderer {...portalProps} />
305+
</Portal>
306+
</UserButtonContext.Provider>
234307
);
235308
},
236309
'UserButton',
@@ -251,12 +324,27 @@ export function MenuLink({ children }: PropsWithChildren<UserButtonLinkProps>) {
251324
return <>{children}</>;
252325
}
253326

327+
export function UserButtonOutlet(outletProps: Without<UserButtonProps, 'userProfileProps'>) {
328+
const providerProps = useContext(UserButtonContext);
329+
330+
const portalProps = {
331+
...providerProps,
332+
props: {
333+
...providerProps.props,
334+
...outletProps,
335+
},
336+
} satisfies MountProps;
337+
338+
return <Portal {...portalProps} />;
339+
}
340+
254341
export const UserButton: UserButtonExportType = Object.assign(_UserButton, {
255342
UserProfilePage,
256343
UserProfileLink,
257344
MenuItems,
258345
Action: MenuAction,
259346
Link: MenuLink,
347+
__experimental_Outlet: UserButtonOutlet,
260348
});
261349

262350
export function OrganizationProfilePage({ children }: PropsWithChildren<OrganizationProfilePageProps>) {
@@ -278,8 +366,9 @@ const _OrganizationProfile = withClerk(
278366
unmount={clerk.unmountOrganizationProfile}
279367
updateProps={(clerk as any).__unstable__updateProps}
280368
props={{ ...props, customPages }}
281-
customPagesPortals={customPagesPortals}
282-
/>
369+
>
370+
<CustomPortalsRenderer customPagesPortals={customPagesPortals} />
371+
</Portal>
283372
);
284373
},
285374
'OrganizationProfile',
@@ -301,27 +390,68 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp<
301390
);
302391
}, 'CreateOrganization');
303392

393+
const OrganizationSwitcherContext = createContext<MountProps>({
394+
mount: () => {},
395+
unmount: () => {},
396+
updateProps: () => {},
397+
});
398+
304399
const _OrganizationSwitcher = withClerk(
305400
({ clerk, ...props }: WithClerkProp<PropsWithChildren<OrganizationSwitcherPropsWithoutCustomPages>>) => {
306-
const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children);
401+
const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children, {
402+
allowForAnyChildren: !!props.__experimental_asProvider,
403+
});
307404
const organizationProfileProps = Object.assign(props.organizationProfileProps || {}, { customPages });
405+
const sanitizedChildren = useSanitizedChildren(props.children);
406+
407+
const passableProps = {
408+
mount: clerk.mountOrganizationSwitcher,
409+
unmount: clerk.unmountOrganizationSwitcher,
410+
updateProps: (clerk as any).__unstable__updateProps,
411+
props: { ...props, organizationProfileProps },
412+
};
413+
414+
/**
415+
* Prefetch organization list
416+
*/
417+
clerk.__experimental_prefetchOrganizationSwitcher();
308418

309419
return (
310-
<Portal
311-
mount={clerk.mountOrganizationSwitcher}
312-
unmount={clerk.unmountOrganizationSwitcher}
313-
updateProps={(clerk as any).__unstable__updateProps}
314-
props={{ ...props, organizationProfileProps }}
315-
customPagesPortals={customPagesPortals}
316-
/>
420+
<OrganizationSwitcherContext.Provider value={passableProps}>
421+
<Portal
422+
{...passableProps}
423+
hideRootHtmlElement={!!props.__experimental_asProvider}
424+
>
425+
{/*This mimics the previous behaviour before asProvider existed*/}
426+
{props.__experimental_asProvider ? sanitizedChildren : null}
427+
<CustomPortalsRenderer customPagesPortals={customPagesPortals} />
428+
</Portal>
429+
</OrganizationSwitcherContext.Provider>
317430
);
318431
},
319432
'OrganizationSwitcher',
320433
);
321434

435+
export function OrganizationSwitcherOutlet(
436+
outletProps: Without<OrganizationSwitcherProps, 'organizationProfileProps'>,
437+
) {
438+
const providerProps = useContext(OrganizationSwitcherContext);
439+
440+
const portalProps = {
441+
...providerProps,
442+
props: {
443+
...providerProps.props,
444+
...outletProps,
445+
},
446+
} satisfies MountProps;
447+
448+
return <Portal {...portalProps} />;
449+
}
450+
322451
export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, {
323452
OrganizationProfilePage,
324453
OrganizationProfileLink,
454+
__experimental_Outlet: OrganizationSwitcherOutlet,
325455
});
326456

327457
export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp<OrganizationListProps>) => {

‎packages/react/src/isomorphicClerk.ts

+9
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
841841
}
842842
};
843843

844+
__experimental_prefetchOrganizationSwitcher = (): void => {
845+
const callback = () => this.clerkjs?.__experimental_prefetchOrganizationSwitcher();
846+
if (this.clerkjs && this.#loaded) {
847+
void callback();
848+
} else {
849+
this.premountMethodCalls.set('__experimental_prefetchOrganizationSwitcher', callback);
850+
}
851+
};
852+
844853
mountOrganizationList = (node: HTMLDivElement, props: OrganizationListProps): void => {
845854
if (this.clerkjs && this.#loaded) {
846855
this.clerkjs.mountOrganizationList(node, props);

‎packages/react/src/types.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,17 @@ export interface HeadlessBrowserClerkConstructor {
6767

6868
export type WithClerkProp<T = unknown> = T & { clerk: LoadedClerk };
6969

70+
export interface CustomPortalsRendererProps {
71+
customPagesPortals?: any[];
72+
customMenuItemsPortals?: any[];
73+
}
74+
7075
// Clerk object
7176
export interface MountProps {
7277
mount: (node: HTMLDivElement, props: any) => void;
7378
unmount: (node: HTMLDivElement) => void;
7479
updateProps: (props: any) => void;
7580
props?: any;
76-
customPagesPortals?: any[];
77-
customMenuItemsPortals?: any[];
7881
}
7982

8083
export interface OpenProps {

‎packages/react/src/utils/useCustomPages.tsx

+71-26
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,39 @@ import { isThatComponent } from './componentValidation';
1616
import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal';
1717
import { useCustomElementPortal } from './useCustomElementPortal';
1818

19-
export const useUserProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => {
19+
export const useUserProfileCustomPages = (
20+
children: React.ReactNode | React.ReactNode[],
21+
options?: UseCustomPagesOptions,
22+
) => {
2023
const reorderItemsLabels = ['account', 'security'];
21-
return useCustomPages({
22-
children,
23-
reorderItemsLabels,
24-
LinkComponent: UserProfileLink,
25-
PageComponent: UserProfilePage,
26-
MenuItemsComponent: MenuItems,
27-
componentName: 'UserProfile',
28-
});
24+
return useCustomPages(
25+
{
26+
children,
27+
reorderItemsLabels,
28+
LinkComponent: UserProfileLink,
29+
PageComponent: UserProfilePage,
30+
MenuItemsComponent: MenuItems,
31+
componentName: 'UserProfile',
32+
},
33+
options,
34+
);
2935
};
3036

31-
export const useOrganizationProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => {
37+
export const useOrganizationProfileCustomPages = (
38+
children: React.ReactNode | React.ReactNode[],
39+
options?: UseCustomPagesOptions,
40+
) => {
3241
const reorderItemsLabels = ['general', 'members'];
33-
return useCustomPages({
34-
children,
35-
reorderItemsLabels,
36-
LinkComponent: OrganizationProfileLink,
37-
PageComponent: OrganizationProfilePage,
38-
componentName: 'OrganizationProfile',
39-
});
42+
return useCustomPages(
43+
{
44+
children,
45+
reorderItemsLabels,
46+
LinkComponent: OrganizationProfileLink,
47+
PageComponent: OrganizationProfilePage,
48+
componentName: 'OrganizationProfile',
49+
},
50+
options,
51+
);
4052
};
4153

4254
type UseCustomPagesParams = {
@@ -48,16 +60,49 @@ type UseCustomPagesParams = {
4860
componentName: string;
4961
};
5062

63+
type UseCustomPagesOptions = {
64+
allowForAnyChildren: boolean;
65+
};
66+
5167
type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode };
5268

53-
const useCustomPages = ({
54-
children,
55-
LinkComponent,
56-
PageComponent,
57-
MenuItemsComponent,
58-
reorderItemsLabels,
59-
componentName,
60-
}: UseCustomPagesParams) => {
69+
/**
70+
* Exclude any children that is used for identifying Custom Pages or Custom Items.
71+
* Passing:
72+
* ```tsx
73+
* <UserProfile.Page/>
74+
* <OrganizationProfile.Link/>
75+
* <MyComponent>
76+
* <UserButton.MenuItems/>
77+
* ```
78+
* Gives back
79+
* ```tsx
80+
* <MyComponent>
81+
* ````
82+
*/
83+
export const useSanitizedChildren = (children: React.ReactNode) => {
84+
const sanitizedChildren: React.ReactNode[] = [];
85+
86+
const excludedComponents: any[] = [
87+
OrganizationProfileLink,
88+
OrganizationProfilePage,
89+
MenuItems,
90+
UserProfilePage,
91+
UserProfileLink,
92+
];
93+
94+
React.Children.forEach(children, child => {
95+
if (!excludedComponents.some(component => isThatComponent(child, component))) {
96+
sanitizedChildren.push(child);
97+
}
98+
});
99+
100+
return sanitizedChildren;
101+
};
102+
103+
const useCustomPages = (params: UseCustomPagesParams, options?: UseCustomPagesOptions) => {
104+
const { children, LinkComponent, PageComponent, MenuItemsComponent, reorderItemsLabels, componentName } = params;
105+
const { allowForAnyChildren = false } = options || {};
61106
const validChildren: CustomPageWithIdType[] = [];
62107

63108
React.Children.forEach(children, child => {
@@ -66,7 +111,7 @@ const useCustomPages = ({
66111
!isThatComponent(child, LinkComponent) &&
67112
!isThatComponent(child, MenuItemsComponent)
68113
) {
69-
if (child) {
114+
if (child && !allowForAnyChildren) {
70115
logErrorInDevMode(customPagesIgnoredComponent(componentName));
71116
}
72117
return;

‎packages/types/src/clerk.ts

+26
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,14 @@ export interface Clerk {
329329
*/
330330
unmountOrganizationSwitcher: (targetNode: HTMLDivElement) => void;
331331

332+
/**
333+
* Prefetches the data displayed by an organization switcher.
334+
* It can be used when `mountOrganizationSwitcher({ asStandalone: true})`, to avoid unwanted loading states.
335+
* @experimantal This API is still under active development and may change at any moment.
336+
* @param props Optional user verification configuration parameters.
337+
*/
338+
__experimental_prefetchOrganizationSwitcher: () => void;
339+
332340
/**
333341
* Mount an organization list component at the target element.
334342
* @param targetNode Target to mount the OrganizationList component.
@@ -1035,6 +1043,15 @@ export type UserButtonProps = UserButtonProfileMode & {
10351043
* Controls the default state of the UserButton
10361044
*/
10371045
defaultOpen?: boolean;
1046+
1047+
/**
1048+
* If true the `<UserButton />` will only render the popover.
1049+
* Enables developers to implement a custom dialog.
1050+
* @experimental This API is experimental and may change at any moment.
1051+
* @default undefined
1052+
*/
1053+
__experimental_asStandalone?: boolean;
1054+
10381055
/**
10391056
* Full URL or path to navigate after sign out is complete
10401057
* @deprecated Configure `afterSignOutUrl` as a global configuration, either in <ClerkProvider/> or in await Clerk.load()
@@ -1095,6 +1112,15 @@ export type OrganizationSwitcherProps = CreateOrganizationMode &
10951112
* Controls the default state of the OrganizationSwitcher
10961113
*/
10971114
defaultOpen?: boolean;
1115+
1116+
/**
1117+
* If true, `<OrganizationSwitcher />` will only render the popover.
1118+
* Enables developers to implement a custom dialog.
1119+
* @experimental This API is experimental and may change at any moment.
1120+
* @default undefined
1121+
*/
1122+
__experimental_asStandalone?: boolean;
1123+
10981124
/**
10991125
* By default, users can switch between organization and their personal account.
11001126
* This option controls whether OrganizationSwitcher will include the user's personal account

‎playground/app-router/src/pages/user/[[...index]].tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ const UserProfilePage: NextPage = (props: any) => {
2222
);
2323
};
2424

25-
export default UserProfilePage;
25+
export default UserProfilePage;

0 commit comments

Comments
 (0)
Please sign in to comment.