From 3b240b7eb155ed4bdaefdb4e4b819a01a9573ea4 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 20 May 2024 15:11:54 +0300 Subject: [PATCH 01/36] feat(clerk-js): Experimental controlled UserButton --- .../src/ui/components/UserButton/UserButton.tsx | 4 +++- packages/clerk-js/src/ui/elements/Popover.tsx | 2 ++ packages/clerk-js/src/ui/hooks/usePopover.ts | 15 +++++++++++++-- packages/types/src/clerk.ts | 4 ++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index 941d8fa1da6..90ed3bc33d1 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -8,9 +8,11 @@ import { UserButtonPopover } from './UserButtonPopover'; import { UserButtonTrigger } from './UserButtonTrigger'; const _UserButton = withFloatingTree(() => { - const { defaultOpen } = useUserButtonContext(); + const { defaultOpen, __experimental_open, __experimental_onOpenChanged } = useUserButtonContext(); const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, + open: __experimental_open, + onOpenChanged: __experimental_onOpenChanged, placement: 'bottom-end', offset: 8, }); diff --git a/packages/clerk-js/src/ui/elements/Popover.tsx b/packages/clerk-js/src/ui/elements/Popover.tsx index d3f5ab43bf5..f79fc27732c 100644 --- a/packages/clerk-js/src/ui/elements/Popover.tsx +++ b/packages/clerk-js/src/ui/elements/Popover.tsx @@ -24,6 +24,7 @@ export const Popover = (props: PopoverProps) => { context={context} initialFocus={initialFocus} order={order} + closeOnFocusOut={false} > <>{children} @@ -40,6 +41,7 @@ export const Popover = (props: PopoverProps) => { context={context} initialFocus={initialFocus} order={order} + closeOnFocusOut={false} > <>{children} diff --git a/packages/clerk-js/src/ui/hooks/usePopover.ts b/packages/clerk-js/src/ui/hooks/usePopover.ts index 21f65a2b7d9..1387e763f63 100644 --- a/packages/clerk-js/src/ui/hooks/usePopover.ts +++ b/packages/clerk-js/src/ui/hooks/usePopover.ts @@ -4,6 +4,8 @@ import React, { useEffect } from 'react'; type UsePopoverProps = { defaultOpen?: boolean; + open?: boolean; + onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; placement?: UseFloatingOptions['placement']; offset?: Parameters[0]; shoudFlip?: boolean; @@ -22,9 +24,17 @@ type UsePopoverProps = { export type UsePopoverReturn = ReturnType; export const usePopover = (props: UsePopoverProps = {}) => { - const { bubbles = false, shoudFlip = true, outsidePress, adjustToReferenceWidth = false, referenceElement } = props; - const [isOpen, setIsOpen] = React.useState(props.defaultOpen || false); + const { bubbles = true, shoudFlip = true, outsidePress, adjustToReferenceWidth = false, referenceElement } = props; + const [isOpen_internal, setIsOpen_internal] = React.useState(props.defaultOpen || false); + + const isOpen = typeof props.open === 'undefined' ? isOpen_internal : props.open; + const setIsOpen = typeof props.onOpenChanged === 'undefined' ? setIsOpen_internal : props.onOpenChanged; const nodeId = useFloatingNodeId(); + + if (typeof props.defaultOpen !== 'undefined' && typeof props.open !== 'undefined') { + console.warn('Both defaultOpen and open are set. `defaultOpen` will be ignored'); + } + const { update, refs, strategy, x, y, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, @@ -56,6 +66,7 @@ export const usePopover = (props: UsePopoverProps = {}) => { useDismiss(context, { bubbles, outsidePress, + //outsidePress: typeof props.open === 'undefined' ? outsidePress : false, }); useEffect(() => { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 2b03f2b6735..a90719e91da 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -909,6 +909,10 @@ export type UserButtonProps = UserButtonProfileMode & { * Controls the default state of the UserButton */ defaultOpen?: boolean; + + __experimental_open?: boolean; + + __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; /** * 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() From 14f15ee60fd1337c32d59acb65c8a968d62f6aff Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 20 May 2024 21:53:46 +0300 Subject: [PATCH 02/36] chore(clerk-js): Add changeset --- .changeset/shaggy-kids-fail.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/shaggy-kids-fail.md diff --git a/.changeset/shaggy-kids-fail.md b/.changeset/shaggy-kids-fail.md new file mode 100644 index 00000000000..cd12a0ebc89 --- /dev/null +++ b/.changeset/shaggy-kids-fail.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Add experimental support for a controlled UserButton. From 92bbb9188f69d4ccf86b860be82ca69406a37319 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 12:07:26 +0300 Subject: [PATCH 03/36] feat(clerk-js): Experimental controlled OrganizationSwitcher --- .../OrganizationSwitcher/OrganizationSwitcher.tsx | 6 +++++- packages/types/src/clerk.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index b066480dec2..5199113a71e 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -1,6 +1,6 @@ import { useId } from 'react'; -import { AcceptedInvitationsProvider, withCoreUserGuard } from '../../contexts'; +import { AcceptedInvitationsProvider, useOrganizationSwitcherContext, withCoreUserGuard } from '../../contexts'; import { Flow } from '../../customizables'; import { Popover, withCardStateProvider, withFloatingTree } from '../../elements'; import { usePopover } from '../../hooks'; @@ -8,7 +8,11 @@ import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover'; import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger'; const _OrganizationSwitcher = withFloatingTree(() => { + const { defaultOpen, __experimental_open, __experimental_onOpenChanged } = useOrganizationSwitcherContext(); const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ + defaultOpen, + open: __experimental_open, + onOpenChanged: __experimental_onOpenChanged, placement: 'bottom-start', offset: 8, }); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index a90719e91da..1406459bbcb 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -973,6 +973,10 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & * Controls the default state of the OrganizationSwitcher */ defaultOpen?: boolean; + + __experimental_open?: boolean; + + __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; /** * By default, users can switch between organization and their personal account. * This option controls whether OrganizationSwitcher will include the user's personal account From 0eb2bdad76093a29f001818aa81cb2548e3e7300 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 24 May 2024 19:17:17 +0300 Subject: [PATCH 04/36] feat(clerk-js): Standalone OrganizationSwitcher --- .../OrganizationSwitcher.tsx | 64 ++++++++++++------- .../OrganizationSwitcherPopover.tsx | 5 +- packages/types/src/clerk.ts | 3 + 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index 5199113a71e..bc39ce2e065 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 { defaultOpen, __experimental_open, __experimental_onOpenChanged } = useOrganizationSwitcherContext(); +const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => { + const { __experimental_onOpenChanged, __experimental_open, defaultOpen } = useOrganizationSwitcherContext(); + const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, open: __experimental_open, @@ -19,34 +21,50 @@ const _OrganizationSwitcher = withFloatingTree(() => { const switcherButtonMenuId = useId(); + return ( + <> + + + {cloneElement(children, { + id: switcherButtonMenuId, + close: toggle, + ref: floating, + style: styles, + })} + + + ); +}); + +const _OrganizationSwitcher = () => { + const { __experimental_hideTrigger, __experimental_onActionEnded } = useOrganizationSwitcherContext(); + return ( - - - - + {__experimental_hideTrigger ? ( + + ) : ( + + + + )} ); -}); +}; 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 7edecbf80ee..5d65a28fcd3 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -21,11 +21,12 @@ import { useRouter } from '../../router'; import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem'; import { OrganizationActionList } from './OtherOrganizationActions'; -type OrganizationSwitcherPopoverProps = { close: () => void } & PropsOfComponent; +type OrganizationSwitcherPopoverProps = { close?: () => void } & PropsOfComponent; export const OrganizationSwitcherPopover = React.forwardRef( (props, ref) => { - const { close, ...rest } = props; + const { close: undefinedClose, ...rest } = props; + const close = () => undefinedClose?.(); const card = useCardState(); const { openOrganizationProfile, openCreateOrganization } = useClerk(); const { organization: currentOrg } = useOrganization(); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 1406459bbcb..7e4fbb7da28 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -977,6 +977,9 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & __experimental_open?: boolean; __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + + __experimental_hideTrigger?: boolean; + __experimental_onActionEnded?: () => void; /** * By default, users can switch between organization and their personal account. * This option controls whether OrganizationSwitcher will include the user's personal account From 74582c647a0989e29639799c00bd9333d22581b9 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 28 Aug 2024 18:06:48 +0300 Subject: [PATCH 05/36] drop unwanted changes --- .../OrganizationSwitcher.tsx | 19 ++++-------- .../ui/components/UserButton/UserButton.tsx | 6 ++-- packages/clerk-js/src/ui/hooks/usePopover.ts | 1 - packages/types/src/clerk.ts | 31 +++++++++++-------- 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index bc39ce2e065..f584f2f8ecb 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -9,12 +9,11 @@ import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover'; import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger'; const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => { - const { __experimental_onOpenChanged, __experimental_open, defaultOpen } = useOrganizationSwitcherContext(); - + const { onOpenChanged, open, defaultOpen } = useOrganizationSwitcherContext(); const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, - open: __experimental_open, - onOpenChanged: __experimental_onOpenChanged, + open, + onOpenChanged, placement: 'bottom-start', offset: 8, }); @@ -47,21 +46,15 @@ const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactE }); const _OrganizationSwitcher = () => { - const { __experimental_hideTrigger, __experimental_onActionEnded } = useOrganizationSwitcherContext(); - return ( - {__experimental_hideTrigger ? ( - - ) : ( - - - - )} + + + ); diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index 90ed3bc33d1..de8c20396e2 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -8,11 +8,11 @@ import { UserButtonPopover } from './UserButtonPopover'; import { UserButtonTrigger } from './UserButtonTrigger'; const _UserButton = withFloatingTree(() => { - const { defaultOpen, __experimental_open, __experimental_onOpenChanged } = useUserButtonContext(); + const { defaultOpen, open, onOpenChanged } = useUserButtonContext(); const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, - open: __experimental_open, - onOpenChanged: __experimental_onOpenChanged, + open, + onOpenChanged, placement: 'bottom-end', offset: 8, }); diff --git a/packages/clerk-js/src/ui/hooks/usePopover.ts b/packages/clerk-js/src/ui/hooks/usePopover.ts index 1387e763f63..fdc16f1ac4c 100644 --- a/packages/clerk-js/src/ui/hooks/usePopover.ts +++ b/packages/clerk-js/src/ui/hooks/usePopover.ts @@ -66,7 +66,6 @@ export const usePopover = (props: UsePopoverProps = {}) => { useDismiss(context, { bubbles, outsidePress, - //outsidePress: typeof props.open === 'undefined' ? outsidePress : false, }); useEffect(() => { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 7e4fbb7da28..1f144fd2e6d 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -906,13 +906,17 @@ export type UserButtonProps = UserButtonProfileMode & { */ showName?: boolean; /** - * Controls the default state of the UserButton + * Controls the default state of the UserButton. */ defaultOpen?: boolean; - - __experimental_open?: boolean; - - __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + /** + * Controls whether the popover is visible. Should be used in conjunction with onOpenChange. + */ + open?: boolean; + /** + * Callback called when the open state of the popover changes. + */ + onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; /** * 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() @@ -970,16 +974,17 @@ type CreateOrganizationMode = export type OrganizationSwitcherProps = CreateOrganizationMode & OrganizationProfileMode & { /** - * Controls the default state of the OrganizationSwitcher + * Controls the default state of the OrganizationSwitcher. */ defaultOpen?: boolean; - - __experimental_open?: boolean; - - __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; - - __experimental_hideTrigger?: boolean; - __experimental_onActionEnded?: () => void; + /** + * Controls whether the popover is visible. Should be used in conjunction with onOpenChange. + */ + open?: boolean; + /** + * Callback called when the open state of the popover changes. + */ + onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; /** * By default, users can switch between organization and their personal account. * This option controls whether OrganizationSwitcher will include the user's personal account From d187ba22f0f413b89190401a5fcd23dec6a3dae2 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 28 Aug 2024 19:17:18 +0300 Subject: [PATCH 06/36] Revert "drop unwanted changes" This reverts commit d27b4ce69f69988f57ed34b775a4cd3176857056. --- .../OrganizationSwitcher.tsx | 19 ++++++++---- .../ui/components/UserButton/UserButton.tsx | 6 ++-- packages/clerk-js/src/ui/hooks/usePopover.ts | 1 + packages/types/src/clerk.ts | 31 ++++++++----------- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index f584f2f8ecb..bc39ce2e065 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -9,11 +9,12 @@ import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover'; import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger'; const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => { - const { onOpenChanged, open, defaultOpen } = useOrganizationSwitcherContext(); + const { __experimental_onOpenChanged, __experimental_open, defaultOpen } = useOrganizationSwitcherContext(); + const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, - open, - onOpenChanged, + open: __experimental_open, + onOpenChanged: __experimental_onOpenChanged, placement: 'bottom-start', offset: 8, }); @@ -46,15 +47,21 @@ const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactE }); const _OrganizationSwitcher = () => { + const { __experimental_hideTrigger, __experimental_onActionEnded } = useOrganizationSwitcherContext(); + return ( - - - + {__experimental_hideTrigger ? ( + + ) : ( + + + + )} ); diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index de8c20396e2..90ed3bc33d1 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -8,11 +8,11 @@ import { UserButtonPopover } from './UserButtonPopover'; import { UserButtonTrigger } from './UserButtonTrigger'; const _UserButton = withFloatingTree(() => { - const { defaultOpen, open, onOpenChanged } = useUserButtonContext(); + const { defaultOpen, __experimental_open, __experimental_onOpenChanged } = useUserButtonContext(); const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, - open, - onOpenChanged, + open: __experimental_open, + onOpenChanged: __experimental_onOpenChanged, placement: 'bottom-end', offset: 8, }); diff --git a/packages/clerk-js/src/ui/hooks/usePopover.ts b/packages/clerk-js/src/ui/hooks/usePopover.ts index fdc16f1ac4c..1387e763f63 100644 --- a/packages/clerk-js/src/ui/hooks/usePopover.ts +++ b/packages/clerk-js/src/ui/hooks/usePopover.ts @@ -66,6 +66,7 @@ export const usePopover = (props: UsePopoverProps = {}) => { useDismiss(context, { bubbles, outsidePress, + //outsidePress: typeof props.open === 'undefined' ? outsidePress : false, }); useEffect(() => { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 1f144fd2e6d..7e4fbb7da28 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -906,17 +906,13 @@ export type UserButtonProps = UserButtonProfileMode & { */ showName?: boolean; /** - * Controls the default state of the UserButton. + * Controls the default state of the UserButton */ defaultOpen?: boolean; - /** - * Controls whether the popover is visible. Should be used in conjunction with onOpenChange. - */ - open?: boolean; - /** - * Callback called when the open state of the popover changes. - */ - onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + + __experimental_open?: boolean; + + __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; /** * 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() @@ -974,17 +970,16 @@ type CreateOrganizationMode = export type OrganizationSwitcherProps = CreateOrganizationMode & OrganizationProfileMode & { /** - * Controls the default state of the OrganizationSwitcher. + * Controls the default state of the OrganizationSwitcher */ defaultOpen?: boolean; - /** - * Controls whether the popover is visible. Should be used in conjunction with onOpenChange. - */ - open?: boolean; - /** - * Callback called when the open state of the popover changes. - */ - onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + + __experimental_open?: boolean; + + __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + + __experimental_hideTrigger?: boolean; + __experimental_onActionEnded?: () => void; /** * By default, users can switch between organization and their personal account. * This option controls whether OrganizationSwitcher will include the user's personal account From 7cd9c0b8fccf838b04b36e6a3ba1597149b7996f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 28 Aug 2024 20:40:07 +0300 Subject: [PATCH 07/36] support hideTrigger in userButton --- .../ui/components/UserButton/UserButton.tsx | 45 +++++++++++++------ .../UserButton/UserButtonPopover.tsx | 5 ++- packages/types/src/clerk.ts | 3 ++ 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index 90ed3bc33d1..108f7a6236a 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,23 +7,21 @@ import { usePopover } from '../../hooks'; import { UserButtonPopover } from './UserButtonPopover'; import { UserButtonTrigger } from './UserButtonTrigger'; -const _UserButton = withFloatingTree(() => { - const { defaultOpen, __experimental_open, __experimental_onOpenChanged } = useUserButtonContext(); +const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => { + const { __experimental_onOpenChanged, __experimental_open, defaultOpen } = useUserButtonContext(); + const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, open: __experimental_open, onOpenChanged: __experimental_onOpenChanged, - placement: 'bottom-end', + placement: 'bottom-start', offset: 8, }); const userButtonMenuId = useId(); return ( - + <> { context={context} isOpen={isOpen} > - + {cloneElement(children, { + id: userButtonMenuId, + close: toggle, + ref: floating, + style: styles, + })} + + ); +}); + +const _UserButton = withFloatingTree(() => { + const { __experimental_hideTrigger, __experimental_onActionEnded } = useUserButtonContext(); + + return ( + + {__experimental_hideTrigger ? ( + + ) : ( + + + + )} ); }); diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx index 10b737e30b9..b7d9ce9c613 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx @@ -9,10 +9,11 @@ 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: optionalClose, ...rest } = props; + const close = () => optionalClose?.(); const { session } = useSession() as { session: ActiveSessionResource }; const { authConfig } = useEnvironment(); const { user } = useUser(); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 7e4fbb7da28..afa5bf85753 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -913,6 +913,9 @@ export type UserButtonProps = UserButtonProfileMode & { __experimental_open?: boolean; __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + + __experimental_hideTrigger?: boolean; + __experimental_onActionEnded?: () => void; /** * 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() From 75e093357f5e2f2d236f88cae8bf5af317332450 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 28 Aug 2024 20:44:16 +0300 Subject: [PATCH 08/36] remove experimental open --- .../OrganizationSwitcher/OrganizationSwitcher.tsx | 4 +--- .../src/ui/components/UserButton/UserButton.tsx | 4 +--- packages/clerk-js/src/ui/elements/Popover.tsx | 2 -- packages/clerk-js/src/ui/hooks/usePopover.ts | 13 ++----------- packages/types/src/clerk.ts | 8 -------- 5 files changed, 4 insertions(+), 27 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index bc39ce2e065..fc49d325ebf 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -9,12 +9,10 @@ import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover'; import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger'; const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => { - const { __experimental_onOpenChanged, __experimental_open, defaultOpen } = useOrganizationSwitcherContext(); + const { defaultOpen } = useOrganizationSwitcherContext(); const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, - open: __experimental_open, - onOpenChanged: __experimental_onOpenChanged, placement: 'bottom-start', offset: 8, }); diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index 108f7a6236a..be18299211b 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -8,12 +8,10 @@ import { UserButtonPopover } from './UserButtonPopover'; import { UserButtonTrigger } from './UserButtonTrigger'; const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => { - const { __experimental_onOpenChanged, __experimental_open, defaultOpen } = useUserButtonContext(); + const { defaultOpen } = useUserButtonContext(); const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, - open: __experimental_open, - onOpenChanged: __experimental_onOpenChanged, placement: 'bottom-start', offset: 8, }); diff --git a/packages/clerk-js/src/ui/elements/Popover.tsx b/packages/clerk-js/src/ui/elements/Popover.tsx index f79fc27732c..d3f5ab43bf5 100644 --- a/packages/clerk-js/src/ui/elements/Popover.tsx +++ b/packages/clerk-js/src/ui/elements/Popover.tsx @@ -24,7 +24,6 @@ export const Popover = (props: PopoverProps) => { context={context} initialFocus={initialFocus} order={order} - closeOnFocusOut={false} > <>{children} @@ -41,7 +40,6 @@ export const Popover = (props: PopoverProps) => { context={context} initialFocus={initialFocus} order={order} - closeOnFocusOut={false} > <>{children} diff --git a/packages/clerk-js/src/ui/hooks/usePopover.ts b/packages/clerk-js/src/ui/hooks/usePopover.ts index 1387e763f63..12f0d20678b 100644 --- a/packages/clerk-js/src/ui/hooks/usePopover.ts +++ b/packages/clerk-js/src/ui/hooks/usePopover.ts @@ -4,8 +4,6 @@ import React, { useEffect } from 'react'; type UsePopoverProps = { defaultOpen?: boolean; - open?: boolean; - onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; placement?: UseFloatingOptions['placement']; offset?: Parameters[0]; shoudFlip?: boolean; @@ -24,17 +22,10 @@ type UsePopoverProps = { export type UsePopoverReturn = ReturnType; export const usePopover = (props: UsePopoverProps = {}) => { - const { bubbles = true, shoudFlip = true, outsidePress, adjustToReferenceWidth = false, referenceElement } = props; - const [isOpen_internal, setIsOpen_internal] = React.useState(props.defaultOpen || false); - - const isOpen = typeof props.open === 'undefined' ? isOpen_internal : props.open; - const setIsOpen = typeof props.onOpenChanged === 'undefined' ? setIsOpen_internal : props.onOpenChanged; + const { bubbles = false, shoudFlip = true, outsidePress, adjustToReferenceWidth = false, referenceElement } = props; + const [isOpen, setIsOpen] = React.useState(props.defaultOpen || false); const nodeId = useFloatingNodeId(); - if (typeof props.defaultOpen !== 'undefined' && typeof props.open !== 'undefined') { - console.warn('Both defaultOpen and open are set. `defaultOpen` will be ignored'); - } - const { update, refs, strategy, x, y, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index afa5bf85753..7b017d16dec 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -910,10 +910,6 @@ export type UserButtonProps = UserButtonProfileMode & { */ defaultOpen?: boolean; - __experimental_open?: boolean; - - __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; - __experimental_hideTrigger?: boolean; __experimental_onActionEnded?: () => void; /** @@ -977,10 +973,6 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & */ defaultOpen?: boolean; - __experimental_open?: boolean; - - __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; - __experimental_hideTrigger?: boolean; __experimental_onActionEnded?: () => void; /** From d6f58e3a099d78a531e8680b8889fa345712ed5e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 28 Aug 2024 20:53:38 +0300 Subject: [PATCH 09/36] update naming and write jsdoc --- .../OrganizationSwitcher.tsx | 6 ++-- .../ui/components/UserButton/UserButton.tsx | 6 ++-- packages/types/src/clerk.ts | 34 ++++++++++++++++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index fc49d325ebf..d6295c92120 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -45,7 +45,7 @@ const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactE }); const _OrganizationSwitcher = () => { - const { __experimental_hideTrigger, __experimental_onActionEnded } = useOrganizationSwitcherContext(); + const { __experimental_standalone, __experimental_onDismiss } = useOrganizationSwitcherContext(); return ( { sx={{ display: 'inline-flex' }} > - {__experimental_hideTrigger ? ( - + {__experimental_standalone ? ( + ) : ( diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index be18299211b..e87cb48ae19 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -44,15 +44,15 @@ const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>( }); const _UserButton = withFloatingTree(() => { - const { __experimental_hideTrigger, __experimental_onActionEnded } = useUserButtonContext(); + const { __experimental_standalone, __experimental_onDismiss } = useUserButtonContext(); return ( - {__experimental_hideTrigger ? ( - + {__experimental_standalone ? ( + ) : ( diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 7b017d16dec..2742a6258d1 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -910,8 +910,22 @@ export type UserButtonProps = UserButtonProfileMode & { */ defaultOpen?: boolean; - __experimental_hideTrigger?: boolean; - __experimental_onActionEnded?: () => void; + /** + * If true the UserButton will only render the popover. + * Enables developers to implement a custom dialog. + * @experimental This API is experimental and may change at any moment. + * @default false + */ + __experimental_standalone?: boolean; + + /** + * Notifies the caller that it's safe to unmount UserButton. + * It only fires when used in conjunction with `__experimental_standalone`. + * @experimental This API is experimental and may change at any moment. + * @default undefined + */ + __experimental_onDismiss?: () => void; + /** * 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() @@ -972,9 +986,21 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & * Controls the default state of the OrganizationSwitcher */ defaultOpen?: boolean; + /** + * If true the OrganizationSwitcher will only render the popover. + * Enables developers to implement a custom dialog. + * @experimental This API is experimental and may change at any moment. + * @default false + */ + __experimental_standalone?: boolean; - __experimental_hideTrigger?: boolean; - __experimental_onActionEnded?: () => void; + /** + * Notifies the caller that it's safe to unmount OrganizationSwitcher. + * It only fires when used in conjunction with `__experimental_standalone`. + * @experimental This API is experimental and may change at any moment. + * @default undefined + */ + __experimental_onDismiss?: () => void; /** * By default, users can switch between organization and their personal account. * This option controls whether OrganizationSwitcher will include the user's personal account From 2f690803e1dab20cca76fd5a4013a2ccf43123fe Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 28 Aug 2024 20:58:18 +0300 Subject: [PATCH 10/36] minor cleanup --- packages/clerk-js/src/ui/components/UserButton/UserButton.tsx | 2 +- packages/clerk-js/src/ui/hooks/usePopover.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index e87cb48ae19..bf6ac843dc2 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -12,7 +12,7 @@ const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>( const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({ defaultOpen, - placement: 'bottom-start', + placement: 'bottom-end', offset: 8, }); diff --git a/packages/clerk-js/src/ui/hooks/usePopover.ts b/packages/clerk-js/src/ui/hooks/usePopover.ts index 12f0d20678b..21f65a2b7d9 100644 --- a/packages/clerk-js/src/ui/hooks/usePopover.ts +++ b/packages/clerk-js/src/ui/hooks/usePopover.ts @@ -25,7 +25,6 @@ export const usePopover = (props: UsePopoverProps = {}) => { const { bubbles = false, shoudFlip = true, outsidePress, adjustToReferenceWidth = false, referenceElement } = props; const [isOpen, setIsOpen] = React.useState(props.defaultOpen || false); const nodeId = useFloatingNodeId(); - const { update, refs, strategy, x, y, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, @@ -57,7 +56,6 @@ export const usePopover = (props: UsePopoverProps = {}) => { useDismiss(context, { bubbles, outsidePress, - //outsidePress: typeof props.open === 'undefined' ? outsidePress : false, }); useEffect(() => { From 22691cbfa67f73864e924279e545eb24f2304cf8 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 30 Aug 2024 20:05:52 +0300 Subject: [PATCH 11/36] add unit tests --- .../__tests__/OrganizationSwitcher.test.tsx | 47 ++++++++++++++++++- .../UserButton/__tests__/UserButton.test.tsx | 28 +++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) 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..2ee55c9210a 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,54 @@ 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_standalone: true, + }); + const { getByText, queryByRole } = render(, { wrapper }); + await waitFor(() => { + expect(queryByRole('button', { name: 'Open organization switcher' })).toBeNull(); + expect(getByText('Personal account')).toBeInTheDocument(); + }); + }); + + it('calls onDismiss when "Manage Organization" is clicked', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + create_organization_enabled: true, + }); + }); + + const onDismiss = jest.fn(); + props.setProps({ + __experimental_standalone: true, + __experimental_onDismiss: onDismiss, + }); + + fixtures.clerk.organization?.getRoles.mockRejectedValue(null); + fixtures.clerk.user?.getOrganizationMemberships.mockResolvedValueOnce([]); + + const { getByRole, userEvent, queryByRole, getByText } = render(, { wrapper }); + await waitFor(() => { + expect(queryByRole('button', { name: 'Open organization switcher' })).toBeNull(); + expect(getByText('Personal account')).toBeInTheDocument(); + }); + await userEvent.click(getByRole('menuitem', { name: 'Create organization' })); + expect(fixtures.clerk.openCreateOrganization).toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + 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/__tests__/UserButton.test.tsx b/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx index f9f2bd3142c..ac13dc57add 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,34 @@ 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_standalone: true, + }); + const { getByText, queryByRole } = render(, { wrapper }); + expect(queryByRole('button', { name: 'Open user button' })).toBeNull(); + getByText('Manage account'); + }); + + it('calls onDismiss when it is used as standalone', async () => { + const { wrapper, props, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + const onDismiss = jest.fn(); + props.setProps({ + __experimental_standalone: true, + __experimental_onDismiss: onDismiss, + }); + const { getByText, userEvent, queryByRole } = render(, { wrapper }); + expect(queryByRole('button', { name: 'Open user button' })).toBeNull(); + await userEvent.click(getByText('Manage account')); + expect(fixtures.clerk.openUserProfile).toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + it('opens the user button popover when clicked', async () => { const { wrapper } = await createFixtures(f => { f.withUser({ From 08a1b3ec08230b6b9bb06d0c7c93ad655f5e917f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 30 Aug 2024 20:07:53 +0300 Subject: [PATCH 12/36] update changeset --- .changeset/shaggy-kids-fail.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/shaggy-kids-fail.md b/.changeset/shaggy-kids-fail.md index cd12a0ebc89..d24776a1cc7 100644 --- a/.changeset/shaggy-kids-fail.md +++ b/.changeset/shaggy-kids-fail.md @@ -3,4 +3,5 @@ '@clerk/types': minor --- -Add experimental support for a controlled UserButton. +Add experimental support for a standalone mode for UserButton and OrganizationSwitcher. +When `__experimental_standalone: true` the component will not render its trigger and instead it will render only the contents of the popover in place. From 57e39d90f058cd7858c2948f2a3b67df7fe23b53 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 4 Sep 2024 17:49:38 +0300 Subject: [PATCH 13/36] wip --- .../src/ui/components/UserButton/UserButton.tsx | 10 ++++++---- .../src/ui/components/UserButton/UserButtonPopover.tsx | 6 +++++- .../src/ui/contexts/ClerkUIComponentsContext.tsx | 4 ++++ packages/react/src/components/uiComponents.tsx | 7 +++++++ packages/types/src/clerk.ts | 4 ++++ 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index bf6ac843dc2..98efe1669ff 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -43,8 +43,8 @@ const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>( ); }); -const _UserButton = withFloatingTree(() => { - const { __experimental_standalone, __experimental_onDismiss } = useUserButtonContext(); +const _UserButton = () => { + const { __experimental_standalone, __experimental_onDismiss, __experimental_open } = useUserButtonContext(); return ( { sx={{ display: 'inline-flex' }} > {__experimental_standalone ? ( - + __experimental_open ? ( + + ) : null ) : ( @@ -60,6 +62,6 @@ const _UserButton = withFloatingTree(() => { )} ); -}); +}; 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 b7d9ce9c613..e9c32b6c1a0 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx @@ -25,7 +25,11 @@ export const UserButtonPopover = React.forwardRef diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 3d064bb273b..b54f06d0759 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -227,6 +227,8 @@ export const useUserProfileContext = (): UserProfileContextType => { throw new Error('Clerk: useUserProfileContext called outside of the mounted UserProfile component.'); } + console.log('----- profile', customPages); + const pages = useMemo(() => { return createUserProfileCustomPages(customPages || [], clerk); }, [customPages]); @@ -265,6 +267,8 @@ export const useUserButtonContext = () => { const { displayConfig } = useEnvironment(); const options = useOptions(); + console.log(ctx?.userProfileProps?.customPages); + if (componentName !== 'UserButton') { throw new Error('Clerk: useUserButtonContext called outside of the mounted UserButton component.'); } diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 1f68a59d898..9e1aa9da3bc 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -111,6 +111,7 @@ class Portal extends React.PureComponent { private portalRef = React.createRef(); componentDidUpdate(_prevProps: Readonly) { + console.log('-----Did update'); if (!isMountProps(_prevProps) || !isMountProps(this.props)) { return; } @@ -123,13 +124,17 @@ 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; + + console.log(prevProps, newProps, isDeeplyEqual(prevProps, newProps)); if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { + console.log('update'); this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); } } componentDidMount() { if (this.portalRef.current) { + console.log('---- props on mount', this.props.props); if (isMountProps(this.props)) { this.props.mount(this.portalRef.current, this.props.props); } @@ -223,6 +228,8 @@ const _UserButton = withClerk( const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages }); const { customMenuItems, customMenuItemsPortals } = useUserButtonCustomMenuItems(props.children); + console.log('---- _UserButton', props, userProfileProps); + return ( void; + __experimental_open?: boolean; + + __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + /** * 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() From 640d7f4fe5dde2b4f42a0defbb512c2600b95854 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 5 Sep 2024 20:33:48 +0300 Subject: [PATCH 14/36] poc state --- .../ui/components/UserButton/UserButton.tsx | 13 +- .../UserButton/UserButtonPopover.tsx | 6 +- packages/clerk-js/src/ui/hooks/usePopover.ts | 12 +- .../react/src/components/uiComponents.tsx | 181 +++++++++++++++--- .../react/src/utils/useCustomMenuItems.tsx | 3 +- packages/react/src/utils/useCustomPages.tsx | 4 +- packages/types/src/clerk.ts | 23 ++- 7 files changed, 198 insertions(+), 44 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index 98efe1669ff..fe6a234e021 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -44,16 +44,21 @@ const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>( }); const _UserButton = () => { - const { __experimental_standalone, __experimental_onDismiss, __experimental_open } = useUserButtonContext(); + const { __experimental_asStandalone } = useUserButtonContext(); return ( - {__experimental_standalone ? ( - __experimental_open ? ( - + {/*{__experimental_standalone ? (*/} + {/* __experimental_open ? (*/} + {/* */} + {/* ) : null*/} + {/* )*/} + {__experimental_asStandalone ? ( + __experimental_asStandalone.open ? ( + ) : null ) : ( diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx index e9c32b6c1a0..dbaf7b8e260 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?: (open: boolean | ((prevState: boolean) => boolean)) => void; +} & PropsOfComponent; export const UserButtonPopover = React.forwardRef((props, ref) => { const { close: optionalClose, ...rest } = props; - const close = () => optionalClose?.(); + const close = () => optionalClose?.(false); const { session } = useSession() as { session: ActiveSessionResource }; const { authConfig } = useEnvironment(); const { user } = useUser(); diff --git a/packages/clerk-js/src/ui/hooks/usePopover.ts b/packages/clerk-js/src/ui/hooks/usePopover.ts index 21f65a2b7d9..5cf7ce4e4d3 100644 --- a/packages/clerk-js/src/ui/hooks/usePopover.ts +++ b/packages/clerk-js/src/ui/hooks/usePopover.ts @@ -4,6 +4,8 @@ import React, { useEffect } from 'react'; type UsePopoverProps = { defaultOpen?: boolean; + open?: boolean; + onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; placement?: UseFloatingOptions['placement']; offset?: Parameters[0]; shoudFlip?: boolean; @@ -23,8 +25,16 @@ export type UsePopoverReturn = ReturnType; export const usePopover = (props: UsePopoverProps = {}) => { const { bubbles = false, shoudFlip = true, outsidePress, adjustToReferenceWidth = false, referenceElement } = props; - const [isOpen, setIsOpen] = React.useState(props.defaultOpen || false); + const [isOpen_internal, setIsOpen_internal] = React.useState(props.defaultOpen || false); + + const isOpen = typeof props.open === 'undefined' ? isOpen_internal : props.open; + const setIsOpen = typeof props.onOpenChanged === 'undefined' ? setIsOpen_internal : props.onOpenChanged; const nodeId = useFloatingNodeId(); + + if (typeof props.defaultOpen !== 'undefined' && typeof props.open !== 'undefined') { + console.warn('Both defaultOpen and open are set. `defaultOpen` will be ignored'); + } + const { update, refs, strategy, x, y, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 9e1aa9da3bc..726807c2a4a 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -14,7 +14,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, @@ -50,6 +50,7 @@ type UserButtonExportType = typeof _UserButton & { MenuItems: typeof MenuItems; Action: typeof MenuAction; Link: typeof MenuLink; + Body: () => React.JSX.Element; }; type UserButtonPropsWithoutCustomPages = Without & { @@ -107,11 +108,25 @@ const isOpenProps = (props: any): props is OpenProps => { return 'open' in props; }; -class Portal extends React.PureComponent { +const CustomPortalsRenderer = (props: MountProps) => { + if (!isMountProps(props)) { + return null; + } + + return ( + <> + {props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} + {props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))} + + ); +}; + +class Portal extends React.PureComponent< + PropsWithChildren<(MountProps | OpenProps) & { renderHtmlElement?: boolean }> +> { private portalRef = React.createRef(); componentDidUpdate(_prevProps: Readonly) { - console.log('-----Did update'); if (!isMountProps(_prevProps) || !isMountProps(this.props)) { return; } @@ -127,14 +142,19 @@ class Portal extends React.PureComponent { console.log(prevProps, newProps, isDeeplyEqual(prevProps, newProps)); if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { - console.log('update'); - this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); + // if (!this.props.props.withOutlet) { + if (this.portalRef.current) { + this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); + } + // } } } componentDidMount() { + // if (this.props.props.withOutlet) { + // return; + // } if (this.portalRef.current) { - console.log('---- props on mount', this.props.props); if (isMountProps(this.props)) { this.props.mount(this.portalRef.current, this.props.props); } @@ -146,6 +166,9 @@ class Portal extends React.PureComponent { } componentWillUnmount() { + // if (this.props.props.withOutlet) { + // return; + // } if (this.portalRef.current) { if (isMountProps(this.props)) { this.props.unmount(this.portalRef.current); @@ -157,18 +180,92 @@ class Portal extends React.PureComponent { } render() { + const { renderHtmlElement = true } = 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 }))} + {/*{!this.props.props.withOutlet &&
}*/} + {renderHtmlElement &&
} + {/*{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 }))}*/} + + {this.props.children} + + {/*{this.props.props.withOutlet*/} + {/* ? React.Children.map(this.props.props.children, (child, index) => {*/} + {/* return child;*/} + {/* // Clone each child and pass additional props*/} + {/* return React.cloneElement(child, {*/} + {/* key: index, // always set a unique key when mapping*/} + {/* // additionalProp: `Value ${index + 1}`, // adding new props or modifying existing ones*/} + {/* ...this.props,*/} + {/* });*/} + {/* })*/} + {/* : null}*/} ); } } +// class Portal2 extends React.PureComponent { +// private portalRef = React.createRef(); +// +// componentDidUpdate(_prevProps: Readonly) { +// if (!isMountProps(_prevProps) || !isMountProps(this.props)) { +// return; +// } +// console.log('portal2 update'); +// +// // Remove children and customPages from props before comparing +// // children might hold circular references which deepEqual can't handle +// // and the implementation of customPages or customMenuItems relies on props getting new references +// const prevProps = without(_prevProps.props, 'customPages', 'customMenuItems', 'children'); +// const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children'); +// // 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; +// +// console.log(prevProps, newProps, isDeeplyEqual(prevProps, newProps)); +// if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { +// this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); +// } +// } +// +// componentDidMount() { +// console.log('portal2 mounted'); +// if (this.portalRef.current) { +// if (isMountProps(this.props)) { +// this.props.mount(this.portalRef.current, this.props.props); +// } +// +// if (isOpenProps(this.props)) { +// this.props.open(this.props.props); +// } +// } +// } +// +// componentWillUnmount() { +// console.log('portal2 unmounted'); +// if (this.portalRef.current) { +// if (isMountProps(this.props)) { +// this.props.unmount(this.portalRef.current); +// } +// if (isOpenProps(this.props)) { +// this.props.close(); +// } +// } +// } +// +// render() { +// return ( +// <> +//
+// +// ); +// } +// } + export const SignIn = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( ); }, 'SignUp'); -export function UserProfilePage({ children }: PropsWithChildren) { +export function UserProfilePage(_: PropsWithChildren) { logErrorInDevMode(userProfilePageRenderedError); - return <>{children}; + return null; } -export function UserProfileLink({ children }: PropsWithChildren) { +export function UserProfileLink(_: PropsWithChildren) { logErrorInDevMode(userProfileLinkRenderedError); - return <>{children}; + return null; } const _UserProfile = withClerk( @@ -222,41 +319,64 @@ export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { Link: UserProfileLink, }); +// @ts-ignore +const UserButtonContext = createContext({}); + const _UserButton = withClerk( ({ clerk, ...props }: WithClerkProp>) => { const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages }); const { customMenuItems, customMenuItemsPortals } = useUserButtonCustomMenuItems(props.children); - console.log('---- _UserButton', props, userProfileProps); + const passableProps = { + mount: clerk.mountUserButton, + unmount: clerk.unmountUserButton, + updateProps: (clerk as any).__unstable__updateProps, + props: { ...props, userProfileProps, customMenuItems }, + customPagesPortals: customPagesPortals, + customMenuItemsPortals: customMenuItemsPortals, + }; return ( - + + + {props.children} + + + ); }, 'UserButton', ); -export function MenuItems({ children }: PropsWithChildren) { +export function MenuItems(_: PropsWithChildren) { logErrorInDevMode(userButtonMenuItemsRenderedError); - return <>{children}; + return null; } -export function MenuAction({ children }: PropsWithChildren) { +export function MenuAction(_: PropsWithChildren) { logErrorInDevMode(userButtonMenuActionRenderedError); - return <>{children}; + return null; } -export function MenuLink({ children }: PropsWithChildren) { +export function MenuLink(_: PropsWithChildren) { logErrorInDevMode(userButtonMenuLinkRenderedError); - return <>{children}; + return null; +} + +export function UserButtonOutlet() { + const props = useContext(UserButtonContext); + return ; } export const UserButton: UserButtonExportType = Object.assign(_UserButton, { @@ -265,6 +385,7 @@ export const UserButton: UserButtonExportType = Object.assign(_UserButton, { MenuItems, Action: MenuAction, Link: MenuLink, + Body: UserButtonOutlet as () => React.JSX.Element, }); export const __experimental_UserVerification = withClerk( diff --git a/packages/react/src/utils/useCustomMenuItems.tsx b/packages/react/src/utils/useCustomMenuItems.tsx index 239a1e0bbf8..30b8a741c8d 100644 --- a/packages/react/src/utils/useCustomMenuItems.tsx +++ b/packages/react/src/utils/useCustomMenuItems.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { MenuAction, MenuItems, MenuLink, UserProfileLink, UserProfilePage } from '../components/uiComponents'; import { customMenuItemsIgnoredComponent, - userButtonIgnoredComponent, userButtonMenuItemLinkWrongProps, userButtonMenuItemsActionWrongsProps, } from '../errors/messages'; @@ -60,7 +59,7 @@ const useCustomMenuItems = ({ !isThatComponent(child, UserProfilePageComponent) ) { if (child) { - logErrorInDevMode(userButtonIgnoredComponent); + // logErrorInDevMode(userButtonIgnoredComponent); } return; } diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx index 4274fe34fca..140a21c5751 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -10,7 +10,7 @@ import { UserProfileLink, UserProfilePage, } from '../components/uiComponents'; -import { customLinkWrongProps, customPagesIgnoredComponent, customPageWrongProps } from '../errors/messages'; +import { customLinkWrongProps, customPageWrongProps } from '../errors/messages'; import type { UserProfilePageProps } from '../types'; import { isThatComponent } from './componentValidation'; import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal'; @@ -67,7 +67,7 @@ const useCustomPages = ({ !isThatComponent(child, MenuItemsComponent) ) { if (child) { - logErrorInDevMode(customPagesIgnoredComponent(componentName)); + // logErrorInDevMode(customPagesIgnoredComponent(componentName)); } return; } diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index aa9ca6fcf93..79af737d913 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -983,6 +983,11 @@ export type UserButtonProps = UserButtonProfileMode & { */ defaultOpen?: boolean; + __experimental_asStandalone?: { + open: boolean; + onOpenChanged: (open: boolean | ((prevState: boolean) => boolean)) => void; + }; + /** * If true the UserButton will only render the popover. * Enables developers to implement a custom dialog. @@ -999,9 +1004,21 @@ export type UserButtonProps = UserButtonProfileMode & { */ __experimental_onDismiss?: () => void; - __experimental_open?: boolean; - - __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + // /** + // * The controlled open state of the popover. When defined the trigger will not be + // * Should be used in conjunction with `__experimental_open`. + // * @experimental This API is experimental and may change at any moment. + // * @default undefined + // */ + // __experimental_open?: boolean; + // + // /** + // * Event handler called when the open state of the dialog changes. + // * It only fires when used in conjunction with `__experimental_open`. + // * @experimental This API is experimental and may change at any moment. + // * @default undefined + // */ + // __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; /** * Full URL or path to navigate after sign out is complete From 4988ea4742587d63b9df1d2a07375889ed0ef10c Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 5 Sep 2024 20:51:36 +0300 Subject: [PATCH 15/36] ready for snapshot --- .../OrganizationSwitcher.tsx | 8 +- .../OrganizationSwitcherPopover.tsx | 6 +- .../ui/components/UserButton/UserButton.tsx | 5 - .../react/src/components/uiComponents.tsx | 189 +++++++----------- packages/types/src/clerk.ts | 75 ++++--- 5 files changed, 114 insertions(+), 169 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index d6295c92120..60661213674 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -45,7 +45,7 @@ const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactE }); const _OrganizationSwitcher = () => { - const { __experimental_standalone, __experimental_onDismiss } = useOrganizationSwitcherContext(); + const { __experimental_asStandalone } = useOrganizationSwitcherContext(); return ( { sx={{ display: 'inline-flex' }} > - {__experimental_standalone ? ( - + {__experimental_asStandalone ? ( + __experimental_asStandalone.open ? ( + + ) : null ) : ( diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 5d65a28fcd3..7c3711a174d 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -21,12 +21,14 @@ 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: undefinedClose, ...rest } = props; - const close = () => undefinedClose?.(); + const close = () => undefinedClose?.(false); const card = useCardState(); const { openOrganizationProfile, openCreateOrganization } = useClerk(); const { organization: currentOrg } = useOrganization(); diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index fe6a234e021..f079674a4d9 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -51,11 +51,6 @@ const _UserButton = () => { flow='userButton' sx={{ display: 'inline-flex' }} > - {/*{__experimental_standalone ? (*/} - {/* __experimental_open ? (*/} - {/* */} - {/* ) : null*/} - {/* )*/} {__experimental_asStandalone ? ( __experimental_asStandalone.open ? ( diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 726807c2a4a..fcff7529f8a 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -65,12 +65,21 @@ type OrganizationProfileExportType = typeof _OrganizationProfile & { type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { OrganizationProfilePage: typeof OrganizationProfilePage; OrganizationProfileLink: typeof OrganizationProfileLink; + Body: () => React.JSX.Element; }; type OrganizationSwitcherPropsWithoutCustomPages = Without & { organizationProfileProps?: Pick; }; +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 // lifecycle props to be invoked correctly. Replacing the class component with a // functional component wrapped with a React.memo is not identical to the original @@ -100,29 +109,8 @@ type OrganizationSwitcherPropsWithoutCustomPages = Without { - return 'mount' in props; -}; - -const isOpenProps = (props: any): props is OpenProps => { - return 'open' in props; -}; - -const CustomPortalsRenderer = (props: MountProps) => { - if (!isMountProps(props)) { - return null; - } - - return ( - <> - {props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} - {props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))} - - ); -}; - class Portal extends React.PureComponent< - PropsWithChildren<(MountProps | OpenProps) & { renderHtmlElement?: boolean }> + PropsWithChildren<(MountProps | OpenProps) & { hideRootHtmlElement?: boolean }> > { private portalRef = React.createRef(); @@ -142,18 +130,13 @@ class Portal extends React.PureComponent< console.log(prevProps, newProps, isDeeplyEqual(prevProps, newProps)); if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { - // if (!this.props.props.withOutlet) { if (this.portalRef.current) { this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); } - // } } } componentDidMount() { - // if (this.props.props.withOutlet) { - // return; - // } if (this.portalRef.current) { if (isMountProps(this.props)) { this.props.mount(this.portalRef.current, this.props.props); @@ -166,9 +149,6 @@ class Portal extends React.PureComponent< } componentWillUnmount() { - // if (this.props.props.withOutlet) { - // return; - // } if (this.portalRef.current) { if (isMountProps(this.props)) { this.props.unmount(this.portalRef.current); @@ -180,91 +160,28 @@ class Portal extends React.PureComponent< } render() { - const { renderHtmlElement = true } = this.props; + const { hideRootHtmlElement = false } = this.props; return ( <> - {/*{!this.props.props.withOutlet &&
}*/} - {renderHtmlElement &&
} - {/*{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} - - {/*{this.props.props.withOutlet*/} - {/* ? React.Children.map(this.props.props.children, (child, index) => {*/} - {/* return child;*/} - {/* // Clone each child and pass additional props*/} - {/* return React.cloneElement(child, {*/} - {/* key: index, // always set a unique key when mapping*/} - {/* // additionalProp: `Value ${index + 1}`, // adding new props or modifying existing ones*/} - {/* ...this.props,*/} - {/* });*/} - {/* })*/} - {/* : null}*/} ); } } -// class Portal2 extends React.PureComponent { -// private portalRef = React.createRef(); -// -// componentDidUpdate(_prevProps: Readonly) { -// if (!isMountProps(_prevProps) || !isMountProps(this.props)) { -// return; -// } -// console.log('portal2 update'); -// -// // Remove children and customPages from props before comparing -// // children might hold circular references which deepEqual can't handle -// // and the implementation of customPages or customMenuItems relies on props getting new references -// const prevProps = without(_prevProps.props, 'customPages', 'customMenuItems', 'children'); -// const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children'); -// // 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; -// -// console.log(prevProps, newProps, isDeeplyEqual(prevProps, newProps)); -// if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { -// this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); -// } -// } -// -// componentDidMount() { -// console.log('portal2 mounted'); -// if (this.portalRef.current) { -// if (isMountProps(this.props)) { -// this.props.mount(this.portalRef.current, this.props.props); -// } -// -// if (isOpenProps(this.props)) { -// this.props.open(this.props.props); -// } -// } -// } -// -// componentWillUnmount() { -// console.log('portal2 unmounted'); -// if (this.portalRef.current) { -// if (isMountProps(this.props)) { -// this.props.unmount(this.portalRef.current); -// } -// if (isOpenProps(this.props)) { -// this.props.close(); -// } -// } -// } -// -// render() { -// return ( -// <> -//
-// -// ); -// } -// } +const CustomPortalsRenderer = (props: MountProps) => { + if (!isMountProps(props)) { + return null; + } + + 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 ( @@ -348,7 +265,7 @@ const _UserButton = withClerk( > {props.children} @@ -402,14 +319,14 @@ export const __experimental_UserVerification = withClerk( '__experimental_UserVerification', ); -export function OrganizationProfilePage({ children }: PropsWithChildren) { +export function OrganizationProfilePage(_: PropsWithChildren) { logErrorInDevMode(organizationProfilePageRenderedError); - return <>{children}; + return null; } -export function OrganizationProfileLink({ children }: PropsWithChildren) { +export function OrganizationProfileLink(_: PropsWithChildren) { logErrorInDevMode(organizationProfileLinkRenderedError); - return <>{children}; + return null; } const _OrganizationProfile = withClerk( @@ -444,27 +361,61 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp< ); }, 'CreateOrganization'); +// @ts-ignore +const OrganizationSwitcherContext = createContext({}); + const _OrganizationSwitcher = withClerk( ({ clerk, ...props }: WithClerkProp>) => { const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); const organizationProfileProps = Object.assign(props.organizationProfileProps || {}, { customPages }); + const passableProps = { + mount: clerk.mountOrganizationSwitcher, + unmount: clerk.unmountOrganizationSwitcher, + updateProps: (clerk as any).__unstable__updateProps, + props: { ...props, organizationProfileProps }, + customPagesPortals: customPagesPortals, + }; + return ( - + // + + + + {props.children} + + + ); }, 'OrganizationSwitcher', ); +export function OrganizationSwitcherOutlet() { + const props = useContext(OrganizationSwitcherContext); + return ; +} + export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, { OrganizationProfilePage, OrganizationProfileLink, + Body: OrganizationSwitcherOutlet, }); export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 79af737d913..9635a3e811e 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -983,42 +983,28 @@ export type UserButtonProps = UserButtonProfileMode & { */ defaultOpen?: boolean; - __experimental_asStandalone?: { - open: boolean; - onOpenChanged: (open: boolean | ((prevState: boolean) => boolean)) => void; - }; - /** * If true the UserButton will only render the popover. * Enables developers to implement a custom dialog. * @experimental This API is experimental and may change at any moment. - * @default false - */ - __experimental_standalone?: boolean; - - /** - * Notifies the caller that it's safe to unmount UserButton. - * It only fires when used in conjunction with `__experimental_standalone`. - * @experimental This API is experimental and may change at any moment. * @default undefined */ - __experimental_onDismiss?: () => void; - - // /** - // * The controlled open state of the popover. When defined the trigger will not be - // * Should be used in conjunction with `__experimental_open`. - // * @experimental This API is experimental and may change at any moment. - // * @default undefined - // */ - // __experimental_open?: boolean; - // - // /** - // * Event handler called when the open state of the dialog changes. - // * It only fires when used in conjunction with `__experimental_open`. - // * @experimental This API is experimental and may change at any moment. - // * @default undefined - // */ - // __experimental_onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; + __experimental_asStandalone?: { + /** + * The controlled open state of the popover. When defined the trigger will not be rendered + * Should be used in conjunction with `__experimental_asStandalone.onOpenChanged`. + * @experimental This API is experimental and may change at any moment. + */ + open: boolean; + + /** + * Event handler called when the open state of the dialog changes. + * It only fires when used in conjunction with `__experimental_asStandalone.open`. + * @experimental This API is experimental and may change at any moment. + * @default undefined + */ + onOpenChanged: (open: boolean | ((prevState: boolean) => boolean)) => void; + }; /** * Full URL or path to navigate after sign out is complete @@ -1080,21 +1066,30 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & * Controls the default state of the OrganizationSwitcher */ defaultOpen?: boolean; - /** - * If true the OrganizationSwitcher will only render the popover. - * Enables developers to implement a custom dialog. - * @experimental This API is experimental and may change at any moment. - * @default false - */ - __experimental_standalone?: boolean; /** - * Notifies the caller that it's safe to unmount OrganizationSwitcher. - * It only fires when used in conjunction with `__experimental_standalone`. + * If true the UserButton 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_onDismiss?: () => void; + __experimental_asStandalone?: { + /** + * The controlled open state of the popover. When defined the trigger will not be rendered + * Should be used in conjunction with `__experimental_asStandalone.onOpenChanged`. + * @experimental This API is experimental and may change at any moment. + */ + open: boolean; + + /** + * Event handler called when the open state of the dialog changes. + * It only fires when used in conjunction with `__experimental_asStandalone.open`. + * @experimental This API is experimental and may change at any moment. + * @default undefined + */ + onOpenChanged: (open: boolean | ((prevState: boolean) => boolean)) => void; + }; + /** * By default, users can switch between organization and their personal account. * This option controls whether OrganizationSwitcher will include the user's personal account From 0c424926d8c898413fc381dd263898a61da3e0fd Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 5 Sep 2024 21:00:05 +0300 Subject: [PATCH 16/36] skip tests --- .../__tests__/OrganizationSwitcher.test.tsx | 4 ++-- .../ui/components/UserButton/__tests__/UserButton.test.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 2ee55c9210a..79d7b063ab9 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 @@ -124,7 +124,7 @@ describe('OrganizationSwitcher', () => { expect(getByText('Create organization')).toBeInTheDocument(); }); - it('renders organization switcher popover as standalone', async () => { + it.skip('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 }); @@ -139,7 +139,7 @@ describe('OrganizationSwitcher', () => { }); }); - it('calls onDismiss when "Manage Organization" is clicked', async () => { + it.skip('calls onDismiss when "Manage Organization" is clicked', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withOrganizations(); f.withUser({ 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 ac13dc57add..629d3cf527e 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,7 +21,7 @@ describe('UserButton', () => { expect(queryByRole('button')).not.toBeNull(); }); - it('renders popover as standalone when there is a user', async () => { + it.skip('renders popover as standalone when there is a user', async () => { const { wrapper, props } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); @@ -33,7 +33,7 @@ describe('UserButton', () => { getByText('Manage account'); }); - it('calls onDismiss when it is used as standalone', async () => { + it.skip('calls onDismiss when it is used as standalone', async () => { const { wrapper, props, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); From f3edf189708ace17598ae7b93b426da1727fd9d5 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 5 Sep 2024 21:00:25 +0300 Subject: [PATCH 17/36] update bundlewatch.config.json --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index c7986ec5694..a4aa084bf64 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "./dist/clerk.browser.js", "maxSize": "64kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "43kB" }, - { "path": "./dist/ui-common*.js", "maxSize": "85KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "85.1KB" }, { "path": "./dist/vendors*.js", "maxSize": "70KB" }, { "path": "./dist/coinbase*.js", "maxSize": "58KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, From f99c981adab319660ccd53ecc5c891037b1cb434 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 14:15:20 +0300 Subject: [PATCH 18/36] wip prefetch on provider --- packages/clerk-js/src/core/clerk.ts | 7 +++++++ packages/clerk-js/src/ui/Components.tsx | 10 ++++++++++ .../src/ui/components/prefetch-organization-list.tsx | 8 ++++++++ packages/clerk-js/src/ui/lazyModules/providers.tsx | 7 +++++++ packages/react/src/components/uiComponents.tsx | 11 +++++++++-- packages/react/src/isomorphicClerk.ts | 9 +++++++++ packages/types/src/clerk.ts | 6 ++++++ 7 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/prefetch-organization-list.tsx diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 0aa484da6a9..f6edfe7143d 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -700,6 +700,13 @@ export class Clerk implements ClerkInterface { void this.#componentControls?.ensureMounted().then(controls => controls.unmountComponent({ node })); }; + public __internal_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 e35ab93326d..bf29a412c53 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'; @@ -85,6 +86,7 @@ export type ComponentControls = { | 'createOrganization' | 'userVerification', ) => void; + prefetch: (component: 'organizationSwitcher') => void; // Special case, as the impersonation fab mounts automatically mountImpersonationFab: () => void; }; @@ -113,6 +115,7 @@ interface ComponentsState { userVerificationModal: null | __experimental_UserVerificationProps; organizationProfileModal: null | OrganizationProfileProps; createOrganizationModal: null | CreateOrganizationProps; + organizationSwitcherPrefetch: boolean; nodes: Map; impersonationFab: boolean; } @@ -190,6 +193,7 @@ const Components = (props: ComponentsProps) => { userVerificationModal: null, organizationProfileModal: null, createOrganizationModal: null, + organizationSwitcherPrefetch: false, nodes: new Map(), impersonationFab: false, }); @@ -258,6 +262,10 @@ const Components = (props: ComponentsProps) => { setState(s => ({ ...s, impersonationFab: true })); }; + componentsControls.prefetch = component => { + setState(s => ({ ...s, [`${component}Prefetch`]: true })); + }; + props.onComponentsMounted(); }, []); @@ -409,6 +417,8 @@ const Components = (props: ComponentsProps) => { )} + + {state.organizationSwitcherPrefetch && } ); 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/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 fcff7529f8a..99621b86b80 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -361,8 +361,10 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp< ); }, 'CreateOrganization'); -// @ts-ignore -const OrganizationSwitcherContext = createContext({}); +const OrganizationSwitcherContext = createContext( + // @ts-expect-error We are defining the values below + {}, +); const _OrganizationSwitcher = withClerk( ({ clerk, ...props }: WithClerkProp>) => { @@ -377,6 +379,11 @@ const _OrganizationSwitcher = withClerk( customPagesPortals: customPagesPortals, }; + /** + * Prefetch organization list + */ + clerk.__internal_prefetchOrganizationSwitcher(); + return ( // { + const callback = () => this.clerkjs?.__internal_prefetchOrganizationSwitcher(); + if (this.clerkjs && this.#loaded) { + void callback(); + } else { + this.premountMethodCalls.set('__internal_prefetchOrganizationSwitcher', callback); + } + }; + mountOrganizationList = (node: HTMLDivElement, props: OrganizationListProps): void => { if (this.clerkjs && this.#loaded) { this.clerkjs.mountOrganizationList(node, props); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index b93c05a471a..8bacbb9065b 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -348,6 +348,8 @@ export interface Clerk { */ unmountOrganizationSwitcher: (targetNode: HTMLDivElement) => void; + __internal_prefetchOrganizationSwitcher: () => void; + /** * Mount an organization list component at the target element. * @param targetNode Target to mount the OrganizationList component. @@ -1042,6 +1044,8 @@ export type UserButtonProps = UserButtonProfileMode & { */ defaultOpen?: boolean; + __experimental_asProvider?: boolean; + /** * If true the UserButton will only render the popover. * Enables developers to implement a custom dialog. @@ -1126,6 +1130,8 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & */ defaultOpen?: boolean; + __experimental_asProvider?: boolean; + /** * If true the UserButton will only render the popover. * Enables developers to implement a custom dialog. From 45509a0c688503ab513fb2728f2e9bf8839e46bd Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 15:29:14 +0300 Subject: [PATCH 19/36] OrganizationSwitcher implement asProvider and asStandalone --- .../OrganizationSwitcher.tsx | 4 +- .../react/src/components/uiComponents.tsx | 49 +++++++++++++------ packages/types/src/clerk.ts | 21 +------- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index 60661213674..e0fdd0be568 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -54,9 +54,7 @@ const _OrganizationSwitcher = () => { > {__experimental_asStandalone ? ( - __experimental_asStandalone.open ? ( - - ) : null + ) : ( diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 99621b86b80..f517f69ab86 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -65,11 +65,26 @@ type OrganizationProfileExportType = typeof _OrganizationProfile & { type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { OrganizationProfilePage: typeof OrganizationProfilePage; OrganizationProfileLink: typeof OrganizationProfileLink; - Body: () => React.JSX.Element; + /** + * The `Outlet` component can be used in conjunction with `asProvider` in order to control rendering + * of the OrganizationSwitcher 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 `Outlet` 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 => { @@ -128,7 +143,6 @@ class Portal extends React.PureComponent< const customPagesChanged = prevProps.customPages?.length !== newProps.customPages?.length; const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length; - console.log(prevProps, newProps, isDeeplyEqual(prevProps, newProps)); if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { if (this.portalRef.current) { this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); @@ -385,14 +399,6 @@ const _OrganizationSwitcher = withClerk( clerk.__internal_prefetchOrganizationSwitcher(); return ( - // - {props.children} @@ -414,15 +420,26 @@ const _OrganizationSwitcher = withClerk( 'OrganizationSwitcher', ); -export function OrganizationSwitcherOutlet() { - const props = useContext(OrganizationSwitcherContext); - return ; +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, - Body: OrganizationSwitcherOutlet, + __experimental_Outlet: OrganizationSwitcherOutlet, }); export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 8bacbb9065b..3db2a338e30 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1130,30 +1130,13 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & */ defaultOpen?: boolean; - __experimental_asProvider?: boolean; - /** - * If true the UserButton will only render the popover. + * If true, OrganizationSwitcher 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?: { - /** - * The controlled open state of the popover. When defined the trigger will not be rendered - * Should be used in conjunction with `__experimental_asStandalone.onOpenChanged`. - * @experimental This API is experimental and may change at any moment. - */ - open: boolean; - - /** - * Event handler called when the open state of the dialog changes. - * It only fires when used in conjunction with `__experimental_asStandalone.open`. - * @experimental This API is experimental and may change at any moment. - * @default undefined - */ - onOpenChanged: (open: boolean | ((prevState: boolean) => boolean)) => void; - }; + __experimental_asStandalone?: boolean; /** * By default, users can switch between organization and their personal account. From 9283c257608c2d920769869245dc3fe1d9dc36e5 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 15:56:23 +0300 Subject: [PATCH 20/36] UserButton implement asProvider and asStandalone --- .../ui/components/UserButton/UserButton.tsx | 4 +- .../ui/contexts/ClerkUIComponentsContext.tsx | 2 - .../react/src/components/uiComponents.tsx | 38 +++++++++++++++---- packages/types/src/clerk.ts | 19 +--------- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx index f079674a4d9..6e256949469 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButton.tsx @@ -52,9 +52,7 @@ const _UserButton = () => { sx={{ display: 'inline-flex' }} > {__experimental_asStandalone ? ( - __experimental_asStandalone.open ? ( - - ) : null + ) : ( diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index b54f06d0759..2c2e4413a78 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -227,8 +227,6 @@ export const useUserProfileContext = (): UserProfileContextType => { throw new Error('Clerk: useUserProfileContext called outside of the mounted UserProfile component.'); } - console.log('----- profile', customPages); - const pages = useMemo(() => { return createUserProfileCustomPages(customPages || [], clerk); }, [customPages]); diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index f517f69ab86..67531e667cc 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -50,11 +50,26 @@ type UserButtonExportType = typeof _UserButton & { MenuItems: typeof MenuItems; Action: typeof MenuAction; Link: typeof MenuLink; - Body: () => React.JSX.Element; + /** + * The `Outlet` component can be used in conjunction with `asProvider` in order to control rendering + * of the OrganizationSwitcher 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 `Outlet` component is mounted. + * @experimental This API is experimental and may change at any moment. + * @default undefined + */ + __experimental_asProvider?: boolean; }; type OrganizationProfileExportType = typeof _OrganizationProfile & { @@ -279,7 +294,7 @@ const _UserButton = withClerk( > {props.children} @@ -305,9 +320,18 @@ export function MenuLink(_: PropsWithChildren) { return null; } -export function UserButtonOutlet() { - const props = useContext(UserButtonContext); - return ; +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, { @@ -316,7 +340,7 @@ export const UserButton: UserButtonExportType = Object.assign(_UserButton, { MenuItems, Action: MenuAction, Link: MenuLink, - Body: UserButtonOutlet as () => React.JSX.Element, + __experimental_Outlet: UserButtonOutlet, }); export const __experimental_UserVerification = withClerk( diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 3db2a338e30..c520267231d 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1044,30 +1044,13 @@ export type UserButtonProps = UserButtonProfileMode & { */ defaultOpen?: boolean; - __experimental_asProvider?: boolean; - /** * If true the UserButton 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?: { - /** - * The controlled open state of the popover. When defined the trigger will not be rendered - * Should be used in conjunction with `__experimental_asStandalone.onOpenChanged`. - * @experimental This API is experimental and may change at any moment. - */ - open: boolean; - - /** - * Event handler called when the open state of the dialog changes. - * It only fires when used in conjunction with `__experimental_asStandalone.open`. - * @experimental This API is experimental and may change at any moment. - * @default undefined - */ - onOpenChanged: (open: boolean | ((prevState: boolean) => boolean)) => void; - }; + __experimental_asStandalone?: boolean; /** * Full URL or path to navigate after sign out is complete From b4903a76c65da97831b7837b67501da21b7012a3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 16:15:06 +0300 Subject: [PATCH 21/36] bump bundlewatch for clerk-js --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index e1b6a6c774d..529b3ec7757 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": "64.5kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "64.6kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "43kB" }, { "path": "./dist/ui-common*.js", "maxSize": "86KB" }, { "path": "./dist/vendors*.js", "maxSize": "70KB" }, From 0c86be3eea61a1150bf197536b05ce55c6f5e7fa Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 18:29:41 +0300 Subject: [PATCH 22/36] update e2e tests --- .../src/custom-user-button-trigger/index.tsx | 100 ++++++++++++++++++ .../src/custom-user-button/index.tsx | 2 +- .../src/custom-user-profile/index.tsx | 2 +- integration/templates/react-vite/src/main.tsx | 5 + integration/tests/custom-pages.test.ts | 68 +++++++++++- .../react/src/components/uiComponents.tsx | 48 ++++----- packages/react/src/types.ts | 7 +- 7 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 integration/templates/react-vite/src/custom-user-button-trigger/index.tsx 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..fb5d7d924a8 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('does not leak children', 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/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 722e1c17baa..f0cc6613050 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -25,6 +25,7 @@ import { userProfilePageRenderedError, } from '../errors/messages'; import type { + CustomPortalsRendererProps, MountProps, OpenProps, OrganizationProfileLinkProps, @@ -198,11 +199,7 @@ class Portal extends React.PureComponent< } } -const CustomPortalsRenderer = (props: MountProps) => { - if (!isMountProps(props)) { - return null; - } - +const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => { return ( <> {props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} @@ -252,8 +249,9 @@ const _UserProfile = withClerk( unmount={clerk.unmountUserProfile} updateProps={(clerk as any).__unstable__updateProps} props={{ ...props, customPages }} - customPagesPortals={customPagesPortals} - /> + > + +
); }, 'UserProfile', @@ -278,25 +276,21 @@ const _UserButton = withClerk( unmount: clerk.unmountUserButton, updateProps: (clerk as any).__unstable__updateProps, props: { ...props, userProfileProps, customMenuItems }, + }; + const portalProps = { customPagesPortals: customPagesPortals, customMenuItemsPortals: customMenuItemsPortals, }; return ( - + - {props.children} - + {/*This mimics the previous behaviour before asProvider existed*/} + {props.__experimental_asProvider ? props.children : null} + ); @@ -361,8 +355,9 @@ const _OrganizationProfile = withClerk( unmount={clerk.unmountOrganizationProfile} updateProps={(clerk as any).__unstable__updateProps} props={{ ...props, customPages }} - customPagesPortals={customPagesPortals} - /> + > + +
); }, 'OrganizationProfile', @@ -399,7 +394,6 @@ const _OrganizationSwitcher = withClerk( unmount: clerk.unmountOrganizationSwitcher, updateProps: (clerk as any).__unstable__updateProps, props: { ...props, organizationProfileProps }, - customPagesPortals: customPagesPortals, }; /** @@ -408,20 +402,14 @@ const _OrganizationSwitcher = withClerk( clerk.__internal_prefetchOrganizationSwitcher(); return ( - + - {props.children} - + {/*This mimics the previous behaviour before asProvider existed*/} + {props.__experimental_asProvider ? props.children : null} + ); 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 { From 0ac130132b6bb2ae3fa6dce600662037b70ba792 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 18:41:45 +0300 Subject: [PATCH 23/36] update unit tests --- .../__tests__/OrganizationSwitcher.test.tsx | 33 ++----------------- .../UserButton/__tests__/UserButton.test.tsx | 20 ++--------- .../ui/contexts/ClerkUIComponentsContext.tsx | 2 -- 3 files changed, 4 insertions(+), 51 deletions(-) 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 79d7b063ab9..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 @@ -124,13 +124,13 @@ describe('OrganizationSwitcher', () => { expect(getByText('Create organization')).toBeInTheDocument(); }); - it.skip('renders organization switcher popover as standalone', async () => { + 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_standalone: true, + __experimental_asStandalone: true, }); const { getByText, queryByRole } = render(, { wrapper }); await waitFor(() => { @@ -139,35 +139,6 @@ describe('OrganizationSwitcher', () => { }); }); - it.skip('calls onDismiss when "Manage Organization" is clicked', async () => { - const { wrapper, fixtures, props } = await createFixtures(f => { - f.withOrganizations(); - f.withUser({ - email_addresses: ['test@clerk.com'], - organization_memberships: [{ name: 'Org1', role: 'basic_member' }], - create_organization_enabled: true, - }); - }); - - const onDismiss = jest.fn(); - props.setProps({ - __experimental_standalone: true, - __experimental_onDismiss: onDismiss, - }); - - fixtures.clerk.organization?.getRoles.mockRejectedValue(null); - fixtures.clerk.user?.getOrganizationMemberships.mockResolvedValueOnce([]); - - const { getByRole, userEvent, queryByRole, getByText } = render(, { wrapper }); - await waitFor(() => { - expect(queryByRole('button', { name: 'Open organization switcher' })).toBeNull(); - expect(getByText('Personal account')).toBeInTheDocument(); - }); - await userEvent.click(getByRole('menuitem', { name: 'Create organization' })); - expect(fixtures.clerk.openCreateOrganization).toHaveBeenCalled(); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - 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/__tests__/UserButton.test.tsx b/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx index 4b9035531da..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,34 +21,18 @@ describe('UserButton', () => { expect(queryByRole('button')).not.toBeNull(); }); - it.skip('renders popover as standalone when there is a user', async () => { + 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_standalone: true, + __experimental_asStandalone: true, }); const { getByText, queryByRole } = render(, { wrapper }); expect(queryByRole('button', { name: 'Open user button' })).toBeNull(); getByText('Manage account'); }); - it.skip('calls onDismiss when it is used as standalone', async () => { - const { wrapper, props, fixtures } = await createFixtures(f => { - f.withUser({ email_addresses: ['test@clerk.com'] }); - }); - const onDismiss = jest.fn(); - props.setProps({ - __experimental_standalone: true, - __experimental_onDismiss: onDismiss, - }); - const { getByText, userEvent, queryByRole } = render(, { wrapper }); - expect(queryByRole('button', { name: 'Open user button' })).toBeNull(); - await userEvent.click(getByText('Manage account')); - expect(fixtures.clerk.openUserProfile).toHaveBeenCalled(); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - it('opens the user button popover when clicked', async () => { const { wrapper } = await createFixtures(f => { f.withUser({ diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index e26171b00e8..c9e0434b434 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -262,8 +262,6 @@ export const useUserButtonContext = () => { const { displayConfig } = useEnvironment(); const options = useOptions(); - console.log(ctx?.userProfileProps?.customPages); - if (componentName !== 'UserButton') { throw new Error('Clerk: useUserButtonContext called outside of the mounted UserButton component.'); } From 145534871b323285daf830cc23457053c9c67608 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 19:45:09 +0300 Subject: [PATCH 24/36] add jsdoc to `__experimental_prefetchOrganizationSwitcher` and sanitized children --- packages/clerk-js/src/core/clerk.ts | 2 +- .../react/src/components/uiComponents.tsx | 51 ++++++---- packages/react/src/isomorphicClerk.ts | 6 +- packages/react/src/utils/useCustomPages.tsx | 92 +++++++++++++------ packages/types/src/clerk.ts | 8 +- 5 files changed, 106 insertions(+), 53 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1be5a28897e..dc4b101985d 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -667,7 +667,7 @@ export class Clerk implements ClerkInterface { void this.#componentControls?.ensureMounted().then(controls => controls.unmountComponent({ node })); }; - public __internal_prefetchOrganizationSwitcher = () => { + public __experimental_prefetchOrganizationSwitcher = () => { this.assertComponentsReady(this.#componentControls); void this.#componentControls ?.ensureMounted({ preloadHint: 'OrganizationSwitcher' }) diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index f0cc6613050..13352a3e40f 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -36,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 & { @@ -230,14 +235,14 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp ); }, 'SignUp'); -export function UserProfilePage(_: PropsWithChildren) { +export function UserProfilePage({ children }: PropsWithChildren) { logErrorInDevMode(userProfilePageRenderedError); - return null; + return <>{children}; } -export function UserProfileLink(_: PropsWithChildren) { +export function UserProfileLink({ children }: PropsWithChildren) { logErrorInDevMode(userProfileLinkRenderedError); - return null; + return <>{children}; } const _UserProfile = withClerk( @@ -267,9 +272,12 @@ const UserButtonContext = createContext({}); 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, @@ -289,7 +297,7 @@ const _UserButton = withClerk( hideRootHtmlElement={!!props.__experimental_asProvider} > {/*This mimics the previous behaviour before asProvider existed*/} - {props.__experimental_asProvider ? props.children : null} + {props.__experimental_asProvider ? sanitizedChildren : null}
@@ -298,19 +306,19 @@ const _UserButton = withClerk( 'UserButton', ); -export function MenuItems(_: PropsWithChildren) { +export function MenuItems({ children }: PropsWithChildren) { logErrorInDevMode(userButtonMenuItemsRenderedError); - return null; + return <>{children}; } -export function MenuAction(_: PropsWithChildren) { +export function MenuAction({ children }: PropsWithChildren) { logErrorInDevMode(userButtonMenuActionRenderedError); - return null; + return <>{children}; } -export function MenuLink(_: PropsWithChildren) { +export function MenuLink({ children }: PropsWithChildren) { logErrorInDevMode(userButtonMenuLinkRenderedError); - return null; + return <>{children}; } export function UserButtonOutlet(outletProps: Without) { @@ -336,14 +344,14 @@ export const UserButton: UserButtonExportType = Object.assign(_UserButton, { __experimental_Outlet: UserButtonOutlet, }); -export function OrganizationProfilePage(_: PropsWithChildren) { +export function OrganizationProfilePage({ children }: PropsWithChildren) { logErrorInDevMode(organizationProfilePageRenderedError); - return null; + return <>{children}; } -export function OrganizationProfileLink(_: PropsWithChildren) { +export function OrganizationProfileLink({ children }: PropsWithChildren) { logErrorInDevMode(organizationProfileLinkRenderedError); - return null; + return <>{children}; } const _OrganizationProfile = withClerk( @@ -386,8 +394,11 @@ const OrganizationSwitcherContext = createContext( 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, @@ -399,7 +410,7 @@ const _OrganizationSwitcher = withClerk( /** * Prefetch organization list */ - clerk.__internal_prefetchOrganizationSwitcher(); + clerk.__experimental_prefetchOrganizationSwitcher(); return ( @@ -408,7 +419,7 @@ const _OrganizationSwitcher = withClerk( hideRootHtmlElement={!!props.__experimental_asProvider} > {/*This mimics the previous behaviour before asProvider existed*/} - {props.__experimental_asProvider ? props.children : null} + {props.__experimental_asProvider ? sanitizedChildren : null}
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index cba1b240571..3a94a521f9a 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -841,12 +841,12 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __internal_prefetchOrganizationSwitcher = (): void => { - const callback = () => this.clerkjs?.__internal_prefetchOrganizationSwitcher(); + __experimental_prefetchOrganizationSwitcher = (): void => { + const callback = () => this.clerkjs?.__experimental_prefetchOrganizationSwitcher(); if (this.clerkjs && this.#loaded) { void callback(); } else { - this.premountMethodCalls.set('__internal_prefetchOrganizationSwitcher', callback); + this.premountMethodCalls.set('__experimental_prefetchOrganizationSwitcher', callback); } }; diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx index 140a21c5751..dd541152807 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -10,33 +10,45 @@ import { UserProfileLink, UserProfilePage, } from '../components/uiComponents'; -import { customLinkWrongProps, customPageWrongProps } from '../errors/messages'; +import { customLinkWrongProps, customPagesIgnoredComponent, customPageWrongProps } from '../errors/messages'; import type { UserProfilePageProps } from '../types'; 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,40 @@ type UseCustomPagesParams = { componentName: string; }; +type UseCustomPagesOptions = { + allowForAnyChildren: boolean; +}; + type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode }; -const useCustomPages = ({ - children, - LinkComponent, - PageComponent, - MenuItemsComponent, - reorderItemsLabels, - componentName, -}: UseCustomPagesParams) => { +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)) + // !isThatComponent(child, PageComponent) && + // !isThatComponent(child, LinkComponent) && + // !isThatComponent(child, MenuItemsComponent) + ) { + 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,8 +102,8 @@ const useCustomPages = ({ !isThatComponent(child, LinkComponent) && !isThatComponent(child, MenuItemsComponent) ) { - if (child) { - // logErrorInDevMode(customPagesIgnoredComponent(componentName)); + if (child && !allowForAnyChildren) { + logErrorInDevMode(customPagesIgnoredComponent(componentName)); } return; } diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 98ac89d8ed8..83f9e7135cb 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -329,7 +329,13 @@ export interface Clerk { */ unmountOrganizationSwitcher: (targetNode: HTMLDivElement) => void; - __internal_prefetchOrganizationSwitcher: () => 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. From b8f2f7e120c519e6ee6fbc83399e9a4a8dfa6ab8 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 19:56:51 +0300 Subject: [PATCH 25/36] update bundlewatch.config.json --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 529b3ec7757..6993b33629c 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": "64.6kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "64.7kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "43kB" }, { "path": "./dist/ui-common*.js", "maxSize": "86KB" }, { "path": "./dist/vendors*.js", "maxSize": "70KB" }, From 131e99cc10207f0293207a3d51dc35edee37f326 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 20:12:32 +0300 Subject: [PATCH 26/36] cleanup --- packages/react/src/utils/useCustomMenuItems.tsx | 3 ++- packages/react/src/utils/useCustomPages.tsx | 7 +------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/react/src/utils/useCustomMenuItems.tsx b/packages/react/src/utils/useCustomMenuItems.tsx index 30b8a741c8d..239a1e0bbf8 100644 --- a/packages/react/src/utils/useCustomMenuItems.tsx +++ b/packages/react/src/utils/useCustomMenuItems.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { MenuAction, MenuItems, MenuLink, UserProfileLink, UserProfilePage } from '../components/uiComponents'; import { customMenuItemsIgnoredComponent, + userButtonIgnoredComponent, userButtonMenuItemLinkWrongProps, userButtonMenuItemsActionWrongsProps, } from '../errors/messages'; @@ -59,7 +60,7 @@ const useCustomMenuItems = ({ !isThatComponent(child, UserProfilePageComponent) ) { if (child) { - // logErrorInDevMode(userButtonIgnoredComponent); + logErrorInDevMode(userButtonIgnoredComponent); } return; } diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx index dd541152807..d50357adaf5 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -78,12 +78,7 @@ export const useSanitizedChildren = (children: React.ReactNode) => { ]; React.Children.forEach(children, child => { - if ( - !excludedComponents.some(component => isThatComponent(child, component)) - // !isThatComponent(child, PageComponent) && - // !isThatComponent(child, LinkComponent) && - // !isThatComponent(child, MenuItemsComponent) - ) { + if (!excludedComponents.some(component => isThatComponent(child, component))) { sanitizedChildren.push(child); } }); From 727ffedc670660c580971ccf209772ecb3b86244 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 20:15:45 +0300 Subject: [PATCH 27/36] bring back pages --- playground/app-router/src/app/page.tsx | 100 ++++++++++++++++++ .../src/pages/user/[[...index]].tsx | 25 +++++ 2 files changed, 125 insertions(+) create mode 100644 playground/app-router/src/app/page.tsx create mode 100644 playground/app-router/src/pages/user/[[...index]].tsx diff --git a/playground/app-router/src/app/page.tsx b/playground/app-router/src/app/page.tsx new file mode 100644 index 00000000000..2df822ec601 --- /dev/null +++ b/playground/app-router/src/app/page.tsx @@ -0,0 +1,100 @@ +import Image from 'next/image'; +import styles from './page.module.css'; + +declare global { + interface UserPublicMetadata { + spotifyToken?: string; + isNewUser?: boolean; + } +} + +export default function Home() { + return ( +
+
+

+ Get started by editing  + src/app/page.tsx +

+ +
+ +
+ Next.js Logo +
+ + +
+ ); +} diff --git a/playground/app-router/src/pages/user/[[...index]].tsx b/playground/app-router/src/pages/user/[[...index]].tsx new file mode 100644 index 00000000000..965be25b361 --- /dev/null +++ b/playground/app-router/src/pages/user/[[...index]].tsx @@ -0,0 +1,25 @@ +import { SignedIn, UserProfile } from '@clerk/nextjs'; +import { getAuth } from '@clerk/nextjs/server'; +import type { GetServerSideProps, NextPage } from 'next'; +import React from 'react'; + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const { userId } = getAuth(req); + console.log(userId); + return { props: { message: 'hello from server' } }; +}; + +const UserProfilePage: NextPage = (props: any) => { + return ( +
+

/pages/user

+
{props.message}
+ +

SignedIn

+
+ +
+ ); +}; + +export default UserProfilePage; \ No newline at end of file From 2f940fd9dcccee6aa1cfc23645fcb550f10eaad5 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 14 Oct 2024 20:26:33 +0300 Subject: [PATCH 28/36] revert necessary changes --- .../ui/components/UserButton/UserButtonPopover.tsx | 12 +++--------- packages/clerk-js/src/ui/hooks/usePopover.ts | 11 +---------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx index 368c1302c79..ceb529e1d6a 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx @@ -9,13 +9,11 @@ import type { PropsOfComponent } from '../../styledSystem'; import { MultiSessionActions, SignOutAllActions, SingleSessionActions } from './SessionActions'; import { useMultisessionActions } from './useMultisessionActions'; -type UserButtonPopoverProps = { - close?: (open: boolean | ((prevState: boolean) => boolean)) => void; -} & PropsOfComponent; +type UserButtonPopoverProps = { close?: () => void } & PropsOfComponent; export const UserButtonPopover = React.forwardRef((props, ref) => { const { close: optionalClose, ...rest } = props; - const close = () => optionalClose?.(false); + const close = () => optionalClose?.(); const { session } = useSession() as { session: ActiveSessionResource }; const { authConfig } = useEnvironment(); const { user } = useUser(); @@ -27,11 +25,7 @@ export const UserButtonPopover = React.forwardRef diff --git a/packages/clerk-js/src/ui/hooks/usePopover.ts b/packages/clerk-js/src/ui/hooks/usePopover.ts index 5cf7ce4e4d3..662556cd305 100644 --- a/packages/clerk-js/src/ui/hooks/usePopover.ts +++ b/packages/clerk-js/src/ui/hooks/usePopover.ts @@ -4,8 +4,6 @@ import React, { useEffect } from 'react'; type UsePopoverProps = { defaultOpen?: boolean; - open?: boolean; - onOpenChanged?: (open: boolean | ((prevState: boolean) => boolean)) => void; placement?: UseFloatingOptions['placement']; offset?: Parameters[0]; shoudFlip?: boolean; @@ -25,16 +23,9 @@ export type UsePopoverReturn = ReturnType; export const usePopover = (props: UsePopoverProps = {}) => { const { bubbles = false, shoudFlip = true, outsidePress, adjustToReferenceWidth = false, referenceElement } = props; - const [isOpen_internal, setIsOpen_internal] = React.useState(props.defaultOpen || false); - - const isOpen = typeof props.open === 'undefined' ? isOpen_internal : props.open; - const setIsOpen = typeof props.onOpenChanged === 'undefined' ? setIsOpen_internal : props.onOpenChanged; + const [isOpen, setIsOpen] = React.useState(props.defaultOpen || false); const nodeId = useFloatingNodeId(); - if (typeof props.defaultOpen !== 'undefined' && typeof props.open !== 'undefined') { - console.warn('Both defaultOpen and open are set. `defaultOpen` will be ignored'); - } - const { update, refs, strategy, x, y, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, From 25168154f39e1a94b8f91a56007e8b707f8d5fe6 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 15 Oct 2024 10:10:05 +0300 Subject: [PATCH 29/36] Apply suggestions from code review Co-authored-by: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> --- integration/tests/custom-pages.test.ts | 2 +- packages/clerk-js/src/ui/hooks/usePopover.ts | 1 - packages/react/src/components/uiComponents.tsx | 13 ++++++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts index fb5d7d924a8..f7621bb9ffb 100644 --- a/integration/tests/custom-pages.test.ts +++ b/integration/tests/custom-pages.test.ts @@ -112,7 +112,7 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })( await expect(u.page.locator('p[data-page="1"]')).toHaveText('Counter: 1'); }); - test('does not leak children', async ({ page, context }) => { + 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(); diff --git a/packages/clerk-js/src/ui/hooks/usePopover.ts b/packages/clerk-js/src/ui/hooks/usePopover.ts index 662556cd305..21f65a2b7d9 100644 --- a/packages/clerk-js/src/ui/hooks/usePopover.ts +++ b/packages/clerk-js/src/ui/hooks/usePopover.ts @@ -25,7 +25,6 @@ export const usePopover = (props: UsePopoverProps = {}) => { const { bubbles = false, shoudFlip = true, outsidePress, adjustToReferenceWidth = false, referenceElement } = props; const [isOpen, setIsOpen] = React.useState(props.defaultOpen || false); const nodeId = useFloatingNodeId(); - const { update, refs, strategy, x, y, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 13352a3e40f..9f24d397380 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -268,7 +268,11 @@ export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { }); // @ts-ignore -const UserButtonContext = createContext({}); +const UserButtonContext = createContext({ + mount: () => {}, + unmount: () => {}, + updateProps: () => {}, +}); const _UserButton = withClerk( ({ clerk, ...props }: WithClerkProp>) => { @@ -388,8 +392,11 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp< }, 'CreateOrganization'); const OrganizationSwitcherContext = createContext( - // @ts-expect-error We are defining the values below - {}, + { + mount: () => {}, + unmount: () => {}, + updateProps: () => {}, + }, ); const _OrganizationSwitcher = withClerk( From 237af16fc92c96c9056b15142b60faa3c549705d Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 15 Oct 2024 10:25:30 +0300 Subject: [PATCH 30/36] update changeset --- .changeset/shaggy-kids-fail.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/shaggy-kids-fail.md b/.changeset/shaggy-kids-fail.md index d24776a1cc7..c5f034a8f67 100644 --- a/.changeset/shaggy-kids-fail.md +++ b/.changeset/shaggy-kids-fail.md @@ -3,5 +3,10 @@ '@clerk/types': minor --- -Add experimental support for a standalone mode for UserButton and OrganizationSwitcher. -When `__experimental_standalone: true` the component will not render its trigger and instead it will render only the contents of the popover in place. +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 })` From 957661cb599d5cb6a6d295cf5e34dd665d6c1035 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 15 Oct 2024 10:30:55 +0300 Subject: [PATCH 31/36] add changeset for react --- .changeset/clean-mugs-wave.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/clean-mugs-wave.md diff --git a/.changeset/clean-mugs-wave.md b/.changeset/clean-mugs-wave.md new file mode 100644 index 00000000000..c4d26841591 --- /dev/null +++ b/.changeset/clean-mugs-wave.md @@ -0,0 +1,26 @@ +--- +"@clerk/clerk-react": minor +--- + +Introducing experimental `asProvider`, `asStandalone` and `` for UserButton and OrganizationSwitcher. +- `asProvider` converts UserButton and OrganizationSwitcher to a provider that defers rendering until `Outlet` is mounted. +- `Outlet` also accepts `asStandalone` which will skip the trigger of these components and display only the UI of 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

+
+ +
+``` From 7b7f8d588233cf0d9c8f88b9ca3d99e7135dddda Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 15 Oct 2024 10:45:33 +0300 Subject: [PATCH 32/36] fix formatting --- packages/react/src/components/uiComponents.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 9f24d397380..bc02da7f8cb 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -391,13 +391,11 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp< ); }, 'CreateOrganization'); -const OrganizationSwitcherContext = createContext( - { - mount: () => {}, - unmount: () => {}, - updateProps: () => {}, - }, -); +const OrganizationSwitcherContext = createContext({ + mount: () => {}, + unmount: () => {}, + updateProps: () => {}, +}); const _OrganizationSwitcher = withClerk( ({ clerk, ...props }: WithClerkProp>) => { From 3ef3bb5e92a243050d58a32b7894101e9a5d9ae7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 15 Oct 2024 15:55:39 +0300 Subject: [PATCH 33/36] Apply suggestions from code review Co-authored-by: Lennart --- .changeset/clean-mugs-wave.md | 6 +++--- .changeset/shaggy-kids-fail.md | 8 ++++---- packages/react/src/components/uiComponents.tsx | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.changeset/clean-mugs-wave.md b/.changeset/clean-mugs-wave.md index c4d26841591..12f8f71c150 100644 --- a/.changeset/clean-mugs-wave.md +++ b/.changeset/clean-mugs-wave.md @@ -2,9 +2,9 @@ "@clerk/clerk-react": minor --- -Introducing experimental `asProvider`, `asStandalone` and `` for UserButton and OrganizationSwitcher. -- `asProvider` converts UserButton and OrganizationSwitcher to a provider that defers rendering until `Outlet` is mounted. -- `Outlet` also accepts `asStandalone` which will skip the trigger of these components and display only the UI of was previously inside the popover. This allows developers to create their own triggers. +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 diff --git a/.changeset/shaggy-kids-fail.md b/.changeset/shaggy-kids-fail.md index c5f034a8f67..31ee5c991d3 100644 --- a/.changeset/shaggy-kids-fail.md +++ b/.changeset/shaggy-kids-fail.md @@ -3,10 +3,10 @@ '@clerk/types': minor --- -Add experimental standalone mode for UserButton and OrganizationSwitcher. +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 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 })` +- (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/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index bc02da7f8cb..b6bff376cd3 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -56,8 +56,8 @@ type UserButtonExportType = typeof _UserButton & { Action: typeof MenuAction; Link: typeof MenuLink; /** - * The `Outlet` component can be used in conjunction with `asProvider` in order to control rendering - * of the OrganizationSwitcher without affecting its configuration or any custom pages + * 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. */ @@ -70,7 +70,7 @@ type UserButtonPropsWithoutCustomPages = Without< > & { userProfileProps?: Pick; /** - * Adding `asProvider` will defer rendering until the `Outlet` component is mounted. + * Adding `asProvider` will defer rendering until the `` component is mounted. * @experimental This API is experimental and may change at any moment. * @default undefined */ @@ -86,8 +86,8 @@ type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { OrganizationProfilePage: typeof OrganizationProfilePage; OrganizationProfileLink: typeof OrganizationProfileLink; /** - * The `Outlet` component can be used in conjunction with `asProvider` in order to control rendering - * of the OrganizationSwitcher without affecting its configuration or any custom pages + * 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. */ @@ -100,7 +100,7 @@ type OrganizationSwitcherPropsWithoutCustomPages = Without< > & { organizationProfileProps?: Pick; /** - * Adding `asProvider` will defer rendering until the `Outlet` component is mounted. + * Adding `asProvider` will defer rendering until the `` component is mounted. * @experimental This API is experimental and may change at any moment. * @default undefined */ From a84b075db2a0338d1e52135a84ee19780ca6db52 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 15 Oct 2024 16:04:56 +0300 Subject: [PATCH 34/36] address review comments --- .changeset/clean-mugs-wave.md | 8 ++++---- .../OrganizationSwitcherPopover.tsx | 4 ++-- .../ui/components/UserButton/UserButtonPopover.tsx | 4 ++-- packages/react/src/components/uiComponents.tsx | 1 - packages/react/src/utils/useCustomPages.tsx | 14 ++++++++++++++ packages/types/src/clerk.ts | 4 ++-- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.changeset/clean-mugs-wave.md b/.changeset/clean-mugs-wave.md index 12f8f71c150..1b73dbeeb5c 100644 --- a/.changeset/clean-mugs-wave.md +++ b/.changeset/clean-mugs-wave.md @@ -8,19 +8,19 @@ Introducing experimental `asProvider`, `asStandalone`, and `` for `< Example usage: ```tsx - +

This is my page available to all children

- +
``` ```tsx - +

This is my page available to all children

- +
``` diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 7b9f8c8ba7e..1b24d09904b 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -26,8 +26,8 @@ type OrganizationSwitcherPopoverProps = { export const OrganizationSwitcherPopover = React.forwardRef( (props, ref) => { - const { close: undefinedClose, ...rest } = props; - const close = () => undefinedClose?.(false); + const { close: unsafeClose, ...rest } = props; + const close = () => unsafeClose?.(false); const card = useCardState(); const { openOrganizationProfile, openCreateOrganization } = useClerk(); const { organization: currentOrg } = useOrganization(); diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx index ceb529e1d6a..19f6ef34633 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx @@ -12,8 +12,8 @@ import { useMultisessionActions } from './useMultisessionActions'; type UserButtonPopoverProps = { close?: () => void } & PropsOfComponent; export const UserButtonPopover = React.forwardRef((props, ref) => { - const { close: optionalClose, ...rest } = props; - const close = () => optionalClose?.(); + const { close: unsafeClose, ...rest } = props; + const close = () => unsafeClose?.(); const { session } = useSession() as { session: ActiveSessionResource }; const { authConfig } = useEnvironment(); const { user } = useUser(); diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index b6bff376cd3..d4c589ced15 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -267,7 +267,6 @@ export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { Link: UserProfileLink, }); -// @ts-ignore const UserButtonContext = createContext({ mount: () => {}, unmount: () => {}, diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx index d50357adaf5..c3367d1cc7b 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -66,6 +66,20 @@ type UseCustomPagesOptions = { type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode }; +/** + * 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[] = []; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 83f9e7135cb..7f7e11cd139 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1045,7 +1045,7 @@ export type UserButtonProps = UserButtonProfileMode & { defaultOpen?: boolean; /** - * If true the UserButton will only render the popover. + * 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 @@ -1114,7 +1114,7 @@ export type OrganizationSwitcherProps = CreateOrganizationMode & defaultOpen?: boolean; /** - * If true, OrganizationSwitcher will only render the popover. + * 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 From 1d67a4520a485f7480616603c4d3b4f7dfe203e3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 15 Oct 2024 16:26:29 +0300 Subject: [PATCH 35/36] update bundlewatch.config.json --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 6993b33629c..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": "64.7kB" }, + { "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" }, From d4b8a123735a1d94f97e65f143c91bf9e0e0abe3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 16 Oct 2024 12:33:28 +0300 Subject: [PATCH 36/36] when popover with asStandalone avoid entry animations --- .../OrganizationSwitcherPopover.tsx | 2 ++ .../UserButton/UserButtonPopover.tsx | 2 ++ .../clerk-js/src/ui/elements/PopoverCard.tsx | 36 +++++++++++++------ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 1b24d09904b..1fbb9436e91 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -29,6 +29,7 @@ export const OrganizationSwitcherPopover = React.forwardRef unsafeClose?.(false); const card = useCardState(); + const { __experimental_asStandalone } = useOrganizationSwitcherContext(); const { openOrganizationProfile, openCreateOrganization } = useClerk(); const { organization: currentOrg } = useOrganization(); const { isLoaded, setActive } = useOrganizationList(); @@ -194,6 +195,7 @@ export const OrganizationSwitcherPopover = React.forwardRef diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx index 19f6ef34633..114a6ed8b8e 100644 --- a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx @@ -15,6 +15,7 @@ export const UserButtonPopover = React.forwardRef unsafeClose?.(); const { session } = useSession() as { session: ActiveSessionResource }; + const { __experimental_asStandalone } = useUserButtonContext(); const { authConfig } = useEnvironment(); const { user } = useUser(); const { @@ -34,6 +35,7 @@ export const UserButtonPopover = React.forwardRef 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}