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}

+ + + ); +} + +function ToggleChildren(props: PropsWithChildren) { + const [isMounted, setMounted] = useState(false); + + return ( + <> + + {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