diff --git a/.changeset/clean-mugs-wave.md b/.changeset/clean-mugs-wave.md
new file mode 100644
index 00000000000..1b73dbeeb5c
--- /dev/null
+++ b/.changeset/clean-mugs-wave.md
@@ -0,0 +1,26 @@
+---
+"@clerk/clerk-react": minor
+---
+
+Introducing experimental `asProvider`, `asStandalone`, and ` ` for ` ` and ` ` components.
+- `asProvider` converts ` ` and ` ` to a provider that defers rendering until ` ` is mounted.
+- ` ` 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
+
+
+ This is my page available to all children
+
+
+
+```
+
+```tsx
+
+
+ This is my page available to all children
+
+
+
+```
diff --git a/.changeset/shaggy-kids-fail.md b/.changeset/shaggy-kids-fail.md
new file mode 100644
index 00000000000..31ee5c991d3
--- /dev/null
+++ b/.changeset/shaggy-kids-fail.md
@@ -0,0 +1,12 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/types': minor
+---
+
+Add experimental standalone mode for ` ` and ` `.
+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 ` `. 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 })`
diff --git a/integration/templates/react-vite/src/custom-user-button-trigger/index.tsx b/integration/templates/react-vite/src/custom-user-button-trigger/index.tsx
new file mode 100644
index 00000000000..bbcd41b52e9
--- /dev/null
+++ b/integration/templates/react-vite/src/custom-user-button-trigger/index.tsx
@@ -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 (
+ <>
+
Page 1
+ Counter: {counter}
+ setCounter(a => a + 1)}
+ >
+ Update
+
+ >
+ );
+}
+
+function ToggleChildren(props: PropsWithChildren) {
+ const [isMounted, setMounted] = useState(false);
+
+ return (
+ <>
+ setMounted(v => !v)}
+ >
+ Toggle
+
+ {isMounted ? props.children : null}
+ >
+ );
+}
+
+export default function Page() {
+ return (
+
+
+ π}
+ url='page-1'
+ >
+
+
+
+ π}
+ url='page-2'
+ >
+ Page 2
+
+ This is leaking
+ π}
+ />
+
+ π}
+ open={'page-1'}
+ />
+
+
+ π}
+ />
+
+ π}
+ />
+
+ π}
+ onClick={() => alert('custom-alert')}
+ />
+
+ π}
+ />
+
+
+
+
+
+ );
+}
diff --git a/integration/templates/react-vite/src/custom-user-button/index.tsx b/integration/templates/react-vite/src/custom-user-button/index.tsx
index e6c800bcaf3..e283cddd76b 100644
--- a/integration/templates/react-vite/src/custom-user-button/index.tsx
+++ b/integration/templates/react-vite/src/custom-user-button/index.tsx
@@ -38,7 +38,7 @@ export default function Page() {
>
Page 2
- π
+ This is leaking
Page 2
- π
+ This is leaking
{
const navigate = useNavigate();
@@ -64,6 +65,10 @@ const router = createBrowserRouter([
path: '/custom-user-button',
element: ,
},
+ {
+ path: '/custom-user-button-trigger',
+ element: ,
+ },
],
},
]);
diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts
index f3dd4a51937..f7621bb9ffb 100644
--- a/integration/tests/custom-pages.test.ts
+++ b/integration/tests/custom-pages.test.ts
@@ -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',
@@ -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();
@@ -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 });
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index 6e8b142720e..af372ae8e98 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -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" },
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 98ebad1d080..dc4b101985d 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -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)) {
diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx
index fa419d4c9f1..5fd0168ee3d 100644
--- a/packages/clerk-js/src/ui/Components.tsx
+++ b/packages/clerk-js/src/ui/Components.tsx
@@ -37,6 +37,7 @@ import {
LazyModalRenderer,
LazyOneTapRenderer,
LazyProviders,
+ OrganizationSwitcherPrefetch,
} from './lazyModules/providers';
import type { AvailableComponentProps } from './types';
@@ -88,6 +89,7 @@ export type ComponentControls = {
notify?: boolean;
},
) => void;
+ prefetch: (component: 'organizationSwitcher') => void;
// Special case, as the impersonation fab mounts automatically
mountImpersonationFab: () => void;
};
@@ -116,6 +118,7 @@ interface ComponentsState {
userVerificationModal: null | __experimental_UserVerificationProps;
organizationProfileModal: null | OrganizationProfileProps;
createOrganizationModal: null | CreateOrganizationProps;
+ organizationSwitcherPrefetch: boolean;
nodes: Map;
impersonationFab: boolean;
}
@@ -193,6 +196,7 @@ const Components = (props: ComponentsProps) => {
userVerificationModal: null,
organizationProfileModal: null,
createOrganizationModal: null,
+ organizationSwitcherPrefetch: false,
nodes: new Map(),
impersonationFab: false,
});
@@ -301,6 +305,10 @@ const Components = (props: ComponentsProps) => {
setState(s => ({ ...s, impersonationFab: true }));
};
+ componentsControls.prefetch = component => {
+ setState(s => ({ ...s, [`${component}Prefetch`]: true }));
+ };
+
props.onComponentsMounted();
}, []);
@@ -452,6 +460,8 @@ const Components = (props: ComponentsProps) => {
)}
+
+ {state.organizationSwitcherPrefetch && }
);
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx
index 234ddb9b2d6..e0fdd0be568 100644
--- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx
@@ -1,4 +1,5 @@
-import { useId } from 'react';
+import type { ReactElement } from 'react';
+import { cloneElement, useId } from 'react';
import { AcceptedInvitationsProvider, useOrganizationSwitcherContext, withCoreUserGuard } from '../../contexts';
import { Flow } from '../../customizables';
@@ -7,8 +8,9 @@ import { usePopover } from '../../hooks';
import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover';
import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger';
-const _OrganizationSwitcher = withFloatingTree(() => {
+const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => {
const { defaultOpen } = useOrganizationSwitcherContext();
+
const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({
defaultOpen,
placement: 'bottom-start',
@@ -17,34 +19,50 @@ const _OrganizationSwitcher = withFloatingTree(() => {
const switcherButtonMenuId = useId();
+ return (
+ <>
+
+
+ {cloneElement(children, {
+ id: switcherButtonMenuId,
+ close: toggle,
+ ref: floating,
+ style: styles,
+ })}
+
+ >
+ );
+});
+
+const _OrganizationSwitcher = () => {
+ const { __experimental_asStandalone } = useOrganizationSwitcherContext();
+
return (
-
-
-
-
+ {__experimental_asStandalone ? (
+
+ ) : (
+
+
+
+ )}
);
-});
+};
export const OrganizationSwitcher = withCoreUserGuard(withCardStateProvider(_OrganizationSwitcher));
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
index 523a35552d4..1fbb9436e91 100644
--- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
@@ -20,12 +20,16 @@ import { useRouter } from '../../router';
import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem';
import { OrganizationActionList } from './OtherOrganizationActions';
-type OrganizationSwitcherPopoverProps = { close: () => void } & PropsOfComponent;
+type OrganizationSwitcherPopoverProps = {
+ close?: (open: boolean | ((prevState: boolean) => boolean)) => void;
+} & PropsOfComponent;
export const OrganizationSwitcherPopover = React.forwardRef(
(props, ref) => {
- const { close, ...rest } = props;
+ const { close: unsafeClose, ...rest } = props;
+ const close = () => unsafeClose?.(false);
const card = useCardState();
+ const { __experimental_asStandalone } = useOrganizationSwitcherContext();
const { openOrganizationProfile, openCreateOrganization } = useClerk();
const { organization: currentOrg } = useOrganization();
const { isLoaded, setActive } = useOrganizationList();
@@ -191,6 +195,7 @@ export const OrganizationSwitcherPopover = React.forwardRef
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
index 60a9fb997ba..bac49c52af3 100644
--- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
@@ -1,5 +1,6 @@
import type { MembershipRole } from '@clerk/types';
import { describe } from '@jest/globals';
+import { waitFor } from '@testing-library/react';
import { act, render } from '../../../../testUtils';
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
@@ -119,10 +120,25 @@ describe('OrganizationSwitcher', () => {
props.setProps({ hidePersonal: true });
const { getByText, getByRole, userEvent } = render( , { wrapper });
- await userEvent.click(getByRole('button'));
+ await userEvent.click(getByRole('button', { name: 'Open organization switcher' }));
expect(getByText('Create organization')).toBeInTheDocument();
});
+ it('renders organization switcher popover as standalone', async () => {
+ const { wrapper, props } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'], create_organization_enabled: true });
+ });
+ props.setProps({
+ __experimental_asStandalone: true,
+ });
+ const { getByText, queryByRole } = render( , { wrapper });
+ await waitFor(() => {
+ expect(queryByRole('button', { name: 'Open organization switcher' })).toBeNull();
+ expect(getByText('Personal account')).toBeInTheDocument();
+ });
+ });
+
it('lists all organizations the user belongs to', async () => {
const { wrapper, props, fixtures } = await createFixtures(f => {
f.withOrganizations();
diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx
index 941d8fa1da6..6e256949469 100644
--- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx
+++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx
@@ -1,4 +1,4 @@
-import { useId } from 'react';
+import { cloneElement, type ReactElement, useId } from 'react';
import { useUserButtonContext, withCoreUserGuard } from '../../contexts';
import { Flow } from '../../customizables';
@@ -7,8 +7,9 @@ import { usePopover } from '../../hooks';
import { UserButtonPopover } from './UserButtonPopover';
import { UserButtonTrigger } from './UserButtonTrigger';
-const _UserButton = withFloatingTree(() => {
+const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => {
const { defaultOpen } = useUserButtonContext();
+
const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({
defaultOpen,
placement: 'bottom-end',
@@ -18,10 +19,7 @@ const _UserButton = withFloatingTree(() => {
const userButtonMenuId = useId();
return (
-
+ <>
{
context={context}
isOpen={isOpen}
>
-
+ {cloneElement(children, {
+ id: userButtonMenuId,
+ close: toggle,
+ ref: floating,
+ style: styles,
+ })}
-
+ >
);
});
+const _UserButton = () => {
+ const { __experimental_asStandalone } = useUserButtonContext();
+
+ return (
+
+ {__experimental_asStandalone ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+};
+
export const UserButton = withCoreUserGuard(withCardStateProvider(_UserButton));
diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx
index ce5ce45d6b0..114a6ed8b8e 100644
--- a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx
+++ b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx
@@ -9,11 +9,13 @@ import type { PropsOfComponent } from '../../styledSystem';
import { MultiSessionActions, SignOutAllActions, SingleSessionActions } from './SessionActions';
import { useMultisessionActions } from './useMultisessionActions';
-type UserButtonPopoverProps = { close: () => void } & PropsOfComponent;
+type UserButtonPopoverProps = { close?: () => void } & PropsOfComponent;
export const UserButtonPopover = React.forwardRef((props, ref) => {
- const { close, ...rest } = props;
+ const { close: unsafeClose, ...rest } = props;
+ const close = () => unsafeClose?.();
const { session } = useSession() as { session: ActiveSessionResource };
+ const { __experimental_asStandalone } = useUserButtonContext();
const { authConfig } = useEnvironment();
const { user } = useUser();
const {
@@ -33,6 +35,7 @@ export const UserButtonPopover = React.forwardRef
diff --git a/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx b/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx
index 42692294ab6..f109431182c 100644
--- a/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx
+++ b/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx
@@ -21,6 +21,18 @@ describe('UserButton', () => {
expect(queryByRole('button')).not.toBeNull();
});
+ it('renders popover as standalone when there is a user', async () => {
+ const { wrapper, props } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ props.setProps({
+ __experimental_asStandalone: true,
+ });
+ const { getByText, queryByRole } = render( , { wrapper });
+ expect(queryByRole('button', { name: 'Open user button' })).toBeNull();
+ getByText('Manage account');
+ });
+
it('opens the user button popover when clicked', async () => {
const { wrapper } = await createFixtures(f => {
f.withUser({
diff --git a/packages/clerk-js/src/ui/components/prefetch-organization-list.tsx b/packages/clerk-js/src/ui/components/prefetch-organization-list.tsx
new file mode 100644
index 00000000000..e7ca34f17c0
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/prefetch-organization-list.tsx
@@ -0,0 +1,8 @@
+import { useOrganizationList } from '@clerk/shared/react';
+
+import { organizationListParams } from './OrganizationSwitcher/utils';
+
+export function OrganizationSwitcherPrefetch() {
+ useOrganizationList(organizationListParams);
+ return null;
+}
diff --git a/packages/clerk-js/src/ui/elements/PopoverCard.tsx b/packages/clerk-js/src/ui/elements/PopoverCard.tsx
index d3446c130e8..9bfe49ea425 100644
--- a/packages/clerk-js/src/ui/elements/PopoverCard.tsx
+++ b/packages/clerk-js/src/ui/elements/PopoverCard.tsx
@@ -3,27 +3,41 @@ import React from 'react';
import { useEnvironment } from '../contexts';
import { Col, descriptors, Flex, Flow, useAppearance } from '../customizables';
import type { ElementDescriptor } from '../customizables/elementDescriptors';
-import type { PropsOfComponent } from '../styledSystem';
+import type { PropsOfComponent, ThemableCssProp } from '../styledSystem';
import { animations, common } from '../styledSystem';
import { colors } from '../utils';
import { Card } from '.';
-const PopoverCardRoot = React.forwardRef>((props, ref) => {
- const { elementDescriptor, ...rest } = props;
+const PopoverCardRoot = React.forwardRef<
+ HTMLDivElement,
+ PropsOfComponent & {
+ shouldEntryAnimate?: boolean;
+ }
+>((props, ref) => {
+ const { elementDescriptor, shouldEntryAnimate = true, ...rest } = props;
+
+ const withAnimation: ThemableCssProp = t => ({
+ animation: shouldEntryAnimate
+ ? `${animations.dropdownSlideInScaleAndFade} ${t.transitionDuration.$fast}`
+ : undefined,
+ });
+
return (
({
- width: t.sizes.$94,
- maxWidth: `calc(100vw - ${t.sizes.$8})`,
- zIndex: t.zIndices.$modal,
- borderRadius: t.radii.$xl,
- animation: `${animations.dropdownSlideInScaleAndFade} ${t.transitionDuration.$fast}`,
- outline: 'none',
- })}
+ sx={[
+ t => ({
+ width: t.sizes.$94,
+ maxWidth: `calc(100vw - ${t.sizes.$8})`,
+ zIndex: t.zIndices.$modal,
+ borderRadius: t.radii.$xl,
+ outline: 'none',
+ }),
+ withAnimation,
+ ]}
>
{props.children}
diff --git a/packages/clerk-js/src/ui/lazyModules/providers.tsx b/packages/clerk-js/src/ui/lazyModules/providers.tsx
index 70c3dcf56e8..1439e17c8a2 100644
--- a/packages/clerk-js/src/ui/lazyModules/providers.tsx
+++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx
@@ -16,6 +16,11 @@ const Portal = lazy(() => import('./../portal').then(m => ({ default: m.Portal }
const VirtualBodyRootPortal = lazy(() => import('./../portal').then(m => ({ default: m.VirtualBodyRootPortal })));
const FlowMetadataProvider = lazy(() => import('./../elements').then(m => ({ default: m.FlowMetadataProvider })));
const Modal = lazy(() => import('./../elements').then(m => ({ default: m.Modal })));
+const OrganizationSwitcherPrefetch = lazy(() =>
+ import(/* webpackChunkName: "prefetchorganizationlist" */ '../components/prefetch-organization-list').then(m => ({
+ default: m.OrganizationSwitcherPrefetch,
+ })),
+);
type LazyProvidersProps = React.PropsWithChildren<{ clerk: any; environment: any; options: any; children: any }>;
@@ -155,3 +160,5 @@ export const LazyOneTapRenderer = (props: LazyOneTapRendererProps) => {
);
};
+
+export { OrganizationSwitcherPrefetch };
diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx
index 919ec0d1736..d4c589ced15 100644
--- a/packages/react/src/components/uiComponents.tsx
+++ b/packages/react/src/components/uiComponents.tsx
@@ -13,7 +13,7 @@ import type {
Without,
} from '@clerk/types';
import type { PropsWithChildren } from 'react';
-import React, { createElement } from 'react';
+import React, { createContext, createElement, useContext } from 'react';
import {
organizationProfileLinkRenderedError,
@@ -25,6 +25,7 @@ import {
userProfilePageRenderedError,
} from '../errors/messages';
import type {
+ CustomPortalsRendererProps,
MountProps,
OpenProps,
OrganizationProfileLinkProps,
@@ -35,7 +36,12 @@ import type {
UserProfilePageProps,
WithClerkProp,
} from '../types';
-import { useOrganizationProfileCustomPages, useUserButtonCustomMenuItems, useUserProfileCustomPages } from '../utils';
+import {
+ useOrganizationProfileCustomPages,
+ useSanitizedChildren,
+ useUserButtonCustomMenuItems,
+ useUserProfileCustomPages,
+} from '../utils';
import { withClerk } from './withClerk';
type UserProfileExportType = typeof _UserProfile & {
@@ -49,10 +55,26 @@ type UserButtonExportType = typeof _UserButton & {
MenuItems: typeof MenuItems;
Action: typeof MenuAction;
Link: typeof MenuLink;
+ /**
+ * The ` ` component can be used in conjunction with `asProvider` in order to control rendering
+ * of the ` ` without affecting its configuration or any custom pages
+ * that could be mounted
+ * @experimental This API is experimental and may change at any moment.
+ */
+ __experimental_Outlet: typeof UserButtonOutlet;
};
-type UserButtonPropsWithoutCustomPages = Without & {
+type UserButtonPropsWithoutCustomPages = Without<
+ UserButtonProps,
+ 'userProfileProps' | '__experimental_asStandalone'
+> & {
userProfileProps?: Pick;
+ /**
+ * Adding `asProvider` will defer rendering until the ` ` component is mounted.
+ * @experimental This API is experimental and may change at any moment.
+ * @default undefined
+ */
+ __experimental_asProvider?: boolean;
};
type OrganizationProfileExportType = typeof _OrganizationProfile & {
@@ -63,10 +85,34 @@ type OrganizationProfileExportType = typeof _OrganizationProfile & {
type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & {
OrganizationProfilePage: typeof OrganizationProfilePage;
OrganizationProfileLink: typeof OrganizationProfileLink;
+ /**
+ * The ` ` component can be used in conjunction with `asProvider` in order to control rendering
+ * of the ` ` without affecting its configuration or any custom pages
+ * that could be mounted
+ * @experimental This API is experimental and may change at any moment.
+ */
+ __experimental_Outlet: typeof OrganizationSwitcherOutlet;
};
-type OrganizationSwitcherPropsWithoutCustomPages = Without & {
+type OrganizationSwitcherPropsWithoutCustomPages = Without<
+ OrganizationSwitcherProps,
+ 'organizationProfileProps' | '__experimental_asStandalone'
+> & {
organizationProfileProps?: Pick;
+ /**
+ * Adding `asProvider` will defer rendering until the ` ` component is mounted.
+ * @experimental This API is experimental and may change at any moment.
+ * @default undefined
+ */
+ __experimental_asProvider?: boolean;
+};
+
+const isMountProps = (props: any): props is MountProps => {
+ return 'mount' in props;
+};
+
+const isOpenProps = (props: any): props is OpenProps => {
+ return 'open' in props;
};
// README: should be a class pure component in order for mount and unmount
@@ -98,15 +144,9 @@ type OrganizationSwitcherPropsWithoutCustomPages = Without {
- return 'mount' in props;
-};
-
-const isOpenProps = (props: any): props is OpenProps => {
- return 'open' in props;
-};
-
-class Portal extends React.PureComponent {
+class Portal extends React.PureComponent<
+ PropsWithChildren<(MountProps | OpenProps) & { hideRootHtmlElement?: boolean }>
+> {
private portalRef = React.createRef();
componentDidUpdate(_prevProps: Readonly) {
@@ -122,8 +162,11 @@ class Portal extends React.PureComponent {
// instead, we simply use the length of customPages to determine if it changed or not
const customPagesChanged = prevProps.customPages?.length !== newProps.customPages?.length;
const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length;
+
if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) {
- this.props.updateProps({ node: this.portalRef.current, props: this.props.props });
+ if (this.portalRef.current) {
+ this.props.updateProps({ node: this.portalRef.current, props: this.props.props });
+ }
}
}
@@ -151,18 +194,25 @@ class Portal extends React.PureComponent {
}
render() {
+ const { hideRootHtmlElement = false } = this.props;
return (
<>
-
- {isMountProps(this.props) &&
- this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))}
- {isMountProps(this.props) &&
- this.props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))}
+ {!hideRootHtmlElement &&
}
+ {this.props.children}
>
);
}
}
+const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => {
+ return (
+ <>
+ {props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))}
+ {props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))}
+ >
+ );
+};
+
export const SignIn = withClerk(({ clerk, ...props }: WithClerkProp) => {
return (
+ >
+
+
);
},
'UserProfile',
@@ -216,21 +267,43 @@ export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, {
Link: UserProfileLink,
});
+const UserButtonContext = createContext({
+ mount: () => {},
+ unmount: () => {},
+ updateProps: () => {},
+});
+
const _UserButton = withClerk(
({ clerk, ...props }: WithClerkProp>) => {
- const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children);
+ const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children, {
+ allowForAnyChildren: !!props.__experimental_asProvider,
+ });
const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages });
const { customMenuItems, customMenuItemsPortals } = useUserButtonCustomMenuItems(props.children);
+ const sanitizedChildren = useSanitizedChildren(props.children);
+
+ const passableProps = {
+ mount: clerk.mountUserButton,
+ unmount: clerk.unmountUserButton,
+ updateProps: (clerk as any).__unstable__updateProps,
+ props: { ...props, userProfileProps, customMenuItems },
+ };
+ const portalProps = {
+ customPagesPortals: customPagesPortals,
+ customMenuItemsPortals: customMenuItemsPortals,
+ };
return (
-
+
+
+ {/*This mimics the previous behaviour before asProvider existed*/}
+ {props.__experimental_asProvider ? sanitizedChildren : null}
+
+
+
);
},
'UserButton',
@@ -251,12 +324,27 @@ export function MenuLink({ children }: PropsWithChildren) {
return <>{children}>;
}
+export function UserButtonOutlet(outletProps: Without) {
+ const providerProps = useContext(UserButtonContext);
+
+ const portalProps = {
+ ...providerProps,
+ props: {
+ ...providerProps.props,
+ ...outletProps,
+ },
+ } satisfies MountProps;
+
+ return ;
+}
+
export const UserButton: UserButtonExportType = Object.assign(_UserButton, {
UserProfilePage,
UserProfileLink,
MenuItems,
Action: MenuAction,
Link: MenuLink,
+ __experimental_Outlet: UserButtonOutlet,
});
export function OrganizationProfilePage({ children }: PropsWithChildren) {
@@ -278,8 +366,9 @@ const _OrganizationProfile = withClerk(
unmount={clerk.unmountOrganizationProfile}
updateProps={(clerk as any).__unstable__updateProps}
props={{ ...props, customPages }}
- customPagesPortals={customPagesPortals}
- />
+ >
+
+
);
},
'OrganizationProfile',
@@ -301,27 +390,68 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp<
);
}, 'CreateOrganization');
+const OrganizationSwitcherContext = createContext({
+ mount: () => {},
+ unmount: () => {},
+ updateProps: () => {},
+});
+
const _OrganizationSwitcher = withClerk(
({ clerk, ...props }: WithClerkProp>) => {
- const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children);
+ const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children, {
+ allowForAnyChildren: !!props.__experimental_asProvider,
+ });
const organizationProfileProps = Object.assign(props.organizationProfileProps || {}, { customPages });
+ const sanitizedChildren = useSanitizedChildren(props.children);
+
+ const passableProps = {
+ mount: clerk.mountOrganizationSwitcher,
+ unmount: clerk.unmountOrganizationSwitcher,
+ updateProps: (clerk as any).__unstable__updateProps,
+ props: { ...props, organizationProfileProps },
+ };
+
+ /**
+ * Prefetch organization list
+ */
+ clerk.__experimental_prefetchOrganizationSwitcher();
return (
-
+
+
+ {/*This mimics the previous behaviour before asProvider existed*/}
+ {props.__experimental_asProvider ? sanitizedChildren : null}
+
+
+
);
},
'OrganizationSwitcher',
);
+export function OrganizationSwitcherOutlet(
+ outletProps: Without,
+) {
+ const providerProps = useContext(OrganizationSwitcherContext);
+
+ const portalProps = {
+ ...providerProps,
+ props: {
+ ...providerProps.props,
+ ...outletProps,
+ },
+ } satisfies MountProps;
+
+ return ;
+}
+
export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, {
OrganizationProfilePage,
OrganizationProfileLink,
+ __experimental_Outlet: OrganizationSwitcherOutlet,
});
export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => {
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index 1623b81cadb..3a94a521f9a 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -841,6 +841,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}
};
+ __experimental_prefetchOrganizationSwitcher = (): void => {
+ const callback = () => this.clerkjs?.__experimental_prefetchOrganizationSwitcher();
+ if (this.clerkjs && this.#loaded) {
+ void callback();
+ } else {
+ this.premountMethodCalls.set('__experimental_prefetchOrganizationSwitcher', callback);
+ }
+ };
+
mountOrganizationList = (node: HTMLDivElement, props: OrganizationListProps): void => {
if (this.clerkjs && this.#loaded) {
this.clerkjs.mountOrganizationList(node, props);
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index 2ad19fa125c..ec0f8dbd235 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -67,14 +67,17 @@ export interface HeadlessBrowserClerkConstructor {
export type WithClerkProp = T & { clerk: LoadedClerk };
+export interface CustomPortalsRendererProps {
+ customPagesPortals?: any[];
+ customMenuItemsPortals?: any[];
+}
+
// Clerk object
export interface MountProps {
mount: (node: HTMLDivElement, props: any) => void;
unmount: (node: HTMLDivElement) => void;
updateProps: (props: any) => void;
props?: any;
- customPagesPortals?: any[];
- customMenuItemsPortals?: any[];
}
export interface OpenProps {
diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx
index 4274fe34fca..c3367d1cc7b 100644
--- a/packages/react/src/utils/useCustomPages.tsx
+++ b/packages/react/src/utils/useCustomPages.tsx
@@ -16,27 +16,39 @@ import { isThatComponent } from './componentValidation';
import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal';
import { useCustomElementPortal } from './useCustomElementPortal';
-export const useUserProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => {
+export const useUserProfileCustomPages = (
+ children: React.ReactNode | React.ReactNode[],
+ options?: UseCustomPagesOptions,
+) => {
const reorderItemsLabels = ['account', 'security'];
- return useCustomPages({
- children,
- reorderItemsLabels,
- LinkComponent: UserProfileLink,
- PageComponent: UserProfilePage,
- MenuItemsComponent: MenuItems,
- componentName: 'UserProfile',
- });
+ return useCustomPages(
+ {
+ children,
+ reorderItemsLabels,
+ LinkComponent: UserProfileLink,
+ PageComponent: UserProfilePage,
+ MenuItemsComponent: MenuItems,
+ componentName: 'UserProfile',
+ },
+ options,
+ );
};
-export const useOrganizationProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => {
+export const useOrganizationProfileCustomPages = (
+ children: React.ReactNode | React.ReactNode[],
+ options?: UseCustomPagesOptions,
+) => {
const reorderItemsLabels = ['general', 'members'];
- return useCustomPages({
- children,
- reorderItemsLabels,
- LinkComponent: OrganizationProfileLink,
- PageComponent: OrganizationProfilePage,
- componentName: 'OrganizationProfile',
- });
+ return useCustomPages(
+ {
+ children,
+ reorderItemsLabels,
+ LinkComponent: OrganizationProfileLink,
+ PageComponent: OrganizationProfilePage,
+ componentName: 'OrganizationProfile',
+ },
+ options,
+ );
};
type UseCustomPagesParams = {
@@ -48,16 +60,49 @@ type UseCustomPagesParams = {
componentName: string;
};
+type UseCustomPagesOptions = {
+ allowForAnyChildren: boolean;
+};
+
type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode };
-const useCustomPages = ({
- children,
- LinkComponent,
- PageComponent,
- MenuItemsComponent,
- reorderItemsLabels,
- componentName,
-}: UseCustomPagesParams) => {
+/**
+ * Exclude any children that is used for identifying Custom Pages or Custom Items.
+ * Passing:
+ * ```tsx
+ *
+ *
+ *
+ *
+ * ```
+ * Gives back
+ * ```tsx
+ *
+ * ````
+ */
+export const useSanitizedChildren = (children: React.ReactNode) => {
+ const sanitizedChildren: React.ReactNode[] = [];
+
+ const excludedComponents: any[] = [
+ OrganizationProfileLink,
+ OrganizationProfilePage,
+ MenuItems,
+ UserProfilePage,
+ UserProfileLink,
+ ];
+
+ React.Children.forEach(children, child => {
+ if (!excludedComponents.some(component => isThatComponent(child, component))) {
+ sanitizedChildren.push(child);
+ }
+ });
+
+ return sanitizedChildren;
+};
+
+const useCustomPages = (params: UseCustomPagesParams, options?: UseCustomPagesOptions) => {
+ const { children, LinkComponent, PageComponent, MenuItemsComponent, reorderItemsLabels, componentName } = params;
+ const { allowForAnyChildren = false } = options || {};
const validChildren: CustomPageWithIdType[] = [];
React.Children.forEach(children, child => {
@@ -66,7 +111,7 @@ const useCustomPages = ({
!isThatComponent(child, LinkComponent) &&
!isThatComponent(child, MenuItemsComponent)
) {
- if (child) {
+ if (child && !allowForAnyChildren) {
logErrorInDevMode(customPagesIgnoredComponent(componentName));
}
return;
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index 3f1b8abd9e2..7f7e11cd139 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -329,6 +329,14 @@ export interface Clerk {
*/
unmountOrganizationSwitcher: (targetNode: HTMLDivElement) => void;
+ /**
+ * Prefetches the data displayed by an organization switcher.
+ * It can be used when `mountOrganizationSwitcher({ asStandalone: true})`, to avoid unwanted loading states.
+ * @experimantal This API is still under active development and may change at any moment.
+ * @param props Optional user verification configuration parameters.
+ */
+ __experimental_prefetchOrganizationSwitcher: () => void;
+
/**
* Mount an organization list component at the target element.
* @param targetNode Target to mount the OrganizationList component.
@@ -1035,6 +1043,15 @@ export type UserButtonProps = UserButtonProfileMode & {
* Controls the default state of the UserButton
*/
defaultOpen?: boolean;
+
+ /**
+ * If true the ` ` will only render the popover.
+ * Enables developers to implement a custom dialog.
+ * @experimental This API is experimental and may change at any moment.
+ * @default undefined
+ */
+ __experimental_asStandalone?: boolean;
+
/**
* Full URL or path to navigate after sign out is complete
* @deprecated Configure `afterSignOutUrl` as a global configuration, either in or in await Clerk.load()
@@ -1095,6 +1112,15 @@ export type OrganizationSwitcherProps = CreateOrganizationMode &
* Controls the default state of the OrganizationSwitcher
*/
defaultOpen?: boolean;
+
+ /**
+ * If true, ` ` will only render the popover.
+ * Enables developers to implement a custom dialog.
+ * @experimental This API is experimental and may change at any moment.
+ * @default undefined
+ */
+ __experimental_asStandalone?: boolean;
+
/**
* By default, users can switch between organization and their personal account.
* This option controls whether OrganizationSwitcher will include the user's personal account
diff --git a/playground/app-router/src/pages/user/[[...index]].tsx b/playground/app-router/src/pages/user/[[...index]].tsx
index 9769bbf59a4..965be25b361 100644
--- a/playground/app-router/src/pages/user/[[...index]].tsx
+++ b/playground/app-router/src/pages/user/[[...index]].tsx
@@ -22,4 +22,4 @@ const UserProfilePage: NextPage = (props: any) => {
);
};
-export default UserProfilePage;
+export default UserProfilePage;
\ No newline at end of file