From 71ff27a06d5d88f3e1423d08b95dc9551b594f92 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 25 Nov 2025 11:48:16 -0500 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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; +}>; From fd628a24927d8c8f0240b2d4e9deeb0b5c10f92c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 8 Jan 2026 15:58:14 -0500 Subject: [PATCH 12/18] add changeset --- .changeset/wicked-friends-exist.md | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .changeset/wicked-friends-exist.md diff --git a/.changeset/wicked-friends-exist.md b/.changeset/wicked-friends-exist.md new file mode 100644 index 00000000000..7a7af6634a2 --- /dev/null +++ b/.changeset/wicked-friends-exist.md @@ -0,0 +1,42 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/react-router': minor +'@clerk/nextjs': minor +'@clerk/shared': minor +'@clerk/astro': minor +'@clerk/react': minor +'@clerk/expo': minor +'@clerk/nuxt': minor +'@clerk/vue': minor +'@clerk/ui': minor +--- + +Introduce `` component which allows you to specify a custom container for Clerk floating UI elements (popovers, modals, tooltips, etc.) that use portals. Only Clerk components within the 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](https://www.radix-ui.com/primitives/docs/components/dialog) or [React Aria Components](https://react-spectrum.adobe.com/react-aria/components.html), where portaled elements need to render within the dialog's container to remain interact-able. + +```tsx +'use client'; + +import { useRef } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { UNSAFE_PortalProvider, UserButton } from '@clerk/nextjs'; + +export function UserDialog() { + const containerRef = useRef(null); + + return ( + + Open Dialog + + + + containerRef.current}> + + + + + + ); +} +``` From 5532a479e854657af68b7c1f01bb0e011a8b6d7a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 8 Jan 2026 15:58:37 -0500 Subject: [PATCH 13/18] Delete .changeset/wet-phones-camp.md --- .changeset/wet-phones-camp.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .changeset/wet-phones-camp.md diff --git a/.changeset/wet-phones-camp.md b/.changeset/wet-phones-camp.md deleted file mode 100644 index a8fc88272a7..00000000000 --- a/.changeset/wet-phones-camp.md +++ /dev/null @@ -1,3 +0,0 @@ ---- -'@clerk/shared': patch ---- From d1c12d1b30ddb80f0485c2073f2fa011e1cd332a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 8 Jan 2026 16:03:48 -0500 Subject: [PATCH 14/18] remove duplicates --- packages/nextjs/src/client-boundary/controlComponents.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/nextjs/src/client-boundary/controlComponents.ts b/packages/nextjs/src/client-boundary/controlComponents.ts index 4a5e86251de..9949c35ef16 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -12,9 +12,6 @@ export { RedirectToSignUp, RedirectToTasks, RedirectToUserProfile, - AuthenticateWithRedirectCallback, - RedirectToCreateOrganization, - RedirectToOrganizationProfile, UNSAFE_PortalProvider, Show, } from '@clerk/react'; From 632874a0e6e64159c11302d221fd4cca187e8db7 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:21:53 -0600 Subject: [PATCH 15/18] chore(ui): Modify bundle limits --- packages/ui/bundlewatch.config.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 6aa64ab7a8a..e58549fa822 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -1,10 +1,10 @@ { "files": [ - { "path": "./dist/ui.browser.js", "maxSize": "19.5KB" }, - { "path": "./dist/ui.legacy.browser.js", "maxSize": "54KB" }, + { "path": "./dist/ui.browser.js", "maxSize": "34KB" }, + { "path": "./dist/ui.legacy.browser.js", "maxSize": "71KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "128KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "118KB" }, { "path": "./dist/signin*.js", "maxSize": "16KB" }, { "path": "./dist/signup*.js", "maxSize": "11KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, From f3045f77465fffa9f679581081453a3891368fba Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Jan 2026 15:36:15 -0500 Subject: [PATCH 16/18] Update exports.test.ts.snap --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 21c0511015a..38ecdeb2e26 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -49,6 +49,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "TaskChooseOrganization", "TaskResetPassword", + "UNSAFE_PortalProvider", "UserAvatar", "UserButton", "UserProfile", From f80e145effcf1f052335a315bbe46a8d66baccf2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Jan 2026 17:04:47 -0500 Subject: [PATCH 17/18] Update exports.test.ts.snap --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index e8f2ed3c6c2..44fcb014fe2 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -44,6 +44,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "TaskChooseOrganization", "TaskResetPassword", + "UNSAFE_PortalProvider", "UserAvatar", "UserButton", "UserProfile", From a3a64e99ac499b9c4b58e40b6eca2996a96c869e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Jan 2026 17:09:32 -0500 Subject: [PATCH 18/18] sort --- packages/shared/src/react/__tests__/PortalProvider.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/react/__tests__/PortalProvider.test.tsx b/packages/shared/src/react/__tests__/PortalProvider.test.tsx index 768959aa1ce..25a4a61e424 100644 --- a/packages/shared/src/react/__tests__/PortalProvider.test.tsx +++ b/packages/shared/src/react/__tests__/PortalProvider.test.tsx @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import React from 'react'; import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; import { UNSAFE_PortalProvider, usePortalRoot } from '../PortalProvider';