Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changeset/wet-phones-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
'@clerk/shared': patch
---
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -46,7 +47,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
})();
});
}
openUserProfile(opts.userProfileProps);
openUserProfile({ getContainer, ...opts.userProfileProps });
return opts.actionCompleteCallback?.();
};

Expand All @@ -60,6 +61,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
});
}
openUserProfile({
getContainer,
...opts.userProfileProps,
...(__experimental_startPath && { __experimental_startPath }),
});
Expand Down
7 changes: 5 additions & 2 deletions packages/clerk-js/src/ui/elements/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContextAndHook, useSafeLayoutEffect } from '@clerk/shared/react';
import { createContextAndHook, usePortalRoot, useSafeLayoutEffect } from '@clerk/shared/react';
import React, { useRef } from 'react';

import { descriptors, Flex } from '../customizables';
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const { floating, isOpen, context, nodeId, toggle } = usePopover({
defaultOpen: true,
Expand All @@ -52,13 +53,15 @@ export const Modal = withFloatingTree((props: ModalProps) => {
};
}, []);

const effectivePortalRoot = portalRoot ?? portalRootFromContext?.() ?? undefined;

return (
<Popover
nodeId={nodeId}
context={context}
isOpen={isOpen}
outsideElementsInert
root={portalRoot}
root={effectivePortalRoot}
initialFocus={initialFocusRef}
>
<ModalContext.Provider value={modalCtx}>
Expand Down
10 changes: 9 additions & 1 deletion packages/clerk-js/src/ui/elements/Popover.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePortalRoot } from '@clerk/shared/react';
import type { FloatingContext, ReferenceType } from '@floating-ui/react';
import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react';
import type { PropsWithChildren } from 'react';
Expand Down Expand Up @@ -35,10 +36,17 @@ export const Popover = (props: PopoverProps) => {
children,
} = props;

const portalRoot = usePortalRoot();
const effectiveRoot = root ?? portalRoot?.() ?? undefined;

console.log('effectiveRoot', effectiveRoot);
console.log('portalRoot', portalRoot);
console.log('root', root);

if (portal) {
return (
<FloatingNode id={nodeId}>
<FloatingPortal root={root}>
<FloatingPortal root={effectiveRoot}>
{isOpen && (
<FloatingFocusManager
context={context}
Expand Down
68 changes: 37 additions & 31 deletions packages/clerk-js/src/ui/lazyModules/providers.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -75,16 +76,18 @@ export const LazyComponentRenderer = (props: LazyComponentRendererProps) => {
appearanceKey={props.appearanceKey}
appearance={props.componentAppearance}
>
<Portal
node={props.node}
component={
ClerkComponents[props.componentName as ClerkComponentName] as React.ComponentType<
Omit<AvailableComponentCtx, 'componentName'>
>
}
props={props.componentProps}
componentName={props.componentName}
/>
<PortalProvider getContainer={props?.componentProps?.getContainer}>
<Portal
node={props.node}
component={
ClerkComponents[props.componentName as ClerkComponentName] as React.ComponentType<
Omit<AvailableComponentCtx, 'componentName'>
>
}
props={props.componentProps}
componentName={props.componentName}
/>
</PortalProvider>
</AppearanceProvider>
);
};
Expand All @@ -103,6 +106,7 @@ type LazyModalRendererProps = React.PropsWithChildren<
canCloseModal?: boolean;
modalId?: string;
modalStyle?: React.CSSProperties;
getContainer: () => HTMLElement | null;
} & AppearanceProviderProps
>;

Expand All @@ -116,27 +120,29 @@ export const LazyModalRenderer = (props: LazyModalRendererProps) => {
>
<FlowMetadataProvider flow={props.flowName || ('' as any)}>
<InternalThemeProvider>
<Modal
id={props.modalId}
style={props.modalStyle}
handleClose={props.onClose}
containerSx={props.modalContainerSx}
contentSx={props.modalContentSx}
canCloseModal={props.canCloseModal}
>
{props.startPath ? (
<Suspense>
<VirtualRouter
startPath={props.startPath}
onExternalNavigate={props.onExternalNavigate}
>
{props.children}
</VirtualRouter>
</Suspense>
) : (
props.children
)}
</Modal>
<PortalProvider getContainer={props.getContainer}>
<Modal
id={props.modalId}
style={props.modalStyle}
handleClose={props.onClose}
containerSx={props.modalContainerSx}
contentSx={props.modalContentSx}
canCloseModal={props.canCloseModal}
>
{props.startPath ? (
<Suspense>
<VirtualRouter
startPath={props.startPath}
onExternalNavigate={props.onExternalNavigate}
>
{props.children}
</VirtualRouter>
</Suspense>
) : (
props.children
)}
</Modal>
</PortalProvider>
</InternalThemeProvider>
</FlowMetadataProvider>
</AppearanceProvider>
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/client-boundary/controlComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
AuthenticateWithRedirectCallback,
RedirectToCreateOrganization,
RedirectToOrganizationProfile,
PortalProvider,
} from '@clerk/clerk-react';

export { MultisessionAppSupport } from '@clerk/clerk-react/internal';
1 change: 1 addition & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
ClerkFailed,
ClerkLoaded,
ClerkLoading,
PortalProvider,
RedirectToCreateOrganization,
RedirectToOrganizationProfile,
RedirectToSignIn,
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/components/withClerk.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePortalRoot } from '@clerk/shared/react';
import type { LoadedClerk, Without } from '@clerk/shared/types';
import React from 'react';

Expand All @@ -19,13 +20,15 @@ export const withClerk = <P extends { clerk: LoadedClerk; component?: string }>(
useAssertWrappedByClerkProvider(displayName || 'withClerk');

const clerk = useIsomorphicClerkContext();
const getContainer = usePortalRoot();

if (!clerk.loaded && !options?.renderWhileLoading) {
return null;
}

return (
<Component
getContainer={getContainer}
{...(props as P)}
component={displayName}
clerk={clerk}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/contexts/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { ClerkProvider } from './ClerkProvider';
export { PortalProvider } from '@clerk/shared/react';
77 changes: 77 additions & 0 deletions packages/shared/src/react/PortalProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client';

import React, { useEffect, useRef } from 'react';

import { createContextAndHook } from './hooks/createContextAndHook';
import { portalRootManager } from './portal-root-manager';

type PortalProviderProps = React.PropsWithChildren<{
/**
* 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;
}>;

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 (
* <RadixDialog ref={containerRef}>
* <PortalProvider getContainer={() => containerRef.current}>
* <UserButton />
* </PortalProvider>
* </RadixDialog>
* );
* }
* ```
*/
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 <PortalContext.Provider value={contextValue}>{children}</PortalContext.Provider>;
};

/**
* Hook to get the current portal root container.
* First checks React context (for same-tree components),
* then falls back to PortalRootManager (for cross-tree like modals).
*/
export const usePortalRoot = (): (() => HTMLElement | null) => {
// Try to get from context first (for components in the same React tree)
const contextValue = usePortalContextWithoutGuarantee();

if (contextValue && 'getContainer' in contextValue && contextValue.getContainer) {
return contextValue.getContainer;
}

// Fall back to manager (for components in different React trees, like modals)
return portalRootManager.getCurrent.bind(portalRootManager);
};
2 changes: 2 additions & 0 deletions packages/shared/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ export {
} from './contexts';

export * from './billing/payment-element';

export { PortalProvider, usePortalRoot } from './PortalProvider';
37 changes: 37 additions & 0 deletions packages/shared/src/react/portal-root-manager.ts
Original file line number Diff line number Diff line change
@@ -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();