From 71ff27a06d5d88f3e1423d08b95dc9551b594f92 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Nov 2025 11:48:16 -0500 Subject: [PATCH 1/5] init --- packages/clerk-js/src/ui/elements/Modal.tsx | 7 +- packages/clerk-js/src/ui/elements/Popover.tsx | 6 +- .../src/client-boundary/controlComponents.ts | 1 + packages/nextjs/src/index.ts | 1 + packages/react/src/contexts/index.ts | 1 + packages/shared/src/react/PortalProvider.tsx | 76 +++++++++++++++++++ packages/shared/src/react/index.ts | 2 + .../shared/src/react/portal-root-manager.ts | 37 +++++++++ 8 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/react/PortalProvider.tsx create mode 100644 packages/shared/src/react/portal-root-manager.ts diff --git a/packages/clerk-js/src/ui/elements/Modal.tsx b/packages/clerk-js/src/ui/elements/Modal.tsx index 46934f2a5d8..dc7d0ef0521 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, useSafeLayoutEffect, usePortalRoot } 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..1c8233b5e94 100644 --- a/packages/clerk-js/src/ui/elements/Popover.tsx +++ b/packages/clerk-js/src/ui/elements/Popover.tsx @@ -1,5 +1,6 @@ import type { FloatingContext, ReferenceType } from '@floating-ui/react'; import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react'; +import { usePortalRoot } from '@clerk/shared/react'; import type { PropsWithChildren } from 'react'; import React from 'react'; @@ -35,10 +36,13 @@ export const Popover = (props: PopoverProps) => { children, } = props; + const portalRoot = usePortalRoot(); + const effectiveRoot = root ?? portalRoot ?? undefined; + if (portal) { return ( - + {isOpen && ( 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) { + return contextValue.getContainer(); + } + + // Fall back to manager (for components in different React trees, like modals) + return portalRootManager.getCurrent(); +}; 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(); From 922010b46cc99ee499f96942e939cc5e804dec82 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Nov 2025 11:58:37 -0500 Subject: [PATCH 2/5] Create wet-phones-camp.md --- .changeset/wet-phones-camp.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/wet-phones-camp.md diff --git a/.changeset/wet-phones-camp.md b/.changeset/wet-phones-camp.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wet-phones-camp.md @@ -0,0 +1,2 @@ +--- +--- From 3d5f3bdb0fe6c295ee205bbe529092f4a7bd3dd4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Nov 2025 12:58:38 -0500 Subject: [PATCH 3/5] Apply suggestion from @alexcarpenter --- .changeset/wet-phones-camp.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/wet-phones-camp.md b/.changeset/wet-phones-camp.md index a845151cc84..a8fc88272a7 100644 --- a/.changeset/wet-phones-camp.md +++ b/.changeset/wet-phones-camp.md @@ -1,2 +1,3 @@ --- +'@clerk/shared': patch --- From e9b66d5fa818fd8fcbeb53ef2ae89bf5838d8295 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Nov 2025 13:40:42 -0500 Subject: [PATCH 4/5] wip --- .../clerk-js/src/ui/lazyModules/providers.tsx | 23 +++++++++++-------- packages/react/src/components/withClerk.tsx | 3 +++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/src/ui/lazyModules/providers.tsx b/packages/clerk-js/src/ui/lazyModules/providers.tsx index 827d869a650..6922f3f8f99 100644 --- a/packages/clerk-js/src/ui/lazyModules/providers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx @@ -1,4 +1,5 @@ import { deprecated } from '@clerk/shared/deprecated'; +import { PortalProvider } from '@clerk/shared/react'; import type { Appearance } from '@clerk/shared/types'; import React, { lazy, Suspense } from 'react'; @@ -75,16 +76,18 @@ export const LazyComponentRenderer = (props: LazyComponentRendererProps) => { appearanceKey={props.appearanceKey} appearance={props.componentAppearance} > - - > - } - props={props.componentProps} - componentName={props.componentName} - /> + props?.componentProps?.portalRoot}> + + > + } + props={props.componentProps} + componentName={props.componentName} + /> + ); }; diff --git a/packages/react/src/components/withClerk.tsx b/packages/react/src/components/withClerk.tsx index 70ace96b3af..1e4c1529f2c 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 portalRoot = usePortalRoot(); if (!clerk.loaded && !options?.renderWhileLoading) { return null; @@ -26,6 +28,7 @@ export const withClerk =

( return ( Date: Mon, 1 Dec 2025 13:02:22 -0500 Subject: [PATCH 5/5] wip --- packages/clerk-js/src/ui/Components.tsx | 1 + .../UserButton/useMultisessionActions.tsx | 6 ++- packages/clerk-js/src/ui/elements/Modal.tsx | 4 +- packages/clerk-js/src/ui/elements/Popover.tsx | 8 +++- .../clerk-js/src/ui/lazyModules/providers.tsx | 47 ++++++++++--------- packages/react/src/components/withClerk.tsx | 4 +- packages/shared/src/react/PortalProvider.tsx | 9 ++-- 7 files changed, 45 insertions(+), 34 deletions(-) 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 dc7d0ef0521..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, usePortalRoot } from '@clerk/shared/react'; +import { createContextAndHook, usePortalRoot, useSafeLayoutEffect } from '@clerk/shared/react'; import React, { useRef } from 'react'; import { descriptors, Flex } from '../customizables'; @@ -53,7 +53,7 @@ export const Modal = withFloatingTree((props: ModalProps) => { }; }, []); - const effectivePortalRoot = portalRoot ?? portalRootFromContext ?? undefined; + const effectivePortalRoot = portalRoot ?? portalRootFromContext?.() ?? undefined; return ( { } = props; const portalRoot = usePortalRoot(); - const effectiveRoot = root ?? portalRoot ?? undefined; + const effectiveRoot = root ?? portalRoot?.() ?? undefined; + + console.log('effectiveRoot', effectiveRoot); + console.log('portalRoot', portalRoot); + console.log('root', root); if (portal) { return ( diff --git a/packages/clerk-js/src/ui/lazyModules/providers.tsx b/packages/clerk-js/src/ui/lazyModules/providers.tsx index 6922f3f8f99..deeb9e97d4e 100644 --- a/packages/clerk-js/src/ui/lazyModules/providers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx @@ -76,7 +76,7 @@ export const LazyComponentRenderer = (props: LazyComponentRendererProps) => { appearanceKey={props.appearanceKey} appearance={props.componentAppearance} > - props?.componentProps?.portalRoot}> + HTMLElement | null; } & AppearanceProviderProps >; @@ -119,27 +120,29 @@ export const LazyModalRenderer = (props: LazyModalRendererProps) => { > - - {props.startPath ? ( - - - {props.children} - - - ) : ( - props.children - )} - + + + {props.startPath ? ( + + + {props.children} + + + ) : ( + props.children + )} + + diff --git a/packages/react/src/components/withClerk.tsx b/packages/react/src/components/withClerk.tsx index 1e4c1529f2c..b8eee8d44bc 100644 --- a/packages/react/src/components/withClerk.tsx +++ b/packages/react/src/components/withClerk.tsx @@ -20,7 +20,7 @@ export const withClerk =

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

( return ( { +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) { - return contextValue.getContainer(); + + 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(); + return portalRootManager.getCurrent.bind(portalRootManager); };