From 71ff27a06d5d88f3e1423d08b95dc9551b594f92 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Nov 2025 11:48:16 -0500 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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 (