Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
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 @@ -463,6 +463,7 @@ const Components = (props: ComponentsProps) => {
componentName={'UserProfileModal'}
modalContainerSx={{ alignItems: 'center' }}
modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })}
portal={(userProfileModal as UserProfileProps & { portal?: boolean | (() => HTMLElement | null) })?.portal}
>
<UserProfileModal {...userProfileModal} />
</LazyModalRenderer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover';
import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger';

const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => {
const { defaultOpen } = useOrganizationSwitcherContext();
const { defaultOpen, portal = true } = useOrganizationSwitcherContext();

Comment on lines +15 to 23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

PortalContext fallback is effectively disabled for OrganizationSwitcher.

  • Line 15 destructures portal with a default (portal: portalProp = true), so portalProp is never undefined.
  • Lines 18–19 then compute portal as portalProp !== undefined ? portalProp : (portalFromContext ?? true), which means the portalFromContext and ?? true fallbacks are dead code.
  • As a result, PortalProvider / usePortalContext cannot influence this Popover at all; only the OrganizationSwitcher context prop can, and even that defaults to true. The comment on line 17 also no longer matches the behavior.

This breaks the intent of wiring global portal configuration through PortalContext into OrganizationSwitcher.

You can fix both the behavior and the comment with something like:

-  const { defaultOpen, portal: portalProp = true } = useOrganizationSwitcherContext();
-
-  // Get portal from context, fallback to prop, then default to true
-  const portalFromContext = usePortalContext();
-  const portal = portalProp !== undefined ? portalProp : (portalFromContext ?? true);
+  const { defaultOpen, portal: portalProp } = useOrganizationSwitcherContext();
+
+  // Get portal from prop, fallback to context, then default to true
+  const portalFromContext = usePortalContext();
+  const portal = portalProp ?? portalFromContext ?? true;

This lets:

  • Per-component portal override the global context when provided.
  • Global PortalProvider portal config apply when the component doesn’t specify portal.
  • The default still be true when neither is set.

Also applies to: 28-29, 46-46

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/OrganizationSwitcher/index.tsx around
lines 15 to 23, the current destructure sets portalProp default to true which
prevents PortalContext fallback from ever being used; remove the default from
the destructure so portalProp can be undefined, compute portal as portalProp ??
portalFromContext ?? true so per-component prop overrides context and context
overrides the final default, update the comment to reflect that ordering, and
adjust the useFixedPosition detection to check typeof portal === 'function' ||
(portal !== false && portalFromContext !== undefined); make the identical fixes
at the other mentioned locations (lines 28-29 and 46) to restore PortalProvider
influence.

const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({
defaultOpen,
Expand All @@ -34,6 +34,7 @@ const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactE
nodeId={nodeId}
context={context}
isOpen={isOpen}
portal={portal}
>
{cloneElement(children, {
id: switcherButtonMenuId,
Expand Down
3 changes: 2 additions & 1 deletion packages/clerk-js/src/ui/components/UserButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { UserButtonPopover } from './UserButtonPopover';
import { UserButtonTrigger } from './UserButtonTrigger';

const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>(({ children }) => {
const { defaultOpen } = useUserButtonContext();
const { defaultOpen, portal = true } = useUserButtonContext();

const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({
defaultOpen,
Expand All @@ -33,6 +33,7 @@ const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>(
nodeId={nodeId}
context={context}
isOpen={isOpen}
portal={portal}
>
{cloneElement(children, {
id: userButtonMenuId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type UseMultisessionActionsParams = {
userProfileUrl?: string;
signInUrl?: string;
taskUrl?: string | null;
} & Pick<UserButtonProps, 'userProfileMode' | 'appearance' | 'userProfileProps'>;
} & Pick<UserButtonProps, 'userProfileMode' | 'appearance' | 'userProfileProps' | 'portal'>;

export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
const { setActive, signOut, openUserProfile } = useClerk();
Expand All @@ -46,7 +46,10 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
})();
});
}
openUserProfile(opts.userProfileProps);
openUserProfile({
...opts.userProfileProps,
portal: opts.portal,
});
return opts.actionCompleteCallback?.();
};

Expand All @@ -62,6 +65,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
openUserProfile({
...opts.userProfileProps,
...(__experimental_startPath && { __experimental_startPath }),
portal: opts.portal,
});

return opts.actionCompleteCallback?.();
Expand Down
23 changes: 21 additions & 2 deletions packages/clerk-js/src/ui/elements/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createContextAndHook, useSafeLayoutEffect } from '@clerk/shared/react';
import React, { useRef } from 'react';

type PortalConfig = boolean | (() => HTMLElement | null);

import { descriptors, Flex } from '../customizables';
import { usePopover } from '../hooks';
import { useScrollLock } from '../hooks/useScrollLock';
Expand All @@ -19,12 +21,28 @@ type ModalProps = React.PropsWithChildren<{
containerSx?: ThemableCssProp;
canCloseModal?: boolean;
style?: React.CSSProperties;
portalRoot?: HTMLElement | React.MutableRefObject<HTMLElement | null>;
portalRoot?: PortalConfig | HTMLElement | React.MutableRefObject<HTMLElement | null>;
}>;

export const Modal = withFloatingTree((props: ModalProps) => {
const { disableScrollLock, enableScrollLock } = useScrollLock();
const { handleClose, handleOpen, contentSx, containerSx, canCloseModal, id, style, portalRoot } = props;

// Resolve portal root
const resolvePortalRoot = (): HTMLElement | null => {
if (typeof portalRoot === 'function') {
return portalRoot();
}
if (typeof portalRoot === 'boolean') {
return null;
}
if (typeof portalRoot === 'object' && 'current' in portalRoot) {
return portalRoot.current;
}
return portalRoot || null;
};

const resolvedRoot = resolvePortalRoot();
const overlayRef = useRef<HTMLDivElement>(null);
const { floating, isOpen, context, nodeId, toggle } = usePopover({
defaultOpen: true,
Expand Down Expand Up @@ -56,7 +74,8 @@ export const Modal = withFloatingTree((props: ModalProps) => {
context={context}
isOpen={isOpen}
outsideElementsInert
root={portalRoot}
portal={resolvedRoot ? () => resolvedRoot : false}
root={resolvedRoot || undefined}
>
<ModalContext.Provider value={modalCtx}>
<Flex
Expand Down
62 changes: 35 additions & 27 deletions packages/clerk-js/src/ui/elements/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui
import type { PropsWithChildren } from 'react';
import React from 'react';

type PortalConfig = boolean | (() => HTMLElement | null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Import PortalConfig from shared types instead of redefining it.

This is the fourth duplication of the PortalConfig type across the codebase. Centralizing this by importing from @clerk/shared/types will improve maintainability.

Apply this diff:

 import type { FloatingContext, ReferenceType } from '@floating-ui/react';
 import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react';
+import type { PortalConfig } from '@clerk/shared/types';
 import type { PropsWithChildren } from 'react';
 import React from 'react';

-type PortalConfig = boolean | (() => HTMLElement | null);
-
 type PopoverProps = PropsWithChildren<{
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type PortalConfig = boolean | (() => HTMLElement | null);
import type { FloatingContext, ReferenceType } from '@floating-ui/react';
import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react';
import type { PortalConfig } from '@clerk/shared/types';
import type { PropsWithChildren } from 'react';
import React from 'react';
type PopoverProps = PropsWithChildren<{
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/elements/Popover.tsx around line 6, the local type
alias "type PortalConfig = boolean | (() => HTMLElement | null);" duplicates a
shared type; remove this local definition and instead import PortalConfig from
"@clerk/shared/types" at the top of the file, update any usages to reference the
imported name, and ensure the import is correctly added to the existing import
block (or create one) and that TypeScript builds without additional type
conflicts.


type PopoverProps = PropsWithChildren<{
context: FloatingContext<ReferenceType>;
nodeId?: string;
Expand All @@ -14,7 +16,7 @@ type PopoverProps = PropsWithChildren<{
*/
outsideElementsInert?: boolean;
order?: Array<'reference' | 'floating' | 'content'>;
portal?: boolean;
portal?: PortalConfig;
/**
* The root element to render the portal into.
* @default document.body
Expand All @@ -35,36 +37,42 @@ export const Popover = (props: PopoverProps) => {
children,
} = props;

if (portal) {
// Resolve portal root
const resolveRoot = (): HTMLElement | null => {
if (typeof portal === 'function') {
return portal();
}
if (typeof root === 'object' && 'current' in root) {
return root.current;
}
return root || null;
};

const portalRoot = portal !== false ? resolveRoot() : null;
const shouldPortal = portal !== false && portalRoot !== null;
Comment on lines +45 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Verify portal logic consistency with documented defaults.

The portal resolution logic (lines 41-52) results in shouldPortal = false when portal={true} is specified without an explicit root element, because resolveRoot() returns null when only a boolean is passed.

However, the JSDoc for UserProfileProps.portal and UserButtonProps.portal documents @default true, suggesting portalling should be enabled by default. With the current logic, portal={true} without a root disables portalling, which seems inconsistent with the documented behavior.

Please verify:

  1. Should portal={true} use a default container (e.g., document.body)?
  2. Or should the JSDoc be updated to clarify that portal={true} requires an explicit root?

I need to verify the review comment by examining the actual codebase. Let me gather the necessary information.


🏁 Script executed:

#!/bin/bash
# Find and read the Popover.tsx file
fd -t f "Popover.tsx" packages/clerk-js/src/ui/elements

Length of output: 45


🏁 Script executed:

#!/bin/bash
# Search for UserProfileProps and UserButtonProps definitions
rg -n "interface UserProfileProps|interface UserButtonProps|type UserProfileProps|type UserButtonProps" -A 10 packages/clerk-js/src

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Read the Popover.tsx file to see full context
cat packages/clerk-js/src/ui/elements/Popover.tsx

Length of output: 2090


I found the Popover.tsx file. Now I need to search for UserProfileProps and UserButtonProps to verify the JSDoc claims in the review.


🏁 Script executed:

#!/bin/bash
# Search more broadly for UserProfileProps and UserButtonProps
rg -n "UserProfileProps|UserButtonProps" packages/clerk-js/src --type=ts --type=tsx | head -20

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for portal prop documentation in prop interfaces
rg -n "portal\?" packages/clerk-js/src -B 5 -A 2 --type=ts --type=tsx | grep -E "(portal|@default)" | head -30

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Find where these props might be defined
fd -t f -e "ts" -e "tsx" packages/clerk-js/src | xargs grep -l "UserProfileProps\|UserButtonProps" | head -5

Length of output: 389


Let me fix the command syntax and search again:


🏁 Script executed:

#!/bin/bash
# Search for UserProfileProps and UserButtonProps without type specification
rg -n "UserProfileProps|UserButtonProps" packages/clerk-js/src | head -20

Length of output: 1856


🏁 Script executed:

#!/bin/bash
# Search for files containing these types
fd . packages/clerk-js/src -type f | xargs grep -l "UserProfileProps\|UserButtonProps" 2>/dev/null | head -10

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Search for portal prop in all files
rg -n "portal\?" packages/clerk-js/src -B 3 -A 1 | head -50

Length of output: 2112


I can see UserProfileProps and UserButtonProps are imported from @clerk/shared/types. Let me find those definitions:


🏁 Script executed:

#!/bin/bash
# Find @clerk/shared package
find . -type d -name "shared" | grep -E "clerk|packages" | head -10

Length of output: 167


🏁 Script executed:

#!/bin/bash
# Search for the actual type definitions in the repo
rg -n "export.*type.*UserProfileProps|export.*interface.*UserProfileProps" . | head -10

Length of output: 292


🏁 Script executed:

#!/bin/bash
# Look for @clerk/shared in package.json files
rg -n "@clerk/shared" packages/clerk-js | head -5

Length of output: 512


Now let me read the UserProfileProps definition and check the portal property documentation:


🏁 Script executed:

#!/bin/bash
# Read the UserProfileProps definition with context
sed -n '1540,1600p' packages/shared/src/types/clerk.ts

Length of output: 1960


🏁 Script executed:

#!/bin/bash
# Search for portal property documentation in the shared types
rg -n "portal" packages/shared/src/types/clerk.ts -B 2 -A 1

Length of output: 3073


🏁 Script executed:

#!/bin/bash
# Find UserButtonProps definition
rg -n "export.*type.*UserButtonProps|export.*interface.*UserButtonProps" packages/shared/src/types/clerk.ts -A 30 | head -60

Length of output: 1249


Portal default behavior in Popover contradicts JSDoc documentation.

The code behavior is inconsistent with the documented defaults. When portal={true} (the default) is used without an explicit root, the shouldPortal flag becomes false, disabling portalling entirely. However, the JSDoc for both UserProfileProps.portal and UserButtonProps.portal in packages/shared/src/types/clerk.ts documents @default true, implying portalling should be enabled by default.

Additionally, the Popover's root prop JSDoc claims @default document.body, but the resolveRoot() function returns null when root is undefined, not using document.body.

Fix required: Either:

  1. Update the implementation to use document.body as the default root when not explicitly provided (matching the JSDoc), or
  2. Update the JSDoc to clarify that portal={true} requires an explicit root element to function.


if (!isOpen) {
return <FloatingNode id={nodeId} />;
}

const content = (
<FloatingFocusManager
context={context}
initialFocus={initialFocus}
outsideElementsInert={outsideElementsInert}
order={order}
>
<>{children}</>
</FloatingFocusManager>
);

if (shouldPortal) {
return (
<FloatingNode id={nodeId}>
<FloatingPortal root={root}>
{isOpen && (
<FloatingFocusManager
context={context}
initialFocus={initialFocus}
outsideElementsInert={outsideElementsInert}
order={order}
>
<>{children}</>
</FloatingFocusManager>
)}
</FloatingPortal>
<FloatingPortal root={portalRoot}>{content}</FloatingPortal>
</FloatingNode>
);
}

return (
<FloatingNode id={nodeId}>
{isOpen && (
<FloatingFocusManager
context={context}
initialFocus={initialFocus}
order={order}
>
<>{children}</>
</FloatingFocusManager>
)}
</FloatingNode>
);
return <FloatingNode id={nodeId}>{content}</FloatingNode>;
};
4 changes: 4 additions & 0 deletions packages/clerk-js/src/ui/lazyModules/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { deprecated } from '@clerk/shared/deprecated';
import type { Appearance } from '@clerk/shared/types';
import React, { lazy, Suspense } from 'react';

type PortalConfig = boolean | (() => HTMLElement | null);

import type { FlowMetadata } from '../elements/contexts';
import type { Drawer } from '../elements/Drawer';
import type { ThemableCssProp } from '../styledSystem';
Expand Down Expand Up @@ -103,6 +105,7 @@ type LazyModalRendererProps = React.PropsWithChildren<
canCloseModal?: boolean;
modalId?: string;
modalStyle?: React.CSSProperties;
portal?: PortalConfig;
} & AppearanceProviderProps
>;

Expand All @@ -123,6 +126,7 @@ export const LazyModalRenderer = (props: LazyModalRendererProps) => {
containerSx={props.modalContainerSx}
contentSx={props.modalContentSx}
canCloseModal={props.canCloseModal}
portalRoot={props.portal}
>
{props.startPath ? (
<Suspense>
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ import type { UserResource } from './user';
import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils';
import type { WaitlistResource } from './waitlist';

/**
* Configuration for portalling components to a specific container.
* - `false`: Disable portalling (render in place)
* - `() => HTMLElement | null`: Portal to the returned element
*/
export type PortalConfig = boolean | (() => HTMLElement | null);

type __experimental_CheckoutStatus = 'needs_initialization' | 'needs_confirmation' | 'completed';

export type __experimental_CheckoutCacheState = Readonly<{
Expand Down Expand Up @@ -1575,6 +1582,14 @@ export type UserProfileProps = RoutingOptions & {
*/
hide?: boolean;
};
/**
* Configuration for portalling the UserProfile modal to a specific container.
* - `false`: Disable portalling (render in place)
* - `() => HTMLElement | null`: Portal to the returned element
*
* @default true
*/
portal?: PortalConfig;
};

export type UserProfileModalProps = WithoutRouting<UserProfileProps>;
Expand Down Expand Up @@ -1725,6 +1740,15 @@ export type UserButtonProps = UserButtonProfileMode & {
* Provide custom menu actions and links to be rendered inside the UserButton.
*/
customMenuItems?: CustomMenuItem[];

/**
* Configuration for portalling the UserButton popover and nested modals to a specific container.
* - `false`: Disable portalling (render in place)
* - `() => HTMLElement | null`: Portal to the returned element
*
* @default true
*/
portal?: PortalConfig;
};

export type UserAvatarProps = {
Expand Down Expand Up @@ -1834,6 +1858,10 @@ export type OrganizationSwitcherProps = CreateOrganizationMode &
* e.g. <UserButton userProfileProps={{appearance: {...}}} />
*/
organizationProfileProps?: Pick<OrganizationProfileProps, 'appearance' | 'customPages'>;
/**
* @default true
*/
portal?: boolean;
};

export type OrganizationListProps = {
Expand Down
Loading