Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
3b240b7
feat(clerk-js): Experimental controlled UserButton
panteliselef May 20, 2024
14f15ee
chore(clerk-js): Add changeset
panteliselef May 20, 2024
92bbb91
feat(clerk-js): Experimental controlled OrganizationSwitcher
panteliselef May 21, 2024
0eb2bda
feat(clerk-js): Standalone OrganizationSwitcher
panteliselef May 24, 2024
74582c6
drop unwanted changes
panteliselef Aug 28, 2024
d187ba2
Revert "drop unwanted changes"
panteliselef Aug 28, 2024
7cd9c0b
support hideTrigger in userButton
panteliselef Aug 28, 2024
75e0933
remove experimental open
panteliselef Aug 28, 2024
d6f58e3
update naming and write jsdoc
panteliselef Aug 28, 2024
2f69080
minor cleanup
panteliselef Aug 28, 2024
22691cb
add unit tests
panteliselef Aug 30, 2024
08a1b3e
update changeset
panteliselef Aug 30, 2024
3c38cd1
Merge branch 'main' into elef/sdk-1754-add-open-and-onopenchange-to-u…
panteliselef Sep 2, 2024
57e39d9
wip
panteliselef Sep 4, 2024
640d7f4
poc state
panteliselef Sep 5, 2024
4988ea4
ready for snapshot
panteliselef Sep 5, 2024
e1348db
Merge branch 'refs/heads/main' into elef/sdk-1754-add-open-and-onopen…
panteliselef Sep 5, 2024
0c42492
skip tests
panteliselef Sep 5, 2024
f3edf18
update bundlewatch.config.json
panteliselef Sep 5, 2024
f99c981
wip prefetch on provider
panteliselef Oct 14, 2024
45509a0
OrganizationSwitcher implement asProvider and asStandalone
panteliselef Oct 14, 2024
9283c25
UserButton implement asProvider and asStandalone
panteliselef Oct 14, 2024
0dbed6c
Merge branch 'refs/heads/main' into elef/sdk-1754-add-open-and-onopen…
panteliselef Oct 14, 2024
b4903a7
bump bundlewatch for clerk-js
panteliselef Oct 14, 2024
0c86be3
update e2e tests
panteliselef Oct 14, 2024
a5b0e9a
Merge branch 'refs/heads/main' into elef/sdk-1754-add-open-and-onopen…
panteliselef Oct 14, 2024
0ac1301
update unit tests
panteliselef Oct 14, 2024
1455348
add jsdoc to `__experimental_prefetchOrganizationSwitcher` and saniti…
panteliselef Oct 14, 2024
24bb37c
Merge branch 'refs/heads/main' into elef/sdk-1754-add-open-and-onopen…
panteliselef Oct 14, 2024
b8f2f7e
update bundlewatch.config.json
panteliselef Oct 14, 2024
7483218
Merge branch 'refs/heads/main' into elef/sdk-1754-add-open-and-onopen…
panteliselef Oct 14, 2024
131e99c
cleanup
panteliselef Oct 14, 2024
727ffed
bring back pages
panteliselef Oct 14, 2024
2f940fd
revert necessary changes
panteliselef Oct 14, 2024
2516815
Apply suggestions from code review
panteliselef Oct 15, 2024
237af16
update changeset
panteliselef Oct 15, 2024
957661c
add changeset for react
panteliselef Oct 15, 2024
6152be0
Merge branch 'refs/heads/main' into elef/sdk-1754-add-open-and-onopen…
panteliselef Oct 15, 2024
7b7f8d5
fix formatting
panteliselef Oct 15, 2024
3ef3bb5
Apply suggestions from code review
panteliselef Oct 15, 2024
a84b075
address review comments
panteliselef Oct 15, 2024
1d67a45
update bundlewatch.config.json
panteliselef Oct 15, 2024
04bb42b
Merge branch 'main' into elef/sdk-1754-add-open-and-onopenchange-to-u…
panteliselef Oct 15, 2024
7fee588
Merge branch 'main' into elef/sdk-1754-add-open-and-onopenchange-to-u…
panteliselef Oct 16, 2024
68fec36
Merge branch 'refs/heads/main' into elef/sdk-1754-add-open-and-onopen…
panteliselef Oct 16, 2024
d4b8a12
when popover with asStandalone avoid entry animations
panteliselef Oct 16, 2024
65fe1c8
Merge branch 'main' into elef/sdk-1754-add-open-and-onopenchange-to-u…
panteliselef Oct 16, 2024
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
26 changes: 26 additions & 0 deletions .changeset/clean-mugs-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@clerk/clerk-react": minor
---

