Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Aug 24, 2022
Merged
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
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