Skip to content
Merged
Changes from 1 commit
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
126 changes: 57 additions & 69 deletions packages/eui/src/components/portal/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,23 @@
* Side Public License, v 1.
*/

/**
* NOTE: We can't test this component because Enzyme doesn't support rendering
* into portals.
*/

import React, {
import {
FunctionComponent,
Component,
ContextType,
ReactNode,
memo,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import { createPortal } from 'react-dom';

import { EuiNestedThemeContext } from '../../services';
import { usePropsWithComponentDefaults } from '../provider/component_defaults';

const usePortalEffect =
typeof document === 'undefined' ? useEffect : useLayoutEffect;

const INSERT_POSITIONS = ['after', 'before'] as const;
type EuiPortalInsertPosition = (typeof INSERT_POSITIONS)[number];
const insertPositions: Record<EuiPortalInsertPosition, InsertPosition> = {
Expand All @@ -45,84 +46,71 @@ export interface EuiPortalProps {
portalRef?: (ref: HTMLDivElement | null) => void;
}

export const EuiPortal: FunctionComponent<EuiPortalProps> = (props) => {
const propsWithDefaults = usePropsWithComponentDefaults('EuiPortal', props);
return <EuiPortalClass {...propsWithDefaults} />;
};

interface EuiPortalState {
portalNode: HTMLDivElement | null;
}

export class EuiPortalClass extends Component<EuiPortalProps, EuiPortalState> {
static contextType = EuiNestedThemeContext;
declare context: ContextType<typeof EuiNestedThemeContext>;
export const EuiPortal: FunctionComponent<EuiPortalProps> = memo((_props) => {
const props = usePropsWithComponentDefaults('EuiPortal', _props);
const { children, insert, portalRef } = props;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

This refactor removes the previously exported EuiPortalClass symbol from this module. Even if it wasn't re-exported from components/portal/index.ts, consumers can still deep-import it from components/portal/portal, so this is a potential breaking API change and also conflicts with the PR description of β€œno API changes”. Either keep a backwards-compatible export (e.g., a deprecated alias) or explicitly call this out as an API/breaking change.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The class component was exported from the file, but not from the portal folder.
If someone imported it manually, it would be rather unexpected usage. The only officially exported component was EuiPortal. Imho, this doesn't require marking it as breaking change.


constructor(props: EuiPortalProps) {
super(props);
const { hasDifferentColorFromGlobalTheme, colorClassName } = useContext(
EuiNestedThemeContext
);

this.state = {
portalNode: null,
};
}
const [portalNode, setPortalNode] = useState<HTMLDivElement | null>(null);

componentDidMount() {
const { insert } = this.props;
// Set the inherited color of the portal based on the wrapping EuiThemeProvider
const setThemeColor = (portalNode: HTMLDivElement) => {
if (hasDifferentColorFromGlobalTheme && insert == null) {
portalNode.classList.add(colorClassName);
}
};

const portalNode = document.createElement('div');
portalNode.dataset.euiportal = 'true';
const updatePortalRef = (ref: HTMLDivElement | null) => {
if (portalRef) {
portalRef(ref);
}
};

/* Uses `useLayoutEffect` on client-side instead of `useEffect` to ensure the portal
node is created and inserted into the DOM synchronously. This matches the same timing
as the previous class component `componentDidMount` behavior.
Using `useEffect` would add an additional render cycle that would break expected
behavior of e.g. `@hello-pangea/dnd` which handles keyboard focus and doesn't expect
a rerender. This falls back to `useEffect` for SSR to avoid console errors. `useEffect` will
be a no-op, same as `componentDidMount` */
usePortalEffect(() => {
const node = document.createElement('div');
node.dataset.euiportal = 'true';

if (insert == null) {
// no insertion defined, append to body
document.body.appendChild(portalNode);
document.body.appendChild(node);
} else {
// inserting before or after an element
const { sibling, position } = insert;
sibling.insertAdjacentElement(insertPositions[position], portalNode);
sibling.insertAdjacentElement(insertPositions[position], node);
}

this.setThemeColor(portalNode);
this.updatePortalRef(portalNode);
setThemeColor(node);
updatePortalRef(node);

// Update state with portalNode to intentionally trigger component rerender
// and call createPortal with correct root element in render()
this.setState({
portalNode,
});
}
// and call createPortal with the correct root element
setPortalNode(node);

componentWillUnmount() {
const { portalNode } = this.state;
if (portalNode?.parentNode) {
portalNode.parentNode.removeChild(portalNode);
}
this.updatePortalRef(null);
}

// Set the inherited color of the portal based on the wrapping EuiThemeProvider
private setThemeColor(portalNode: HTMLDivElement) {
if (this.context) {
const { hasDifferentColorFromGlobalTheme, colorClassName } = this.context;

if (hasDifferentColorFromGlobalTheme && this.props.insert == null) {
portalNode.classList.add(colorClassName);
return () => {
if (node?.parentNode) {
node.parentNode.removeChild(node);
}
}
}

private updatePortalRef(ref: HTMLDivElement | null) {
if (this.props.portalRef) {
this.props.portalRef(ref);
}
}
updatePortalRef(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- on mount only
}, []);
Comment thread
mgadewoll marked this conversation as resolved.

render() {
const { portalNode } = this.state;
if (!portalNode) {
return null;
}

if (!portalNode) {
return null;
}
return createPortal(children, portalNode);
});

return createPortal(this.props.children, portalNode);
}
}
EuiPortal.displayName = 'EuiPortal';
Loading