Introducing experimental `asProvider`, `asStandalone`, and `<X.Outlet />` for `<UserButton />` and `<OrganizationSwitcher />` components.
- `asProvider` converts `<UserButton />` and `<OrganizationSwitcher />` to a provider that defers rendering until `<Outlet />` is mounted.
- `<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.

Example usage:
```tsx
<UserButton __experimental_asProvider afterSignOutUrl='/'>
<UserButton.UserProfilePage label="Custom Page" url="/custom-page">
<h1> This is my page available to all children </h1>
</UserButton.UserProfilePage>
<UserButton.__experimental_Outlet __experimental_asStandalone />
</UserButton>
```

```tsx
<OrganizationSwitcher __experimental_asProvider afterSignOutUrl='/'>
<OrganizationSwitcher.OrganizationProfilePage label="Custom Page" url="/custom-page">
<h1> This is my page available to all children </h1>
</OrganizationSwitcher.OrganizationProfilePage>
<OrganizationSwitcher.__experimental_Outlet __experimental_asStandalone />
</OrganizationSwitcher>
```
12 changes: 12 additions & 0 deletions .changeset/shaggy-kids-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Add experimental standalone mode for `<UserButton />` and `<OrganizationSwitcher />`.
When `__experimental_asStandalone: true` the component will not render its trigger, and instead it will render only the contents of the popover in place.

APIs that changed:
- (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.
- New property for `mountOrganizationSwitcher(node, { __experimental_asStandalone: true })`
- New property for `mountUserButton(node, { __experimental_asStandalone: true })`
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { UserButton } from '@clerk/clerk-react';
import { PropsWithChildren, useContext, useState } from 'react';
import { PageContext, PageContextProvider } from '../PageContext.tsx';

function Page1() {
const { counter, setCounter } = useContext(PageContext);

return (
<>
<h1 data-page={1}>Page 1</h1>
<p data-page={1}>Counter: {counter}</p>
<button
data-page={1}
onClick={() => setCounter(a => a + 1)}
>
Update
</button>
</>
);
}

function ToggleChildren(props: PropsWithChildren) {
const [isMounted, setMounted] = useState(false);

return (
<>
<button
data-toggle-btn
onClick={() => setMounted(v => !v)}
>
Toggle
</button>
{isMounted ? props.children : null}
</>
);
}

export default function Page() {
return (
<PageContextProvider>
<UserButton __experimental_asProvider>
<UserButton.UserProfilePage
label={'Page 1'}
labelIcon={<p data-label-icon={'page-1'}>🙃</p>}
url='page-1'
>
<Page1 />
</UserButton.UserProfilePage>
<UserButton.UserProfilePage label={'security'} />
<UserButton.UserProfilePage
label={'Page 2'}
labelIcon={<p data-label-icon={'page-2'}>🙃</p>}
url='page-2'
>
<h1>Page 2</h1>
</UserButton.UserProfilePage>
<p data-leaked-child>This is leaking</p>
<UserButton.UserProfileLink
url={'https://clerk.com'}
label={'Visit Clerk'}
labelIcon={<p data-label-icon={'page-3'}>🌐</p>}
/>
<UserButton.MenuItems>
<UserButton.Action
label={'page-1'}
labelIcon={<span>🙃</span>}
open={'page-1'}
/>
<UserButton.Action label={'manageAccount'} />
<UserButton.Action label={'signOut'} />
<UserButton.Link
href={'http://clerk.com'}
label={'Visit Clerk'}
labelIcon={<span>🌐</span>}
/>

<UserButton.Link
href={'/user'}
label={'Visit User page'}
labelIcon={<span>🌐</span>}
/>

<UserButton.Action
label={'Custom Alert'}
labelIcon={<span>🔔</span>}
onClick={() => alert('custom-alert')}
/>
</UserButton.MenuItems>
<UserButton.UserProfileLink
url={'/user'}
label={'Visit User page'}
labelIcon={<p data-label-icon={'page-4'}>🌐</p>}
/>
<ToggleChildren>
<UserButton.__experimental_Outlet __experimental_asStandalone />
</ToggleChildren>
</UserButton>
</PageContextProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function Page() {
>
<h1>Page 2</h1>
</UserButton.UserProfilePage>
🌐
<p data-leaked-child>This is leaking</p>
<UserButton.UserProfileLink
url={'https://clerk.com'}
label={'Visit Clerk'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function Page() {
>
<h1>Page 2</h1>
</UserProfile.Page>
🌐
<p data-leaked-child>This is leaking</p>
<UserProfile.Link
url={'https://clerk.com'}
label={'Visit Clerk'}
Expand Down
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 @@ -10,6 +10,7 @@ import SignUp from './sign-up';
import UserProfile from './user';
import UserProfileCustom from './custom-user-profile';
import UserButtonCustom from './custom-user-button';
import UserButtonCustomTrigger from './custom-user-button-trigger';

const Root = () => {
const navigate = useNavigate();
Expand Down Expand Up @@ -64,6 +65,10 @@ const router = createBrowserRouter([
path: '/custom-user-button',
element: <UserButtonCustom />,
},
{
path: '/custom-user-button-trigger',
element: <UserButtonCustomTrigger />,
},
],
},
]);
Expand Down
68 changes: 67 additions & 1 deletion integration/tests/custom-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createTestUtils, testAgainstRunningApps } from '../testUtils';

const CUSTOM_PROFILE_PAGE = '/custom-user-profile';
const CUSTOM_BUTTON_PAGE = '/custom-user-button';
const CUSTOM_BUTTON_TRIGGER_PAGE = '/custom-user-button-trigger';

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

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

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

test('renders only custom pages and does not display unrelated child components', 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 waitForMountedComponent(component, u);

const buttons = await u.page.locator('button.cl-navbarButton__custom-page-0').all();
expect(buttons.length).toBe(1);
const [profilePage] = buttons;
await expect(profilePage.locator('div.cl-navbarButtonIcon__custom-page-0')).toHaveText('🙃');
await profilePage.click();

await expect(u.page.locator('p[data-leaked-child]')).toBeHidden();
});

test('user profile custom external absolute link', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
Expand Down Expand Up @@ -149,6 +168,53 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
});
});

test.describe('User Button with experimental asStandalone and asProvider', () => {
test('items at the specified order', 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_TRIGGER_PAGE);
const toggleButton = await u.page.waitForSelector('button[data-toggle-btn]');
await toggleButton.click();

await u.po.userButton.waitForPopover();
await u.po.userButton.triggerManageAccount();
await u.po.userProfile.waitForMounted();

const pagesContainer = u.page.locator('div.cl-navbarButtons').first();

const buttons = await pagesContainer.locator('button').all();

expect(buttons.length).toBe(6);

const expectedTexts = ['Profile', '🙃Page 1', 'Security', '🙃Page 2', '🌐Visit Clerk', '🌐Visit User page'];
for (let i = 0; i < buttons.length; i++) {
await expect(buttons[i]).toHaveText(expectedTexts[i]);
}
});

test('children should be leaking when used with asProvider', 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_TRIGGER_PAGE);
const toggleButton = await u.page.waitForSelector('button[data-toggle-btn]');
await toggleButton.click();

await u.po.userButton.waitForPopover();
await u.po.userButton.triggerManageAccount();
await u.po.userProfile.waitForMounted();

await expect(u.page.locator('p[data-leaked-child]')).toBeVisible();
});
});

test.describe('User Button custom items', () => {
test('items at the specified order', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/clerk.browser.js", "maxSize": "65kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "64.8kB" },
{ "path": "./dist/clerk.headless.js", "maxSize": "43kB" },
{ "path": "./dist/ui-common*.js", "maxSize": "86KB" },
{ "path": "./dist/vendors*.js", "maxSize": "70KB" },
Expand Down
7 changes: 7 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,13 @@ export class Clerk implements ClerkInterface {
void this.#componentControls?.ensureMounted().then(controls => controls.unmountComponent({ node }));
};

public __experimental_prefetchOrganizationSwitcher = () => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls
?.ensureMounted({ preloadHint: 'OrganizationSwitcher' })
.then(controls => controls.prefetch('organizationSwitcher'));
};

public mountOrganizationList = (node: HTMLDivElement, props?: OrganizationListProps) => {
this.assertComponentsReady(this.#componentControls);
if (disabledOrganizationsFeature(this, this.environment)) {
Expand Down
10 changes: 10 additions & 0 deletions packages/clerk-js/src/ui/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
LazyModalRenderer,
LazyOneTapRenderer,
LazyProviders,
OrganizationSwitcherPrefetch,
} from './lazyModules/providers';
import type { AvailableComponentProps } from './types';

Expand Down Expand Up @@ -88,6 +89,7 @@ export type ComponentControls = {
notify?: boolean;
},
) => void;
prefetch: (component: 'organizationSwitcher') => void;
// Special case, as the impersonation fab mounts automatically
mountImpersonationFab: () => void;
};
Expand Down Expand Up @@ -116,6 +118,7 @@ interface ComponentsState {
userVerificationModal: null | __experimental_UserVerificationProps;
organizationProfileModal: null | OrganizationProfileProps;
createOrganizationModal: null | CreateOrganizationProps;
organizationSwitcherPrefetch: boolean;
nodes: Map<HTMLDivElement, HtmlNodeOptions>;
impersonationFab: boolean;
}
Expand Down Expand Up @@ -193,6 +196,7 @@ const Components = (props: ComponentsProps) => {
userVerificationModal: null,
organizationProfileModal: null,
createOrganizationModal: null,
organizationSwitcherPrefetch: false,
nodes: new Map(),
impersonationFab: false,
});
Expand Down Expand Up @@ -301,6 +305,10 @@ const Components = (props: ComponentsProps) => {
setState(s => ({ ...s, impersonationFab: true }));
};

componentsControls.prefetch = component => {
setState(s => ({ ...s, [`${component}Prefetch`]: true }));
};

props.onComponentsMounted();
}, []);

Expand Down Expand Up @@ -452,6 +460,8 @@ const Components = (props: ComponentsProps) => {
<ImpersonationFab />
</LazyImpersonationFabProvider>
)}

<Suspense>{state.organizationSwitcherPrefetch && <OrganizationSwitcherPrefetch />}</Suspense>
</LazyProviders>
</Suspense>
);
Expand Down
Loading
Loading