Skip to content

Commit ce40ff6

Browse files
panteliselefLauraBeatrisLekoArts
authored
feat(clerk-js,types): Standalone UserButton and OrganizationSwitcher (clerk#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
);

0 commit comments

Comments
 (0)