diff --git a/.changeset/wet-phones-camp.md b/.changeset/wet-phones-camp.md new file mode 100644 index 00000000000..a8fc88272a7 --- /dev/null +++ b/.changeset/wet-phones-camp.md @@ -0,0 +1,3 @@ +--- +'@clerk/shared': patch +--- diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 6f3be02ea95..c014547fe25 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -484,6 +484,7 @@ const Components = (props: ComponentsProps) => { base: '/user', path: userProfileModal?.__experimental_startPath || urlStateParam?.path, })} + getContainer={userProfileModal?.getContainer} componentName={'UserProfileModal'} modalContainerSx={{ alignItems: 'center' }} modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} diff --git a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx index cb6e110363c..434576df47b 100644 --- a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx @@ -1,4 +1,4 @@ -import { useClerk } from '@clerk/shared/react'; +import { useClerk, usePortalRoot } from '@clerk/shared/react'; import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types'; import { navigateIfTaskExists } from '@/core/sessionTasks'; @@ -27,6 +27,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user }); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); + const getContainer = usePortalRoot(); const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { if (otherSessions.length === 0) { @@ -46,7 +47,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { })(); }); } - openUserProfile(opts.userProfileProps); + openUserProfile({ getContainer, ...opts.userProfileProps }); return opts.actionCompleteCallback?.(); }; @@ -60,6 +61,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }); } openUserProfile({ + getContainer, ...opts.userProfileProps, ...(__experimental_startPath && { __experimental_startPath }), }); diff --git a/packages/clerk-js/src/ui/elements/Modal.tsx b/packages/clerk-js/src/ui/elements/Modal.tsx index 46934f2a5d8..588db619795 100644 --- a/packages/clerk-js/src/ui/elements/Modal.tsx +++ b/packages/clerk-js/src/ui/elements/Modal.tsx @@ -1,4 +1,4 @@ -import { createContextAndHook, useSafeLayoutEffect } from '@clerk/shared/react'; +import { createContextAndHook, usePortalRoot, useSafeLayoutEffect } from '@clerk/shared/react'; import React, { useRef } from 'react'; import { descriptors, Flex } from '../customizables'; @@ -27,6 +27,7 @@ export const Modal = withFloatingTree((props: ModalProps) => { const { disableScrollLock, enableScrollLock } = useScrollLock(); const { handleClose, handleOpen, contentSx, containerSx, canCloseModal, id, style, portalRoot, initialFocusRef } = props; + const portalRootFromContext = usePortalRoot(); const overlayRef = useRef(null); const { floating, isOpen, context, nodeId, toggle } = usePopover({ defaultOpen: true, @@ -52,13 +53,15 @@ export const Modal = withFloatingTree((props: ModalProps) => { }; }, []); + const effectivePortalRoot = portalRoot ?? portalRootFromContext?.() ?? undefined; + return ( diff --git a/packages/clerk-js/src/ui/elements/Popover.tsx b/packages/clerk-js/src/ui/elements/Popover.tsx index 826adc7860f..52fc4656027 100644 --- a/packages/clerk-js/src/ui/elements/Popover.tsx +++ b/packages/clerk-js/src/ui/elements/Popover.tsx @@ -1,3 +1,4 @@ +import { usePortalRoot } from '@clerk/shared/react'; import type { FloatingContext, ReferenceType } from '@floating-ui/react'; import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react'; import type { PropsWithChildren } from 'react'; @@ -35,10 +36,17 @@ export const Popover = (props: PopoverProps) => { children, } = props; + const portalRoot = usePortalRoot(); + const effectiveRoot = root ?? portalRoot?.() ?? undefined; + + console.log('effectiveRoot', effectiveRoot); + console.log('portalRoot', portalRoot); + console.log('root', root); + if (portal) { return ( - + {isOpen && ( { appearanceKey={props.appearanceKey} appearance={props.componentAppearance} > - - > - } - props={props.componentProps} - componentName={props.componentName} - /> + + + > + } + props={props.componentProps} + componentName={props.componentName} + /> + ); }; @@ -103,6 +106,7 @@ type LazyModalRendererProps = React.PropsWithChildren< canCloseModal?: boolean; modalId?: string; modalStyle?: React.CSSProperties; + getContainer: () => HTMLElement | null; } & AppearanceProviderProps >; @@ -116,27 +120,29 @@ export const LazyModalRenderer = (props: LazyModalRendererProps) => { > - - {props.startPath ? ( - - - {props.children} - - - ) : ( - props.children - )} - + + + {props.startPath ? ( + + + {props.children} + + + ) : ( + props.children + )} + + diff --git a/packages/nextjs/src/client-boundary/controlComponents.ts b/packages/nextjs/src/client-boundary/controlComponents.ts index 5398de807fc..34fc73c5360 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -15,6 +15,7 @@ export { AuthenticateWithRedirectCallback, RedirectToCreateOrganization, RedirectToOrganizationProfile, + PortalProvider, } from '@clerk/clerk-react'; export { MultisessionAppSupport } from '@clerk/clerk-react/internal'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 2e29bcd7568..43f78a6e9c9 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -8,6 +8,7 @@ export { ClerkFailed, ClerkLoaded, ClerkLoading, + PortalProvider, RedirectToCreateOrganization, RedirectToOrganizationProfile, RedirectToSignIn, diff --git a/packages/react/src/components/withClerk.tsx b/packages/react/src/components/withClerk.tsx index 70ace96b3af..b8eee8d44bc 100644 --- a/packages/react/src/components/withClerk.tsx +++ b/packages/react/src/components/withClerk.tsx @@ -1,3 +1,4 @@ +import { usePortalRoot } from '@clerk/shared/react'; import type { LoadedClerk, Without } from '@clerk/shared/types'; import React from 'react'; @@ -19,6 +20,7 @@ export const withClerk =

( useAssertWrappedByClerkProvider(displayName || 'withClerk'); const clerk = useIsomorphicClerkContext(); + const getContainer = usePortalRoot(); if (!clerk.loaded && !options?.renderWhileLoading) { return null; @@ -26,6 +28,7 @@ export const withClerk =

( return ( HTMLElement | null; +}>; + +const [PortalContext, , usePortalContextWithoutGuarantee] = createContextAndHook<{ + getContainer: () => HTMLElement | null; +}>('PortalProvider'); + +/** + * PortalProvider allows you to specify a custom container for all Clerk floating UI elements + * (popovers, modals, tooltips, etc.) that use portals. + * + * This is particularly useful when using Clerk components inside external UI libraries + * like Radix Dialog or React Aria Components, where portaled elements need to render + * within the dialog's container to remain interactable. + * + * @example + * ```tsx + * function Example() { + * const containerRef = useRef(null); + * return ( + * + * containerRef.current}> + * + * + * + * ); + * } + * ``` + */ +export const PortalProvider = ({ children, getContainer }: PortalProviderProps) => { + const getContainerRef = useRef(getContainer); + getContainerRef.current = getContainer; + + // Register with the manager for cross-tree access (e.g., modals in Components.tsx) + useEffect(() => { + const getContainerWrapper = () => getContainerRef.current(); + portalRootManager.push(getContainerWrapper); + return () => { + portalRootManager.pop(); + }; + }, []); + + // Provide context for same-tree access (e.g., UserButton popover) + const contextValue = React.useMemo(() => ({ value: { getContainer } }), [getContainer]); + + return {children}; +}; + +/** + * Hook to get the current portal root container. + * First checks React context (for same-tree components), + * then falls back to PortalRootManager (for cross-tree like modals). + */ +export const usePortalRoot = (): (() => HTMLElement | null) => { + // Try to get from context first (for components in the same React tree) + const contextValue = usePortalContextWithoutGuarantee(); + + if (contextValue && 'getContainer' in contextValue && contextValue.getContainer) { + return contextValue.getContainer; + } + + // Fall back to manager (for components in different React trees, like modals) + return portalRootManager.getCurrent.bind(portalRootManager); +}; diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index b05c94db6bd..cdf195d9fe8 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -20,3 +20,5 @@ export { } from './contexts'; export * from './billing/payment-element'; + +export { PortalProvider, usePortalRoot } from './PortalProvider'; diff --git a/packages/shared/src/react/portal-root-manager.ts b/packages/shared/src/react/portal-root-manager.ts new file mode 100644 index 00000000000..eb371adfc18 --- /dev/null +++ b/packages/shared/src/react/portal-root-manager.ts @@ -0,0 +1,37 @@ +/** + * PortalRootManager manages a stack of portal root containers. + * This allows PortalProvider to work across separate React trees + * (e.g., when Clerk modals are rendered in a different tree via Components.tsx). + */ +class PortalRootManager { + private stack: Array<() => HTMLElement | null> = []; + + /** + * Push a new portal root getter onto the stack. + * @param getContainer Function that returns the container element + */ + push(getContainer: () => HTMLElement | null): void { + this.stack.push(getContainer); + } + + /** + * Pop the most recent portal root from the stack. + */ + pop(): void { + this.stack.pop(); + } + + /** + * Get the current (topmost) portal root container. + * @returns The container element or null if no provider is active + */ + getCurrent(): HTMLElement | null { + if (this.stack.length === 0) { + return null; + } + const getContainer = this.stack[this.stack.length - 1]; + return getContainer(); + } +} + +export const portalRootManager = new PortalRootManager();