From 71ff27a06d5d88f3e1423d08b95dc9551b594f92 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Nov 2025 11:48:16 -0500 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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); }; From 44854e19667b7c8018c2b1d2bd86fa19a57d472a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Dec 2025 14:51:51 -0500 Subject: [PATCH 06/11] expand portalprovider usage --- packages/astro/src/react/index.ts | 1 + packages/expo/src/index.ts | 1 + packages/react-router/src/client/index.ts | 1 + .../react/__tests__/PortalProvider.test.tsx | 180 ++++++++++++++++++ packages/shared/src/types/clerk.ts | 60 +++++- .../tanstack-react-start/src/client/index.ts | 1 + packages/ui/src/Components.tsx | 6 + .../APIKeys/__tests__/APIKeyModal.test.tsx | 88 +++++++++ .../OrganizationSwitcherPopover.tsx | 6 +- .../__tests__/OrganizationSwitcher.test.tsx | 49 +++++ .../components/PricingTable/PricingTable.tsx | 5 +- .../UserButton/__tests__/UserButton.test.tsx | 30 +++ packages/ui/src/elements/Drawer.tsx | 13 +- packages/ui/src/elements/Popover.tsx | 4 - packages/ui/src/elements/Tooltip.tsx | 6 +- 15 files changed, 433 insertions(+), 18 deletions(-) create mode 100644 packages/shared/src/react/__tests__/PortalProvider.test.tsx create mode 100644 packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx diff --git a/packages/astro/src/react/index.ts b/packages/astro/src/react/index.ts index c16086cc435..77e3762d505 100644 --- a/packages/astro/src/react/index.ts +++ b/packages/astro/src/react/index.ts @@ -8,6 +8,7 @@ import { SubscriptionDetailsButton, type SubscriptionDetailsButtonProps } from ' export * from './uiComponents'; export * from './controlComponents'; export * from './hooks'; +export { PortalProvider } from '@clerk/react'; export { SignInButton, SignOutButton, SignUpButton }; export { SubscriptionDetailsButton as __experimental_SubscriptionDetailsButton, diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts index 3020bc4407f..91f423cd464 100644 --- a/packages/expo/src/index.ts +++ b/packages/expo/src/index.ts @@ -17,6 +17,7 @@ export { getClerkInstance } from './provider/singleton'; export * from './provider/ClerkProvider'; export * from './hooks'; export * from './components'; +export { PortalProvider } from '@clerk/react'; // Override Clerk React error thrower to show that errors come from @clerk/expo setErrorThrowerOptions({ packageName: PACKAGE_NAME }); diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index e6e89242bfb..ea5fc895d10 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -1,3 +1,4 @@ export * from './ReactRouterClerkProvider'; export type { WithClerkState } from './types'; export { SignIn, SignUp, OrganizationProfile, UserProfile } from './uiComponents'; +export { PortalProvider } from '@clerk/react'; diff --git a/packages/shared/src/react/__tests__/PortalProvider.test.tsx b/packages/shared/src/react/__tests__/PortalProvider.test.tsx new file mode 100644 index 00000000000..7ea7f640683 --- /dev/null +++ b/packages/shared/src/react/__tests__/PortalProvider.test.tsx @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { PortalProvider, usePortalRoot } from '../PortalProvider'; +import { portalRootManager } from '../portal-root-manager'; + +describe('PortalProvider', () => { + it('provides getContainer to children via context', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const TestComponent = () => { + const portalRoot = usePortalRoot(); + return

