diff --git a/.changeset/portal-css-containment.md b/.changeset/portal-css-containment.md new file mode 100644 index 00000000000..c11b9f7be0c --- /dev/null +++ b/.changeset/portal-css-containment.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Added `primer_react_css_contain_portal` feature flag that applies CSS containment (`contain: layout style`) to Portal elements when enabled. This can improve rendering performance by isolating layout and style calculations within portals without clipping overflow content. diff --git a/packages/react/src/Portal/Portal.test.tsx b/packages/react/src/Portal/Portal.test.tsx index 7b4975b4616..05635e12396 100644 --- a/packages/react/src/Portal/Portal.test.tsx +++ b/packages/react/src/Portal/Portal.test.tsx @@ -1,9 +1,10 @@ -import {describe, expect, it} from 'vitest' -import Portal, {registerPortalRoot, PortalContext} from '../Portal/index' +import {describe, expect, it, vi, beforeEach} from 'vitest' +import Portal, {registerPortalRoot, PortalContext, resetCSSContainmentTracking} from '../Portal/index' import {render} from '@testing-library/react' import BaseStyles from '../BaseStyles' import React from 'react' +import {FeatureFlags} from '../FeatureFlags' describe('Portal', () => { it('renders a default portal into document.body (no BaseStyles present)', () => { @@ -183,4 +184,100 @@ describe('Portal', () => { document.body.removeChild(contextPortalRoot) document.body.removeChild(propPortalRoot) }) + + describe('CSS containment feature flag', () => { + beforeEach(() => { + // Reset containment tracking between tests + resetCSSContainmentTracking() + }) + + it('does not apply CSS containment by default', () => { + const {baseElement} = render(test-content) + const generatedRoot = baseElement.querySelector('#__primerPortalRoot__') as HTMLElement + + expect(generatedRoot).toBeInstanceOf(HTMLElement) + expect(generatedRoot.style.contain).toBe('') + + baseElement.innerHTML = '' + }) + + it('applies CSS containment when primer_react_css_contain_portal flag is enabled', () => { + const toRender = ( + + contained-content + + ) + + const {baseElement} = render(toRender) + const generatedRoot = baseElement.querySelector('#__primerPortalRoot__') as HTMLElement + + expect(generatedRoot).toBeInstanceOf(HTMLElement) + expect(generatedRoot.style.contain).toBe('layout style') + + baseElement.innerHTML = '' + }) + + it('does not apply CSS containment when flag is explicitly disabled', () => { + const toRender = ( + + uncontained-content + + ) + + const {baseElement} = render(toRender) + const generatedRoot = baseElement.querySelector('#__primerPortalRoot__') as HTMLElement + + expect(generatedRoot).toBeInstanceOf(HTMLElement) + expect(generatedRoot.style.contain).toBe('') + + baseElement.innerHTML = '' + }) + + it('applies CSS containment to pre-existing portal root', () => { + // Create a pre-existing portal root declaratively + const existingRoot = document.createElement('div') + existingRoot.id = '__primerPortalRoot__' + document.body.appendChild(existingRoot) + + const toRender = ( + + content-in-existing-root + + ) + + const {baseElement} = render(toRender) + const generatedRoot = baseElement.querySelector('#__primerPortalRoot__') as HTMLElement + + expect(generatedRoot).toBeInstanceOf(HTMLElement) + expect(generatedRoot.style.contain).toBe('layout style') + + baseElement.innerHTML = '' + document.body.removeChild(existingRoot) + }) + + it('warns when overriding existing contain value', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Create a pre-existing portal root with a different contain value + const existingRoot = document.createElement('div') + existingRoot.id = '__primerPortalRoot__' + existingRoot.style.contain = 'size' + document.body.appendChild(existingRoot) + + const toRender = ( + + content-with-override + + ) + + render(toRender) + + expect(warnSpy).toHaveBeenCalledWith( + 'Portal root already has contain: "size". Overriding with "layout style" due to primer_react_css_contain_portal flag.', + ) + + warnSpy.mockRestore() + document.body.removeChild(existingRoot) + }) + }) }) diff --git a/packages/react/src/Portal/Portal.tsx b/packages/react/src/Portal/Portal.tsx index 68797db5a15..c142ad21794 100644 --- a/packages/react/src/Portal/Portal.tsx +++ b/packages/react/src/Portal/Portal.tsx @@ -1,12 +1,25 @@ import React, {useContext} from 'react' import {createPortal} from 'react-dom' import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' +import {useFeatureFlag} from '../FeatureFlags' const PRIMER_PORTAL_ROOT_ID = '__primerPortalRoot__' const DEFAULT_PORTAL_CONTAINER_NAME = '__default__' const portalRootRegistry: Partial> = {} +// Track which portal roots have had CSS containment applied (auto-cleans when element is GC'd) +const cssContainmentApplied = new WeakMap() + +// Reset containment tracking (exported for testing) +export function resetCSSContainmentTracking(): void { + // WeakMap doesn't have a clear method, but we can reset by deleting the current portal root entry + const portalRoot = portalRootRegistry[DEFAULT_PORTAL_CONTAINER_NAME] + if (portalRoot) { + cssContainmentApplied.delete(portalRoot) + } +} + /** * Register a container to serve as a portal root. * @param root The element that will be the root for portals created in this container @@ -20,7 +33,7 @@ export function registerPortalRoot(root: Element, name = DEFAULT_PORTAL_CONTAINE // Ensures that a default portal root exists and is registered. If a DOM element exists // with id __primerPortalRoot__, allow that element to serve as the default portal root. // Otherwise, create that element and attach it to the end of document.body. -function ensureDefaultPortal() { +function ensureDefaultPortal(enableCSSContainment = false) { const existingDefaultPortalContainer = portalRootRegistry[DEFAULT_PORTAL_CONTAINER_NAME] if (!existingDefaultPortalContainer || !document.body.contains(existingDefaultPortalContainer)) { let defaultPortalContainer = document.getElementById(PRIMER_PORTAL_ROOT_ID) @@ -41,6 +54,22 @@ function ensureDefaultPortal() { registerPortalRoot(defaultPortalContainer) } + + // Apply CSS containment to the portal root if enabled (only once per root) + if (enableCSSContainment) { + const portalRoot = portalRootRegistry[DEFAULT_PORTAL_CONTAINER_NAME] + if (portalRoot instanceof HTMLElement && !cssContainmentApplied.has(portalRoot)) { + const existingContain = portalRoot.style.contain + if (existingContain && existingContain !== 'layout style') { + // eslint-disable-next-line no-console + console.warn( + `Portal root already has contain: "${existingContain}". Overriding with "layout style" due to primer_react_css_contain_portal flag.`, + ) + } + portalRoot.style.contain = 'layout style' + cssContainmentApplied.set(portalRoot, true) + } + } } /** @@ -75,6 +104,7 @@ export const Portal: React.FC> = ({ containerName: _containerName, }) => { const {portalContainerName} = useContext(PortalContext) + const enableCSSContainment = useFeatureFlag('primer_react_css_contain_portal') const elementRef = React.useRef(null) if (!elementRef.current) { const div = document.createElement('div') @@ -92,7 +122,7 @@ export const Portal: React.FC> = ({ let containerName = _containerName ?? portalContainerName if (containerName === undefined) { containerName = DEFAULT_PORTAL_CONTAINER_NAME - ensureDefaultPortal() + ensureDefaultPortal(enableCSSContainment) } const parentElement = portalRootRegistry[containerName] diff --git a/packages/react/src/Portal/index.ts b/packages/react/src/Portal/index.ts index 375dce81355..493f74ac3b5 100644 --- a/packages/react/src/Portal/index.ts +++ b/packages/react/src/Portal/index.ts @@ -1,7 +1,7 @@ import type {PortalProps} from './Portal' -import {Portal, registerPortalRoot, PortalContext} from './Portal' +import {Portal, registerPortalRoot, PortalContext, resetCSSContainmentTracking} from './Portal' export default Portal -export {registerPortalRoot} +export {registerPortalRoot, resetCSSContainmentTracking} export type {PortalProps} export {PortalContext}