Skip to content

Commit

Permalink
[core] fix: partially revert #5493, create Portal2 (#5512)
Browse files Browse the repository at this point in the history
  • Loading branch information
adidahiya authored Aug 24, 2022
1 parent db00225 commit 0bed48f
Show file tree
Hide file tree
Showing 5 changed files with 221 additions and 68 deletions.
6 changes: 6 additions & 0 deletions packages/core/karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ module.exports = function (config) {
"src/common/abstractComponent*",
"src/common/abstractPureComponent*",
"src/compatibility/*",

// HACKHACK: for karma upgrade only
"src/common/refs.ts",

// HACKHACK: need to add hotkeys v2 tests
"src/components/hotkeys/hotkeysDialog2.tsx",
"src/components/hotkeys/hotkeysTarget2.tsx",
"src/context/hotkeys/hotkeysProvider.tsx",

// HACKHACK: see https://github.com/palantir/blueprint/issues/5511
"src/components/portal/portal2.tsx",
"src/context/portal/portalProvider.tsx",
];

const baseConfig = createKarmaConfig({
Expand Down
16 changes: 9 additions & 7 deletions packages/core/src/components/portal/portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ For the most part, Portal is a thin wrapper around [`ReactDOM.createPortal`](htt

@## React context (legacy)

<div class="@ns-callout @ns-intent-danger @ns-icon-error">
<div class="@ns-callout @ns-intent-warning @ns-icon-warning-sign">
<h4 class="@ns-heading">

Deprecated: use [PortalProvider](#core/context/portal-provider)
React legacy API

</h4>

This API is **deprecated since @blueprintjs/core v4.8.0** in favor of
[PortalProvider](#core/context/portal-provider), which uses the
[newer React context API](https://reactjs.org/docs/context.html).
This feature uses React's legacy context API. Support for the
[newer React context API](https://reactjs.org/docs/context.html) will be coming soon
in Blueprint v5.x.

</div>

Expand All @@ -27,12 +27,14 @@ To use them, supply a child context to a subtree that contains the Portals you w

@interface PortalLegacyContext

<!--
@## React context
Portal supports the following options on its [React context](https://reactjs.org/docs/context.html)
via [PortalProvider](#core/context/portal-provider).
-->

@interface PortalContextOptions
<!-- @interface PortalContextOptions -->

@## Props

Expand All @@ -43,7 +45,7 @@ child of the `<body>`.
Portal is used inside [Overlay](#core/components/overlay) to actually overlay the content on the
application.

<div class="@ns-callout @ns-intent-warning @ns-icon-warning-sign">
<div class="@ns-callout @ns-intent-warning @ns-icon-move">
<h4 class="@ns-heading">A note about responsive layouts</h4>

For a single-page app, if the `<body>` is styled with `width: 100%` and `height: 100%`, a `Portal`
Expand Down
112 changes: 52 additions & 60 deletions packages/core/src/components/portal/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import * as Classes from "../../common/classes";
import { ValidationMap } from "../../common/context";
import * as Errors from "../../common/errors";
import { DISPLAYNAME_PREFIX, Props } from "../../common/props";
import { PortalContext } from "../../context/portal/portalProvider";
import { usePrevious } from "../../hooks/usePrevious";

// eslint-disable-next-line deprecation/deprecation
export type PortalProps = IPortalProps;
Expand All @@ -44,7 +42,11 @@ export interface IPortalProps extends Props {
container?: HTMLElement;
}

/** @deprecated use PortalProvider */
export interface IPortalState {
hasMounted: boolean;
}

/** @deprecated use PortalLegacyContext */
export type IPortalContext = PortalLegacyContext;
export interface PortalLegacyContext {
/** Additional CSS classes to add to all `Portal` elements in this React context. */
Expand All @@ -65,74 +67,64 @@ const REACT_CONTEXT_TYPES: ValidationMap<PortalLegacyContext> = {
* Use it when you need to circumvent DOM z-stacking (for dialogs, popovers, etc.).
* Any class names passed to this element will be propagated to the new container element on document.body.
*/
export function Portal(props: PortalProps, legacyContext: PortalLegacyContext = {}) {
const context = React.useContext(PortalContext);
export class Portal extends React.Component<PortalProps, IPortalState> {
public static displayName = `${DISPLAYNAME_PREFIX}.Portal`;

const [hasMounted, setHasMounted] = React.useState(false);
const [portalElement, setPortalElement] = React.useState<HTMLElement>();
public static contextTypes = REACT_CONTEXT_TYPES;

const createContainerElement = React.useCallback(() => {
const container = document.createElement("div");
container.classList.add(Classes.PORTAL);
maybeAddClass(container.classList, props.className); // directly added to this portal element
maybeAddClass(container.classList, context.portalClassName); // added via PortalProvider context
public static defaultProps: Partial<PortalProps> = {
container: typeof document !== "undefined" ? document.body : undefined,
};

const { blueprintPortalClassName } = legacyContext;
if (blueprintPortalClassName != null && blueprintPortalClassName !== "") {
console.error(Errors.PORTAL_LEGACY_CONTEXT_API);
maybeAddClass(container.classList, blueprintPortalClassName); // added via legacy context
}
public context: PortalLegacyContext = {};

return container;
}, [props.className, context.portalClassName]);
public state: IPortalState = { hasMounted: false };

private portalElement: HTMLElement | null = null;

public render() {
// Only render `children` once this component has mounted in a browser environment, so they are
// immediately attached to the DOM tree and can do DOM things like measuring or `autoFocus`.
// See long comment on componentDidMount in https://reactjs.org/docs/portals.html#event-bubbling-through-portals
if (typeof document === "undefined" || !this.state.hasMounted || this.portalElement === null) {
return null;
} else {
return ReactDOM.createPortal(this.props.children, this.portalElement);
}
}

// create the container element & attach it to the DOM
React.useEffect(() => {
if (props.container == null) {
public componentDidMount() {
if (this.props.container == null) {
return;
}
const newPortalElement = createContainerElement();
props.container.appendChild(newPortalElement);
setPortalElement(newPortalElement);
setHasMounted(true);

return () => {
newPortalElement.remove();
setHasMounted(false);
setPortalElement(undefined);
};
}, [props.container, createContainerElement]);

// wait until next successful render to invoke onChildrenMount callback
React.useEffect(() => {
if (hasMounted) {
props.onChildrenMount?.();
this.portalElement = this.createContainerElement();
this.props.container.appendChild(this.portalElement);
/* eslint-disable-next-line react/no-did-mount-set-state */
this.setState({ hasMounted: true }, this.props.onChildrenMount);
}

public componentDidUpdate(prevProps: PortalProps) {
// update className prop on portal DOM element
if (this.portalElement != null && prevProps.className !== this.props.className) {
maybeRemoveClass(this.portalElement.classList, prevProps.className);
maybeAddClass(this.portalElement.classList, this.props.className);
}
}, [hasMounted, props.onChildrenMount]);

// update className prop on portal DOM element when props change
const prevClassName = usePrevious(props.className);
React.useEffect(() => {
if (portalElement != null) {
maybeRemoveClass(portalElement.classList, prevClassName);
maybeAddClass(portalElement.classList, props.className);
}

public componentWillUnmount() {
this.portalElement?.remove();
}

private createContainerElement() {
const container = document.createElement("div");
container.classList.add(Classes.PORTAL);
maybeAddClass(container.classList, this.props.className);
if (this.context != null) {
maybeAddClass(container.classList, this.context.blueprintPortalClassName);
}
}, [props.className]);

// Only render `children` once this component has mounted in a browser environment, so they are
// immediately attached to the DOM tree and can do DOM things like measuring or `autoFocus`.
// See long comment on componentDidMount in https://reactjs.org/docs/portals.html#event-bubbling-through-portals
if (typeof document === "undefined" || !hasMounted || portalElement == null) {
return null;
} else {
return ReactDOM.createPortal(props.children, portalElement);
return container;
}
}
Portal.defaultProps = {
container: typeof document !== "undefined" ? document.body : undefined,
};
Portal.displayName = `${DISPLAYNAME_PREFIX}.Portal`;
Portal.contextTypes = REACT_CONTEXT_TYPES;

function maybeRemoveClass(classList: DOMTokenList, className?: string) {
if (className != null && className !== "") {
Expand Down
152 changes: 152 additions & 0 deletions packages/core/src/components/portal/portal2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Copyright 2022 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* @fileoverview This is the next version of <Portal>, reimplemented as a function component.
*
* It supports both the newer React context API and the legacy context API. Support for the legacy context API
* will be removed in Blueprint v6.0.
*
* Portal2 is not currently used anywhere in Blueprint. We had to revert the change which updated the standard
* <Portal> to use this implementation because of subtle breaks caused by interactions with the (long-deprecated)
* react-hot-loader library. To be safe, we've left Portal as a class component for now, and will promote this Portal2
* implementation to be the standard Portal in Blueprint v5.0.
*
* @see https://github.com/palantir/blueprint/issues/5511
*/

import * as React from "react";
import * as ReactDOM from "react-dom";

import * as Classes from "../../common/classes";
import { ValidationMap } from "../../common/context";
import * as Errors from "../../common/errors";
import { DISPLAYNAME_PREFIX, Props } from "../../common/props";
import { PortalContext } from "../../context/portal/portalProvider";
import { usePrevious } from "../../hooks/usePrevious";
import type { PortalLegacyContext } from "./portal";

export interface Portal2Props extends Props {
/** Contents to send through the portal. */
children: React.ReactNode;

/**
* Callback invoked when the children of this `Portal` have been added to the DOM.
*/
onChildrenMount?: () => void;

/**
* The HTML element that children will be mounted to.
*
* @default document.body
*/
container?: HTMLElement;
}

const REACT_CONTEXT_TYPES: ValidationMap<PortalLegacyContext> = {
blueprintPortalClassName: (obj: PortalLegacyContext, key: keyof PortalLegacyContext) => {
if (obj[key] != null && typeof obj[key] !== "string") {
return new Error(Errors.PORTAL_CONTEXT_CLASS_NAME_STRING);
}
return undefined;
},
};

/**
* This component detaches its contents and re-attaches them to document.body.
* Use it when you need to circumvent DOM z-stacking (for dialogs, popovers, etc.).
* Any class names passed to this element will be propagated to the new container element on document.body.
*/
export function Portal2(props: Portal2Props, legacyContext: PortalLegacyContext = {}) {
const context = React.useContext(PortalContext);

const [hasMounted, setHasMounted] = React.useState(false);
const [portalElement, setPortalElement] = React.useState<HTMLElement>();

const createContainerElement = React.useCallback(() => {
const container = document.createElement("div");
container.classList.add(Classes.PORTAL);
maybeAddClass(container.classList, props.className); // directly added to this portal element
maybeAddClass(container.classList, context.portalClassName); // added via PortalProvider context

const { blueprintPortalClassName } = legacyContext;
if (blueprintPortalClassName != null && blueprintPortalClassName !== "") {
console.error(Errors.PORTAL_LEGACY_CONTEXT_API);
maybeAddClass(container.classList, blueprintPortalClassName); // added via legacy context
}

return container;
}, [props.className, context.portalClassName]);

// create the container element & attach it to the DOM
React.useEffect(() => {
if (props.container == null) {
return;
}
const newPortalElement = createContainerElement();
props.container.appendChild(newPortalElement);
setPortalElement(newPortalElement);
setHasMounted(true);

return () => {
newPortalElement.remove();
setHasMounted(false);
setPortalElement(undefined);
};
}, [props.container, createContainerElement]);

// wait until next successful render to invoke onChildrenMount callback
React.useEffect(() => {
if (hasMounted) {
props.onChildrenMount?.();
}
}, [hasMounted, props.onChildrenMount]);

// update className prop on portal DOM element when props change
const prevClassName = usePrevious(props.className);
React.useEffect(() => {
if (portalElement != null) {
maybeRemoveClass(portalElement.classList, prevClassName);
maybeAddClass(portalElement.classList, props.className);
}
}, [props.className]);

// Only render `children` once this component has mounted in a browser environment, so they are
// immediately attached to the DOM tree and can do DOM things like measuring or `autoFocus`.
// See long comment on componentDidMount in https://reactjs.org/docs/portals.html#event-bubbling-through-portals
if (typeof document === "undefined" || !hasMounted || portalElement == null) {
return null;
} else {
return ReactDOM.createPortal(props.children, portalElement);
}
}
Portal2.defaultProps = {
container: typeof document !== "undefined" ? document.body : undefined,
};
Portal2.displayName = `${DISPLAYNAME_PREFIX}.Portal2`;
Portal2.contextTypes = REACT_CONTEXT_TYPES;

function maybeRemoveClass(classList: DOMTokenList, className?: string) {
if (className != null && className !== "") {
classList.remove(...className.split(" "));
}
}

function maybeAddClass(classList: DOMTokenList, className?: string) {
if (className != null && className !== "") {
classList.add(...className.split(" "));
}
}
3 changes: 2 additions & 1 deletion packages/core/test/portal/portalTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ describe("<Portal>", () => {
assert.isTrue(portalElement?.classList.contains(Classes.PORTAL));
});

it("respects portalClassName on new context API", () => {
// HACKHACK, see https://github.com/palantir/blueprint/issues/5511
it.skip("respects portalClassName on new context API", () => {
const CLASS_TO_TEST = "bp-test-klass bp-other-class";
portal = mount(
<PortalProvider portalClassName={CLASS_TO_TEST}>
Expand Down

1 comment on commit 0bed48f

@blueprint-bot
Copy link

Choose a reason for hiding this comment

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

[core] fix: partially revert #5493, create Portal2 (#5512)

Previews: documentation | landing | table | demo

Please sign in to comment.