{portalRoot === getContainer ? 'found' : 'not-found'}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('test').textContent).toBe('found'); + }); + + it('registers with portalRootManager on mount', () => { + const container = document.createElement('div'); + const getContainer = () => container; + const pushSpy = vi.spyOn(portalRootManager, 'push'); + + const { unmount } = render( + +
test
+
, + ); + + expect(pushSpy).toHaveBeenCalledTimes(1); + expect(portalRootManager.getCurrent()).toBe(container); + + unmount(); + }); + + it('unregisters from portalRootManager on unmount', () => { + const container = document.createElement('div'); + const getContainer = () => container; + const popSpy = vi.spyOn(portalRootManager, 'pop'); + + const { unmount } = render( + +
test
+
, + ); + + unmount(); + + expect(popSpy).toHaveBeenCalledTimes(1); + expect(portalRootManager.getCurrent()).toBeNull(); + }); +}); + +describe('usePortalRoot', () => { + it('returns getContainer from context when inside PortalProvider', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const TestComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === container ? 'found' : 'not-found'}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('test').textContent).toBe('found'); + }); + + it('returns manager.getCurrent when outside PortalProvider', () => { + const container = document.createElement('div'); + portalRootManager.push(() => container); + + const TestComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === container ? 'found' : 'not-found'}
; + }; + + render(); + + expect(screen.getByTestId('test').textContent).toBe('found'); + + portalRootManager.pop(); + }); + + it('context value takes precedence over manager', () => { + const contextContainer = document.createElement('div'); + const managerContainer = document.createElement('div'); + const contextGetContainer = () => contextContainer; + + portalRootManager.push(() => managerContainer); + + const TestComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === contextContainer ? 'found' : 'not-found'}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('test').textContent).toBe('found'); + + portalRootManager.pop(); + }); +}); + +describe('portalRootManager', () => { + beforeEach(() => { + // Clear the stack before each test + while (portalRootManager.getCurrent() !== null) { + portalRootManager.pop(); + } + }); + + it('maintains stack of portal roots', () => { + const container1 = document.createElement('div'); + const container2 = document.createElement('div'); + const getContainer1 = () => container1; + const getContainer2 = () => container2; + + portalRootManager.push(getContainer1); + portalRootManager.push(getContainer2); + + expect(portalRootManager.getCurrent()).toBe(container2); + + portalRootManager.pop(); + expect(portalRootManager.getCurrent()).toBe(container1); + + portalRootManager.pop(); + }); + + it('getCurrent returns topmost root', () => { + const container1 = document.createElement('div'); + const container2 = document.createElement('div'); + const getContainer1 = () => container1; + const getContainer2 = () => container2; + + portalRootManager.push(getContainer1); + portalRootManager.push(getContainer2); + + expect(portalRootManager.getCurrent()).toBe(container2); + + portalRootManager.pop(); + portalRootManager.pop(); + }); + + it('pop removes topmost root', () => { + const container1 = document.createElement('div'); + const container2 = document.createElement('div'); + const getContainer1 = () => container1; + const getContainer2 = () => container2; + + portalRootManager.push(getContainer1); + portalRootManager.push(getContainer2); + + portalRootManager.pop(); + + expect(portalRootManager.getCurrent()).toBe(container1); + + portalRootManager.pop(); + }); + + it('getCurrent returns null when stack is empty', () => { + expect(portalRootManager.getCurrent()).toBeNull(); + }); +}); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index d83d5db8cfb..d98009b0089 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1422,9 +1422,22 @@ export interface TransferableOption { transferable?: boolean; } -export type SignInModalProps = WithoutRouting; +export type SignInModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; export type __internal_UserVerificationProps = RoutingOptions & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; /** * Non-awaitable callback for when verification is completed successfully */ @@ -1566,7 +1579,14 @@ export type SignUpProps = RoutingOptions & { SignInForceRedirectUrl & AfterSignOutUrl; -export type SignUpModalProps = WithoutRouting; +export type SignUpModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; export type UserProfileProps = RoutingOptions & { /** @@ -1608,7 +1628,14 @@ export type UserProfileProps = RoutingOptions & { }; }; -export type UserProfileModalProps = WithoutRouting; +export type UserProfileModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; export type OrganizationProfileProps = RoutingOptions & { /** @@ -1651,7 +1678,14 @@ export type OrganizationProfileProps = RoutingOptions & { }; }; -export type OrganizationProfileModalProps = WithoutRouting; +export type OrganizationProfileModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; export type CreateOrganizationProps = RoutingOptions & { /** @@ -1677,7 +1711,14 @@ export type CreateOrganizationProps = RoutingOptions & { appearance?: ClerkAppearanceTheme; }; -export type CreateOrganizationModalProps = WithoutRouting; +export type CreateOrganizationModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; type UserProfileMode = 'modal' | 'navigation'; type UserButtonProfileMode = @@ -1900,7 +1941,14 @@ export type WaitlistProps = { signInUrl?: string; }; -export type WaitlistModalProps = WaitlistProps; +export type WaitlistModalProps = WaitlistProps & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; type PricingTableDefaultProps = { /** diff --git a/packages/tanstack-react-start/src/client/index.ts b/packages/tanstack-react-start/src/client/index.ts index 09ce51e6a72..c7068c04a7e 100644 --- a/packages/tanstack-react-start/src/client/index.ts +++ b/packages/tanstack-react-start/src/client/index.ts @@ -1,2 +1,3 @@ export * from './ClerkProvider'; export { SignIn, SignUp, OrganizationProfile, OrganizationList, UserProfile } from './uiComponents'; +export { PortalProvider } from '@clerk/react'; diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index c9b86df18ed..73bcdaaa4fa 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -466,6 +466,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('signIn')} onExternalNavigate={() => componentsControls.closeModal('signIn')} startPath={buildVirtualRouterUrl({ base: '/sign-in', path: urlStateParam?.path })} + getContainer={signInModal?.getContainer} componentName={'SignInModal'} > @@ -483,6 +484,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('signUp')} onExternalNavigate={() => componentsControls.closeModal('signUp')} startPath={buildVirtualRouterUrl({ base: '/sign-up', path: urlStateParam?.path })} + getContainer={signUpModal?.getContainer} componentName={'SignUpModal'} > @@ -521,6 +523,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('userVerification')} onExternalNavigate={() => componentsControls.closeModal('userVerification')} startPath={buildVirtualRouterUrl({ base: '/user-verification', path: urlStateParam?.path })} + getContainer={userVerificationModal?.getContainer} componentName={'UserVerificationModal'} modalContainerSx={{ alignItems: 'center' }} > @@ -540,6 +543,7 @@ const Components = (props: ComponentsProps) => { base: '/organizationProfile', path: organizationProfileModal?.__experimental_startPath || urlStateParam?.path, })} + getContainer={organizationProfileModal?.getContainer} componentName={'OrganizationProfileModal'} modalContainerSx={{ alignItems: 'center' }} modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} @@ -557,6 +561,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('createOrganization')} onExternalNavigate={() => componentsControls.closeModal('createOrganization')} startPath={buildVirtualRouterUrl({ base: '/createOrganization', path: urlStateParam?.path })} + getContainer={createOrganizationModal?.getContainer} componentName={'CreateOrganizationModal'} modalContainerSx={{ alignItems: 'center' }} modalContentSx={t => ({ height: `min(${t.sizes.$120}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} @@ -574,6 +579,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('waitlist')} onExternalNavigate={() => componentsControls.closeModal('waitlist')} startPath={buildVirtualRouterUrl({ base: '/waitlist', path: urlStateParam?.path })} + getContainer={waitlistModal?.getContainer} componentName={'WaitlistModal'} > diff --git a/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx new file mode 100644 index 00000000000..78e997c2819 --- /dev/null +++ b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import React from 'react'; +import { render } from '@testing-library/react'; + +import { PortalProvider } from '@clerk/react'; + +import { APIKeyModal } from '../APIKeyModal'; + +describe('APIKeyModal modalRoot behavior', () => { + it('renders modal inside modalRoot when provided', () => { + const modalRoot = React.createRef(); + const container = document.createElement('div'); + modalRoot.current = container; + document.body.appendChild(container); + + const { container: testContainer } = render( + {}} + handleClose={() => {}} + canCloseModal={true} + > +
Test Content
+
, + ); + + // The modal should render inside the modalRoot container, not document.body + // We can verify this by checking that the modal content is within the container + expect(container.querySelector('[data-testid="modal-content"]')).toBeInTheDocument(); + + document.body.removeChild(container); + }); + + it('applies scoped portal container styles when modalRoot provided', () => { + const modalRoot = React.createRef(); + const container = document.createElement('div'); + modalRoot.current = container; + document.body.appendChild(container); + + const { container: testContainer } = render( + {}} + handleClose={() => {}} + canCloseModal={true} + > +
Test
+
, + ); + + // The modal should have scoped styles (position: absolute) when modalRoot is provided + const modalElement = container.querySelector('[data-clerk-element="modalBackdrop"]'); + expect(modalElement).toBeTruthy(); + + document.body.removeChild(container); + }); + + it('modalRoot takes precedence over PortalProvider context', () => { + const modalRoot = React.createRef(); + const container1 = document.createElement('div'); + const container2 = document.createElement('div'); + modalRoot.current = container1; + document.body.appendChild(container1); + document.body.appendChild(container2); + + const getContainer = () => container2; + + const { container: testContainer } = render( + + {}} + handleClose={() => {}} + canCloseModal={true} + > +
Test Content
+
+
, + ); + + // The modal should render in container1 (modalRoot), not container2 (PortalProvider) + expect(container1.querySelector('[data-testid="modal-content"]')).toBeInTheDocument(); + expect(container2.querySelector('[data-testid="modal-content"]')).not.toBeInTheDocument(); + + document.body.removeChild(container1); + document.body.removeChild(container2); + }); +}); diff --git a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index aec8b0f21de..5e58e0e87d9 100644 --- a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -1,4 +1,4 @@ -import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/shared/react'; +import { useClerk, useOrganization, useOrganizationList, usePortalRoot, useUser } from '@clerk/shared/react'; import type { OrganizationResource } from '@clerk/shared/types'; import React from 'react'; @@ -25,6 +25,7 @@ export const OrganizationSwitcherPopover = React.forwardRef { @@ -88,6 +89,7 @@ export const OrganizationSwitcherPopover = React.forwardRef { ); }); }); + + describe('OrganizationSwitcher with PortalProvider', () => { + it('passes getContainer to openOrganizationProfile', async () => { + const container = document.createElement('div'); + const getContainer = () => container; + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button')); + const manageButton = await waitFor(() => getByRole('menuitem', { name: /manage organization/i })); + await userEvent.click(manageButton); + + expect(fixtures.clerk.openOrganizationProfile).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + }); + + it('passes getContainer to openCreateOrganization', async () => { + const container = document.createElement('div'); + const getContainer = () => container; + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button', { name: 'Open organization switcher' })); + const createButton = await waitFor(() => getByRole('menuitem', { name: 'Create organization' })); + await userEvent.click(createButton); + + expect(fixtures.clerk.openCreateOrganization).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + }); + }); }); diff --git a/packages/ui/src/components/PricingTable/PricingTable.tsx b/packages/ui/src/components/PricingTable/PricingTable.tsx index be84d597b71..9ced0c884ff 100644 --- a/packages/ui/src/components/PricingTable/PricingTable.tsx +++ b/packages/ui/src/components/PricingTable/PricingTable.tsx @@ -1,4 +1,4 @@ -import { useClerk } from '@clerk/shared/react'; +import { useClerk, usePortalRoot } from '@clerk/shared/react'; import type { BillingPlanResource, BillingSubscriptionPlanPeriod, PricingTableProps } from '@clerk/shared/types'; import { useEffect, useMemo, useState } from 'react'; @@ -10,6 +10,7 @@ import { PricingTableMatrix } from './PricingTableMatrix'; const PricingTableRoot = (props: PricingTableProps) => { const clerk = useClerk(); + const getContainer = usePortalRoot(); const { mode = 'mounted', signInMode = 'redirect' } = usePricingTableContext(); const isCompact = mode === 'modal'; const { data: subscription, subscriptionItems } = useSubscription(); @@ -52,7 +53,7 @@ const PricingTableRoot = (props: PricingTableProps) => { const selectPlan = (plan: BillingPlanResource, event?: React.MouseEvent) => { if (!clerk.isSignedIn) { if (signInMode === 'modal') { - return clerk.openSignIn(); + return clerk.openSignIn({ getContainer }); } return clerk.redirectToSignIn(); } diff --git a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx index cc5c292751f..b5b6409d9a9 100644 --- a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx +++ b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest'; +import React from 'react'; + +import { PortalProvider } from '@clerk/react'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, waitFor } from '@/test/utils'; @@ -84,6 +87,33 @@ describe('UserButton', () => { it.todo('navigates to sign in url when "Add account" is clicked'); + describe('UserButton with PortalProvider', () => { + it('passes getContainer to openUserProfile when wrapped in PortalProvider', async () => { + const container = document.createElement('div'); + const getContainer = () => container; + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ + first_name: 'First', + last_name: 'Last', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); + }); + + const { getByText, getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button', { name: 'Open user menu' })); + await userEvent.click(getByText('Manage account')); + + expect(fixtures.clerk.openUserProfile).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + }); + }); + describe('Multi Session Popover', () => { const initConfig = createFixtures.config(f => { f.withMultiSessionMode(); diff --git a/packages/ui/src/elements/Drawer.tsx b/packages/ui/src/elements/Drawer.tsx index 29fb30caa35..317ce0ca0f3 100644 --- a/packages/ui/src/elements/Drawer.tsx +++ b/packages/ui/src/elements/Drawer.tsx @@ -1,4 +1,4 @@ -import { useSafeLayoutEffect } from '@clerk/shared/react/index'; +import { usePortalRoot, useSafeLayoutEffect } from '@clerk/shared/react/index'; import type { UseDismissProps, UseFloatingOptions, UseRoleProps } from '@floating-ui/react'; import { FloatingFocusManager, @@ -88,6 +88,8 @@ function Root({ dismissProps, }: RootProps) { const direction = useDirection(); + const portalRoot = usePortalRoot(); + const effectivePortalRoot = portalProps?.root ?? portalRoot?.() ?? undefined; const { refs, context } = useFloating({ open, @@ -110,14 +112,19 @@ function Root({ isOpen: open, setIsOpen: onOpenChange, strategy, - portalProps: portalProps || {}, + portalProps: { ...portalProps, root: effectivePortalRoot }, refs, context, getFloatingProps, direction, }} > - {children} + + {children} + ); } diff --git a/packages/ui/src/elements/Popover.tsx b/packages/ui/src/elements/Popover.tsx index 52fc4656027..770ca00a9dc 100644 --- a/packages/ui/src/elements/Popover.tsx +++ b/packages/ui/src/elements/Popover.tsx @@ -39,10 +39,6 @@ export const Popover = (props: PopoverProps) => { const portalRoot = usePortalRoot(); const effectiveRoot = root ?? portalRoot?.() ?? undefined; - console.log('effectiveRoot', effectiveRoot); - console.log('portalRoot', portalRoot); - console.log('root', root); - if (portal) { return ( diff --git a/packages/ui/src/elements/Tooltip.tsx b/packages/ui/src/elements/Tooltip.tsx index ee3447341ca..c1e4fc887f3 100644 --- a/packages/ui/src/elements/Tooltip.tsx +++ b/packages/ui/src/elements/Tooltip.tsx @@ -16,6 +16,8 @@ import { } from '@floating-ui/react'; import * as React from 'react'; +import { usePortalRoot } from '@clerk/shared/react'; + import { Box, descriptors, type LocalizationKey, Span, Text, useAppearance } from '../customizables'; import { usePrefersReducedMotion } from '../hooks'; import type { ThemableCssProp } from '../styledSystem'; @@ -192,13 +194,15 @@ const Content = React.forwardRef< >(function TooltipContent({ style, text, sx, ...props }, propRef) { const context = useTooltipContext(); const ref = useMergeRefs([context.refs.setFloating, propRef]); + const portalRoot = usePortalRoot(); + const effectiveRoot = portalRoot?.() ?? undefined; if (!context.isMounted) { return null; } return ( - + Date: Mon, 15 Dec 2025 15:06:44 -0500 Subject: [PATCH 07/11] fix export --- packages/astro/src/react/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/src/react/index.ts b/packages/astro/src/react/index.ts index 77e3762d505..fc4c32212ea 100644 --- a/packages/astro/src/react/index.ts +++ b/packages/astro/src/react/index.ts @@ -8,7 +8,7 @@ import { SubscriptionDetailsButton, type SubscriptionDetailsButtonProps } from ' export * from './uiComponents'; export * from './controlComponents'; export * from './hooks'; -export { PortalProvider } from '@clerk/react'; +export { PortalProvider } from '@clerk/shared/react'; export { SignInButton, SignOutButton, SignUpButton }; export { SubscriptionDetailsButton as __experimental_SubscriptionDetailsButton, From c90b1684623e78363157622e35c87546585c2fcc Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Dec 2025 15:51:40 -0500 Subject: [PATCH 08/11] rename to UNSAFE_PortalProvider --- packages/astro/src/react/index.ts | 2 +- packages/expo/src/index.ts | 2 +- .../src/client-boundary/controlComponents.ts | 2 +- packages/nextjs/src/index.ts | 2 +- packages/react-router/src/client/index.ts | 2 +- packages/react/src/contexts/index.ts | 2 +- packages/shared/src/react/PortalProvider.tsx | 2 +- .../react/__tests__/PortalProvider.test.tsx | 22 +++++++++---------- packages/shared/src/react/index.ts | 2 +- .../tanstack-react-start/src/client/index.ts | 2 +- .../APIKeys/__tests__/APIKeyModal.test.tsx | 6 ++--- .../__tests__/OrganizationSwitcher.test.tsx | 10 ++++----- .../UserButton/__tests__/UserButton.test.tsx | 6 ++--- packages/ui/src/lazyModules/providers.tsx | 10 ++++----- 14 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/astro/src/react/index.ts b/packages/astro/src/react/index.ts index fc4c32212ea..eb5f40d788c 100644 --- a/packages/astro/src/react/index.ts +++ b/packages/astro/src/react/index.ts @@ -8,7 +8,7 @@ import { SubscriptionDetailsButton, type SubscriptionDetailsButtonProps } from ' export * from './uiComponents'; export * from './controlComponents'; export * from './hooks'; -export { PortalProvider } from '@clerk/shared/react'; +export { UNSAFE_PortalProvider } from '@clerk/shared/react'; export { SignInButton, SignOutButton, SignUpButton }; export { SubscriptionDetailsButton as __experimental_SubscriptionDetailsButton, diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts index 91f423cd464..d3d6a591b0c 100644 --- a/packages/expo/src/index.ts +++ b/packages/expo/src/index.ts @@ -17,7 +17,7 @@ export { getClerkInstance } from './provider/singleton'; export * from './provider/ClerkProvider'; export * from './hooks'; export * from './components'; -export { PortalProvider } from '@clerk/react'; +export { UNSAFE_PortalProvider } from '@clerk/react'; // Override Clerk React error thrower to show that errors come from @clerk/expo setErrorThrowerOptions({ packageName: PACKAGE_NAME }); diff --git a/packages/nextjs/src/client-boundary/controlComponents.ts b/packages/nextjs/src/client-boundary/controlComponents.ts index d4e5dbe16c7..ec526c7d623 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -15,7 +15,7 @@ export { AuthenticateWithRedirectCallback, RedirectToCreateOrganization, RedirectToOrganizationProfile, - PortalProvider, + UNSAFE_PortalProvider, } from '@clerk/react'; export { MultisessionAppSupport } from '@clerk/react/internal'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 4fa023b15ec..8419a156b47 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -8,7 +8,7 @@ export { ClerkFailed, ClerkLoaded, ClerkLoading, - PortalProvider, + UNSAFE_PortalProvider, RedirectToCreateOrganization, RedirectToOrganizationProfile, RedirectToSignIn, diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index ea5fc895d10..a63e39894f7 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -1,4 +1,4 @@ export * from './ReactRouterClerkProvider'; export type { WithClerkState } from './types'; export { SignIn, SignUp, OrganizationProfile, UserProfile } from './uiComponents'; -export { PortalProvider } from '@clerk/react'; +export { UNSAFE_PortalProvider } from '@clerk/react'; diff --git a/packages/react/src/contexts/index.ts b/packages/react/src/contexts/index.ts index 8f653d20c3e..4a2746b3472 100644 --- a/packages/react/src/contexts/index.ts +++ b/packages/react/src/contexts/index.ts @@ -1,2 +1,2 @@ export { ClerkProvider } from './ClerkProvider'; -export { PortalProvider } from '@clerk/shared/react'; +export { UNSAFE_PortalProvider } from '@clerk/shared/react'; diff --git a/packages/shared/src/react/PortalProvider.tsx b/packages/shared/src/react/PortalProvider.tsx index ab7e032e914..8a171f64ad0 100644 --- a/packages/shared/src/react/PortalProvider.tsx +++ b/packages/shared/src/react/PortalProvider.tsx @@ -40,7 +40,7 @@ const [PortalContext, , usePortalContextWithoutGuarantee] = createContextAndHook * } * ``` */ -export const PortalProvider = ({ children, getContainer }: PortalProviderProps) => { +export const UNSAFE_PortalProvider = ({ children, getContainer }: PortalProviderProps) => { const getContainerRef = useRef(getContainer); getContainerRef.current = getContainer; diff --git a/packages/shared/src/react/__tests__/PortalProvider.test.tsx b/packages/shared/src/react/__tests__/PortalProvider.test.tsx index 7ea7f640683..60fd15ec8fa 100644 --- a/packages/shared/src/react/__tests__/PortalProvider.test.tsx +++ b/packages/shared/src/react/__tests__/PortalProvider.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; import { render, screen } from '@testing-library/react'; -import { PortalProvider, usePortalRoot } from '../PortalProvider'; +import { UNSAFE_PortalProvider, usePortalRoot } from '../PortalProvider'; import { portalRootManager } from '../portal-root-manager'; describe('PortalProvider', () => { @@ -16,9 +16,9 @@ describe('PortalProvider', () => { }; render( - + - , + , ); expect(screen.getByTestId('test').textContent).toBe('found'); @@ -30,9 +30,9 @@ describe('PortalProvider', () => { const pushSpy = vi.spyOn(portalRootManager, 'push'); const { unmount } = render( - +
test
-
, + , ); expect(pushSpy).toHaveBeenCalledTimes(1); @@ -47,9 +47,9 @@ describe('PortalProvider', () => { const popSpy = vi.spyOn(portalRootManager, 'pop'); const { unmount } = render( - +
test
-
, + , ); unmount(); @@ -70,9 +70,9 @@ describe('usePortalRoot', () => { }; render( - + - , + , ); expect(screen.getByTestId('test').textContent).toBe('found'); @@ -107,9 +107,9 @@ describe('usePortalRoot', () => { }; render( - + - , + , ); expect(screen.getByTestId('test').textContent).toBe('found'); diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index cdf195d9fe8..b865b1602d4 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -21,4 +21,4 @@ export { export * from './billing/payment-element'; -export { PortalProvider, usePortalRoot } from './PortalProvider'; +export { UNSAFE_PortalProvider, usePortalRoot } from './PortalProvider'; diff --git a/packages/tanstack-react-start/src/client/index.ts b/packages/tanstack-react-start/src/client/index.ts index c7068c04a7e..edd5e28a1b1 100644 --- a/packages/tanstack-react-start/src/client/index.ts +++ b/packages/tanstack-react-start/src/client/index.ts @@ -1,3 +1,3 @@ export * from './ClerkProvider'; export { SignIn, SignUp, OrganizationProfile, OrganizationList, UserProfile } from './uiComponents'; -export { PortalProvider } from '@clerk/react'; +export { UNSAFE_PortalProvider } from '@clerk/react'; diff --git a/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx index 78e997c2819..9a73acdcda4 100644 --- a/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx +++ b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import React from 'react'; import { render } from '@testing-library/react'; -import { PortalProvider } from '@clerk/react'; +import { UNSAFE_PortalProvider } from '@clerk/react'; import { APIKeyModal } from '../APIKeyModal'; @@ -66,7 +66,7 @@ describe('APIKeyModal modalRoot behavior', () => { const getContainer = () => container2; const { container: testContainer } = render( - + {}} @@ -75,7 +75,7 @@ describe('APIKeyModal modalRoot behavior', () => { >
Test Content
-
, + , ); // The modal should render in container1 (modalRoot), not container2 (PortalProvider) diff --git a/packages/ui/src/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/ui/src/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx index 0893741eb03..fdf8e7f6221 100644 --- a/packages/ui/src/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx @@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import React from 'react'; -import { PortalProvider } from '@clerk/react'; +import { UNSAFE_PortalProvider } from '@clerk/react'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { act, render } from '@/test/utils'; @@ -552,9 +552,9 @@ describe('OrganizationSwitcher', () => { }); const { getByRole, userEvent } = render( - + - , + , { wrapper }, ); @@ -574,9 +574,9 @@ describe('OrganizationSwitcher', () => { }); const { getByRole, userEvent } = render( - + - , + , { wrapper }, ); diff --git a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx index b5b6409d9a9..64ed3ac95e3 100644 --- a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx +++ b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import React from 'react'; -import { PortalProvider } from '@clerk/react'; +import { UNSAFE_PortalProvider } from '@clerk/react'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, waitFor } from '@/test/utils'; @@ -101,9 +101,9 @@ describe('UserButton', () => { }); const { getByText, getByRole, userEvent } = render( - + - , + , { wrapper }, ); diff --git a/packages/ui/src/lazyModules/providers.tsx b/packages/ui/src/lazyModules/providers.tsx index 6d7c1b85730..93b014b9b4b 100644 --- a/packages/ui/src/lazyModules/providers.tsx +++ b/packages/ui/src/lazyModules/providers.tsx @@ -1,6 +1,6 @@ import { deprecated } from '@clerk/shared/deprecated'; import type { ModuleManager } from '@clerk/shared/moduleManager'; -import { PortalProvider } from '@clerk/shared/react'; +import { UNSAFE_PortalProvider } from '@clerk/shared/react'; import React, { lazy, Suspense } from 'react'; import type { FlowMetadata } from '../elements/contexts'; @@ -86,7 +86,7 @@ export const LazyComponentRenderer = (props: LazyComponentRendererProps) => { appearanceKey={props.appearanceKey} appearance={props.componentAppearance} > - + { props={props.componentProps} componentName={props.componentName} /> - + ); }; @@ -130,7 +130,7 @@ export const LazyModalRenderer = (props: LazyModalRendererProps) => { > - + { props.children )} - + From 998c30aea2ebab44aeecfa62c59dbf021ab8012f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Dec 2025 15:51:54 -0500 Subject: [PATCH 09/11] fix menu item wrapping --- packages/ui/src/elements/Menu.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/elements/Menu.tsx b/packages/ui/src/elements/Menu.tsx index 0788ffdda6a..0579758d082 100644 --- a/packages/ui/src/elements/Menu.tsx +++ b/packages/ui/src/elements/Menu.tsx @@ -199,6 +199,7 @@ export const MenuItem = (props: MenuItemProps) => { justifyContent: 'start', borderRadius: theme.radii.$sm, padding: `${theme.space.$1} ${theme.space.$3}`, + whiteSpace: 'nowrap', }), sx, ]} From 7d4e63c0cd6fa0e69c55cdf1bed5ab21cb7a365c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 15 Dec 2025 16:18:19 -0500 Subject: [PATCH 10/11] remove PortalRootManager usage --- packages/shared/src/react/PortalProvider.tsx | 34 ++--- .../react/__tests__/PortalProvider.test.tsx | 141 ++++-------------- .../shared/src/react/portal-root-manager.ts | 37 ----- 3 files changed, 43 insertions(+), 169 deletions(-) delete mode 100644 packages/shared/src/react/portal-root-manager.ts diff --git a/packages/shared/src/react/PortalProvider.tsx b/packages/shared/src/react/PortalProvider.tsx index 8a171f64ad0..d2a0817b77e 100644 --- a/packages/shared/src/react/PortalProvider.tsx +++ b/packages/shared/src/react/PortalProvider.tsx @@ -1,9 +1,8 @@ 'use client'; -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import { createContextAndHook } from './hooks/createContextAndHook'; -import { portalRootManager } from './portal-root-manager'; type PortalProviderProps = React.PropsWithChildren<{ /** @@ -19,9 +18,12 @@ const [PortalContext, , usePortalContextWithoutGuarantee] = createContextAndHook }>('PortalProvider'); /** - * PortalProvider allows you to specify a custom container for all Clerk floating UI elements + * UNSAFE_PortalProvider allows you to specify a custom container for Clerk floating UI elements * (popovers, modals, tooltips, etc.) that use portals. * + * Only components within this provider will be affected. Components outside the provider + * will continue to use the default document.body for 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. @@ -32,28 +34,15 @@ const [PortalContext, , usePortalContextWithoutGuarantee] = createContextAndHook * const containerRef = useRef(null); * return ( * - * containerRef.current}> + * containerRef.current}> * - * + * * * ); * } * ``` */ export const UNSAFE_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}; @@ -61,17 +50,16 @@ export const UNSAFE_PortalProvider = ({ children, getContainer }: PortalProvider /** * 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). + * Returns the getContainer function from context if inside a PortalProvider, + * otherwise returns a function that returns null (default behavior). */ 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); + // Return a function that returns null when not inside a PortalProvider + return () => null; }; diff --git a/packages/shared/src/react/__tests__/PortalProvider.test.tsx b/packages/shared/src/react/__tests__/PortalProvider.test.tsx index 60fd15ec8fa..768959aa1ce 100644 --- a/packages/shared/src/react/__tests__/PortalProvider.test.tsx +++ b/packages/shared/src/react/__tests__/PortalProvider.test.tsx @@ -1,11 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import React from 'react'; import { render, screen } from '@testing-library/react'; import { UNSAFE_PortalProvider, usePortalRoot } from '../PortalProvider'; -import { portalRootManager } from '../portal-root-manager'; -describe('PortalProvider', () => { +describe('UNSAFE_PortalProvider', () => { it('provides getContainer to children via context', () => { const container = document.createElement('div'); const getContainer = () => container; @@ -24,38 +23,31 @@ describe('PortalProvider', () => { expect(screen.getByTestId('test').textContent).toBe('found'); }); - it('registers with portalRootManager on mount', () => { + it('only affects components within the provider', () => { const container = document.createElement('div'); const getContainer = () => container; - const pushSpy = vi.spyOn(portalRootManager, 'push'); - const { unmount } = render( - -
test
-
, - ); - - expect(pushSpy).toHaveBeenCalledTimes(1); - expect(portalRootManager.getCurrent()).toBe(container); - - unmount(); - }); + const InsideComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === container ? 'container' : 'null'}
; + }; - it('unregisters from portalRootManager on unmount', () => { - const container = document.createElement('div'); - const getContainer = () => container; - const popSpy = vi.spyOn(portalRootManager, 'pop'); + const OutsideComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === null ? 'null' : 'container'}
; + }; - const { unmount } = render( - -
test
-
, + render( + <> + + + + + , ); - unmount(); - - expect(popSpy).toHaveBeenCalledTimes(1); - expect(portalRootManager.getCurrent()).toBeNull(); + expect(screen.getByTestId('inside').textContent).toBe('container'); + expect(screen.getByTestId('outside').textContent).toBe('null'); }); }); @@ -78,103 +70,34 @@ describe('usePortalRoot', () => { expect(screen.getByTestId('test').textContent).toBe('found'); }); - it('returns manager.getCurrent when outside PortalProvider', () => { - const container = document.createElement('div'); - portalRootManager.push(() => container); - + it('returns a function that returns null when outside PortalProvider', () => { const TestComponent = () => { const portalRoot = usePortalRoot(); - return
{portalRoot() === container ? 'found' : 'not-found'}
; + return
{portalRoot() === null ? 'null' : 'not-null'}
; }; render(); - expect(screen.getByTestId('test').textContent).toBe('found'); - - portalRootManager.pop(); + expect(screen.getByTestId('test').textContent).toBe('null'); }); - it('context value takes precedence over manager', () => { - const contextContainer = document.createElement('div'); - const managerContainer = document.createElement('div'); - const contextGetContainer = () => contextContainer; - - portalRootManager.push(() => managerContainer); + it('supports nested providers with innermost taking precedence', () => { + const outerContainer = document.createElement('div'); + const innerContainer = document.createElement('div'); const TestComponent = () => { const portalRoot = usePortalRoot(); - return
{portalRoot() === contextContainer ? 'found' : 'not-found'}
; + return
{portalRoot() === innerContainer ? 'inner' : 'outer'}
; }; render( - - + outerContainer}> + innerContainer}> + + , ); - expect(screen.getByTestId('test').textContent).toBe('found'); - - portalRootManager.pop(); - }); -}); - -describe('portalRootManager', () => { - beforeEach(() => { - // Clear the stack before each test - while (portalRootManager.getCurrent() !== null) { - portalRootManager.pop(); - } - }); - - it('maintains stack of portal roots', () => { - const container1 = document.createElement('div'); - const container2 = document.createElement('div'); - const getContainer1 = () => container1; - const getContainer2 = () => container2; - - portalRootManager.push(getContainer1); - portalRootManager.push(getContainer2); - - expect(portalRootManager.getCurrent()).toBe(container2); - - portalRootManager.pop(); - expect(portalRootManager.getCurrent()).toBe(container1); - - portalRootManager.pop(); - }); - - it('getCurrent returns topmost root', () => { - const container1 = document.createElement('div'); - const container2 = document.createElement('div'); - const getContainer1 = () => container1; - const getContainer2 = () => container2; - - portalRootManager.push(getContainer1); - portalRootManager.push(getContainer2); - - expect(portalRootManager.getCurrent()).toBe(container2); - - portalRootManager.pop(); - portalRootManager.pop(); - }); - - it('pop removes topmost root', () => { - const container1 = document.createElement('div'); - const container2 = document.createElement('div'); - const getContainer1 = () => container1; - const getContainer2 = () => container2; - - portalRootManager.push(getContainer1); - portalRootManager.push(getContainer2); - - portalRootManager.pop(); - - expect(portalRootManager.getCurrent()).toBe(container1); - - portalRootManager.pop(); - }); - - it('getCurrent returns null when stack is empty', () => { - expect(portalRootManager.getCurrent()).toBeNull(); + expect(screen.getByTestId('test').textContent).toBe('inner'); }); }); diff --git a/packages/shared/src/react/portal-root-manager.ts b/packages/shared/src/react/portal-root-manager.ts deleted file mode 100644 index eb371adfc18..00000000000 --- a/packages/shared/src/react/portal-root-manager.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * 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 fa6ef65a8fc66229d1b42e9dcfd3a10c9a86a890 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 17 Dec 2025 06:14:55 -0800 Subject: [PATCH 11/11] feat(vue): UNSAFE_PortalProvider implementation (#7491) --- packages/nuxt/src/runtime/components/index.ts | 1 + .../vue/src/components/ClerkHostRenderer.ts | 17 ++- packages/vue/src/components/PortalProvider.ts | 52 +++++++++ packages/vue/src/components/index.ts | 1 + .../__tests__/usePortalRoot.test.ts | 100 ++++++++++++++++++ packages/vue/src/composables/index.ts | 2 + packages/vue/src/composables/usePortalRoot.ts | 19 ++++ packages/vue/src/keys.ts | 4 + 8 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 packages/vue/src/components/PortalProvider.ts create mode 100644 packages/vue/src/composables/__tests__/usePortalRoot.test.ts create mode 100644 packages/vue/src/composables/usePortalRoot.ts diff --git a/packages/nuxt/src/runtime/components/index.ts b/packages/nuxt/src/runtime/components/index.ts index 61bde896c00..0f4326f7071 100644 --- a/packages/nuxt/src/runtime/components/index.ts +++ b/packages/nuxt/src/runtime/components/index.ts @@ -25,4 +25,5 @@ export { SignOutButton, SignInWithMetamaskButton, PricingTable, + UNSAFE_PortalProvider, } from '@clerk/vue'; diff --git a/packages/vue/src/components/ClerkHostRenderer.ts b/packages/vue/src/components/ClerkHostRenderer.ts index e3e9f692837..a24afaf52cf 100644 --- a/packages/vue/src/components/ClerkHostRenderer.ts +++ b/packages/vue/src/components/ClerkHostRenderer.ts @@ -1,6 +1,7 @@ import type { PropType } from 'vue'; import { defineComponent, h, onUnmounted, ref, watch, watchEffect } from 'vue'; +import { usePortalRoot } from '../composables/usePortalRoot'; import type { CustomPortalsRendererProps } from '../types'; import { ClerkLoaded } from './controlComponents'; @@ -44,6 +45,7 @@ export const ClerkHostRenderer = defineComponent({ }, setup(props) { const portalRef = ref(null); + const getContainer = usePortalRoot(); let isPortalMounted = false; watchEffect(() => { @@ -52,11 +54,16 @@ export const ClerkHostRenderer = defineComponent({ return; } + const propsWithContainer = { + ...props.props, + getContainer, + }; + if (props.mount) { - props.mount(portalRef.value, props.props); + props.mount(portalRef.value, propsWithContainer); } if (props.open) { - props.open(props.props); + props.open(propsWithContainer); } isPortalMounted = true; }); @@ -65,7 +72,11 @@ export const ClerkHostRenderer = defineComponent({ () => props.props, newProps => { if (isPortalMounted && props.updateProps && portalRef.value) { - props.updateProps({ node: portalRef.value, props: newProps }); + const propsWithContainer = { + ...newProps, + getContainer, + }; + props.updateProps({ node: portalRef.value, props: propsWithContainer }); } }, { deep: true }, diff --git a/packages/vue/src/components/PortalProvider.ts b/packages/vue/src/components/PortalProvider.ts new file mode 100644 index 00000000000..ec49c81c856 --- /dev/null +++ b/packages/vue/src/components/PortalProvider.ts @@ -0,0 +1,52 @@ +import { defineComponent, type PropType, provide } from 'vue'; + +import { PortalInjectionKey } from '../keys'; + +/** + * UNSAFE_PortalProvider allows you to specify a custom container for Clerk floating UI elements + * (popovers, modals, tooltips, etc.) that use portals. + * + * Only components within this provider will be affected. Components outside the provider + * will continue to use the default document.body for portals. + * + * This is particularly useful when using Clerk components inside external UI libraries + * like Reka UI Dialog, where portaled elements need to render within the dialog's + * container to remain interactable. + * + * @example + * ```vue + * + * + * + * ``` + */ +export const UNSAFE_PortalProvider = defineComponent({ + name: 'UNSAFE_PortalProvider', + props: { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Reka UI Dialog) instead of document.body. + */ + getContainer: { + type: Function as PropType<() => HTMLElement | null>, + required: true, + }, + }, + setup(props, { slots }) { + provide(PortalInjectionKey, { getContainer: props.getContainer }); + return () => slots.default?.(); + }, +}); diff --git a/packages/vue/src/components/index.ts b/packages/vue/src/components/index.ts index 65c8398137f..dd19fafc2bd 100644 --- a/packages/vue/src/components/index.ts +++ b/packages/vue/src/components/index.ts @@ -30,3 +30,4 @@ export { default as SignInButton } from './SignInButton.vue'; export { default as SignUpButton } from './SignUpButton.vue'; export { default as SignOutButton } from './SignOutButton.vue'; export { default as SignInWithMetamaskButton } from './SignInWithMetamaskButton.vue'; +export { UNSAFE_PortalProvider } from './PortalProvider'; diff --git a/packages/vue/src/composables/__tests__/usePortalRoot.test.ts b/packages/vue/src/composables/__tests__/usePortalRoot.test.ts new file mode 100644 index 00000000000..47f7d601a2e --- /dev/null +++ b/packages/vue/src/composables/__tests__/usePortalRoot.test.ts @@ -0,0 +1,100 @@ +import { render } from '@testing-library/vue'; +import { describe, expect, it } from 'vitest'; +import { defineComponent, h } from 'vue'; + +import { UNSAFE_PortalProvider } from '../../components/PortalProvider'; +import { usePortalRoot } from '../usePortalRoot'; + +describe('usePortalRoot', () => { + it('returns getContainer from context when inside PortalProvider', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const TestComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'test' }, portalRoot() === container ? 'found' : 'not-found'); + }, + }); + + const { getByTestId } = render(h(UNSAFE_PortalProvider, { getContainer }, () => h(TestComponent))); + + expect(getByTestId('test').textContent).toBe('found'); + }); + + it('returns a function that returns null when outside PortalProvider', () => { + const TestComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'test' }, portalRoot() === null ? 'null' : 'not-null'); + }, + }); + + const { getByTestId } = render(TestComponent); + + expect(getByTestId('test').textContent).toBe('null'); + }); + + it('only affects components within the provider', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const InsideComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'inside' }, portalRoot() === container ? 'container' : 'null'); + }, + }); + + const OutsideComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'outside' }, portalRoot() === null ? 'null' : 'container'); + }, + }); + + const { getByTestId } = render({ + components: { InsideComponent, OutsideComponent, UNSAFE_PortalProvider }, + template: ` + + + + + `, + setup() { + return { getContainer }; + }, + }); + + expect(getByTestId('inside').textContent).toBe('container'); + expect(getByTestId('outside').textContent).toBe('null'); + }); + + it('supports nested providers with innermost taking precedence', () => { + const outerContainer = document.createElement('div'); + const innerContainer = document.createElement('div'); + + const TestComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'test' }, portalRoot() === innerContainer ? 'inner' : 'outer'); + }, + }); + + const { getByTestId } = render({ + components: { TestComponent, UNSAFE_PortalProvider }, + template: ` + + + + + + `, + setup() { + return { outerContainer, innerContainer }; + }, + }); + + expect(getByTestId('test').textContent).toBe('inner'); + }); +}); diff --git a/packages/vue/src/composables/index.ts b/packages/vue/src/composables/index.ts index 9ca30fcc06c..7adac21c765 100644 --- a/packages/vue/src/composables/index.ts +++ b/packages/vue/src/composables/index.ts @@ -13,3 +13,5 @@ export { useSignUp } from './useSignUp'; export { useSessionList } from './useSessionList'; export { useOrganization } from './useOrganization'; + +export { usePortalRoot } from './usePortalRoot'; diff --git a/packages/vue/src/composables/usePortalRoot.ts b/packages/vue/src/composables/usePortalRoot.ts new file mode 100644 index 00000000000..03adf0cf453 --- /dev/null +++ b/packages/vue/src/composables/usePortalRoot.ts @@ -0,0 +1,19 @@ +import { inject } from 'vue'; + +import { PortalInjectionKey } from '../keys'; + +/** + * Composable to get the current portal root container. + * Returns the getContainer function from context if inside a PortalProvider, + * otherwise returns a function that returns null (default behavior). + */ +export const usePortalRoot = (): (() => HTMLElement | null) => { + const context = inject(PortalInjectionKey, null); + + if (context && context.getContainer) { + return context.getContainer; + } + + // Return a function that returns null when not inside a PortalProvider + return () => null; +}; diff --git a/packages/vue/src/keys.ts b/packages/vue/src/keys.ts index 7a5ca1f5d38..e012c639c3b 100644 --- a/packages/vue/src/keys.ts +++ b/packages/vue/src/keys.ts @@ -19,3 +19,7 @@ export const UserProfileInjectionKey = Symbol('UserProfile') as InjectionKey<{ export const OrganizationProfileInjectionKey = Symbol('OrganizationProfile') as InjectionKey<{ addCustomPage(params: AddCustomPagesParams): void; }>; + +export const PortalInjectionKey = Symbol('Portal') as InjectionKey<{ + getContainer: () => HTMLElement | null; +}>;