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/astro/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SubscriptionDetailsButton, type SubscriptionDetailsButtonProps } from '
export * from './uiComponents';
export * from './controlComponents';
export * from './hooks';
export { UNSAFE_PortalProvider } from '@clerk/shared/react';
export { SignInButton, SignOutButton, SignUpButton };
export {
SubscriptionDetailsButton as __experimental_SubscriptionDetailsButton,
Expand Down
1 change: 1 addition & 0 deletions packages/expo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { getClerkInstance } from './provider/singleton';
export * from './provider/ClerkProvider';
export * from './hooks';
export * from './components';
export { UNSAFE_PortalProvider } from '@clerk/react';

// Override Clerk React error thrower to show that errors come from @clerk/expo
setErrorThrowerOptions({ packageName: PACKAGE_NAME });
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,
UNSAFE_PortalProvider,
} from '@clerk/react';

export { MultisessionAppSupport } from '@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,
UNSAFE_PortalProvider,
RedirectToCreateOrganization,
RedirectToOrganizationProfile,
RedirectToSignIn,
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/src/runtime/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export {
SignOutButton,
SignInWithMetamaskButton,
PricingTable,
UNSAFE_PortalProvider,
} from '@clerk/vue';
1 change: 1 addition & 0 deletions packages/react-router/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ReactRouterClerkProvider';
export type { WithClerkState } from './types';
export { SignIn, SignUp, OrganizationProfile, UserProfile } from './uiComponents';
export { UNSAFE_PortalProvider } from '@clerk/react';
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 { UNSAFE_PortalProvider } from '@clerk/shared/react';
65 changes: 65 additions & 0 deletions packages/shared/src/react/PortalProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';

import React from 'react';

import { createContextAndHook } from './hooks/createContextAndHook';

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');

/**
* 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.
*
* @example
* ```tsx
* function Example() {
* const containerRef = useRef(null);
* return (
* <RadixDialog ref={containerRef}>
* <UNSAFE_PortalProvider getContainer={() => containerRef.current}>
* <UserButton />
* </UNSAFE_PortalProvider>
* </RadixDialog>
* );
* }
* ```
*/
export const UNSAFE_PortalProvider = ({ children, getContainer }: PortalProviderProps) => {
const contextValue = React.useMemo(() => ({ value: { getContainer } }), [getContainer]);

return <PortalContext.Provider value={contextValue}>{children}</PortalContext.Provider>;
};

/**
* Hook 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 contextValue = usePortalContextWithoutGuarantee();

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

// Return a function that returns null when not inside a PortalProvider
return () => null;
};
103 changes: 103 additions & 0 deletions packages/shared/src/react/__tests__/PortalProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';

import { UNSAFE_PortalProvider, usePortalRoot } from '../PortalProvider';

describe('UNSAFE_PortalProvider', () => {
it('provides getContainer to children via context', () => {
const container = document.createElement('div');
const getContainer = () => container;

const TestComponent = () => {
const portalRoot = usePortalRoot();
return <div data-testid='test'>{portalRoot === getContainer ? 'found' : 'not-found'}</div>;
};

render(
<UNSAFE_PortalProvider getContainer={getContainer}>
<TestComponent />
</UNSAFE_PortalProvider>,
);

expect(screen.getByTestId('test').textContent).toBe('found');
});

it('only affects components within the provider', () => {
const container = document.createElement('div');
const getContainer = () => container;

const InsideComponent = () => {
const portalRoot = usePortalRoot();
return <div data-testid='inside'>{portalRoot() === container ? 'container' : 'null'}</div>;
};

const OutsideComponent = () => {
const portalRoot = usePortalRoot();
return <div data-testid='outside'>{portalRoot() === null ? 'null' : 'container'}</div>;
};

render(
<>
<OutsideComponent />
<UNSAFE_PortalProvider getContainer={getContainer}>
<InsideComponent />
</UNSAFE_PortalProvider>
</>,
);

expect(screen.getByTestId('inside').textContent).toBe('container');
expect(screen.getByTestId('outside').textContent).toBe('null');
});
});

describe('usePortalRoot', () => {
it('returns getContainer from context when inside PortalProvider', () => {
const container = document.createElement('div');
const getContainer = () => container;

const TestComponent = () => {
const portalRoot = usePortalRoot();
return <div data-testid='test'>{portalRoot() === container ? 'found' : 'not-found'}</div>;
};

render(
<UNSAFE_PortalProvider getContainer={getContainer}>
<TestComponent />
</UNSAFE_PortalProvider>,
);

expect(screen.getByTestId('test').textContent).toBe('found');
});

it('returns a function that returns null when outside PortalProvider', () => {
const TestComponent = () => {
const portalRoot = usePortalRoot();
return <div data-testid='test'>{portalRoot() === null ? 'null' : 'not-null'}</div>;
};

render(<TestComponent />);

expect(screen.getByTestId('test').textContent).toBe('null');
});

it('supports nested providers with innermost taking precedence', () => {
const outerContainer = document.createElement('div');
const innerContainer = document.createElement('div');

const TestComponent = () => {
const portalRoot = usePortalRoot();
return <div data-testid='test'>{portalRoot() === innerContainer ? 'inner' : 'outer'}</div>;
};

render(
<UNSAFE_PortalProvider getContainer={() => outerContainer}>
<UNSAFE_PortalProvider getContainer={() => innerContainer}>
<TestComponent />
</UNSAFE_PortalProvider>
</UNSAFE_PortalProvider>,
);

expect(screen.getByTestId('test').textContent).toBe('inner');
});
});
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 { UNSAFE_PortalProvider, usePortalRoot } from './PortalProvider';
60 changes: 54 additions & 6 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1422,9 +1422,22 @@ export interface TransferableOption {
transferable?: boolean;
}

export type SignInModalProps = WithoutRouting<SignInProps>;
export type SignInModalProps = WithoutRouting<SignInProps> & {
/**
* 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
*/
Expand Down Expand Up @@ -1566,7 +1579,14 @@ export type SignUpProps = RoutingOptions & {
SignInForceRedirectUrl &
AfterSignOutUrl;

export type SignUpModalProps = WithoutRouting<SignUpProps>;
export type SignUpModalProps = WithoutRouting<SignUpProps> & {
/**
* 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 & {
/**
Expand Down Expand Up @@ -1608,7 +1628,14 @@ export type UserProfileProps = RoutingOptions & {
};
};

export type UserProfileModalProps = WithoutRouting<UserProfileProps>;
export type UserProfileModalProps = WithoutRouting<UserProfileProps> & {
/**
* 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 & {
/**
Expand Down Expand Up @@ -1651,7 +1678,14 @@ export type OrganizationProfileProps = RoutingOptions & {
};
};

export type OrganizationProfileModalProps = WithoutRouting<OrganizationProfileProps>;
export type OrganizationProfileModalProps = WithoutRouting<OrganizationProfileProps> & {
/**
* 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 & {
/**
Expand All @@ -1677,7 +1711,14 @@ export type CreateOrganizationProps = RoutingOptions & {
appearance?: ClerkAppearanceTheme;
};

export type CreateOrganizationModalProps = WithoutRouting<CreateOrganizationProps>;
export type CreateOrganizationModalProps = WithoutRouting<CreateOrganizationProps> & {
/**
* 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 =
Expand Down Expand Up @@ -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 = {
/**
Expand Down
1 change: 1 addition & 0 deletions packages/tanstack-react-start/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './ClerkProvider';
export { SignIn, SignUp, OrganizationProfile, OrganizationList, UserProfile } from './uiComponents';
export { UNSAFE_PortalProvider } from '@clerk/react';
Loading