From 18c5d44f5fcaa262e0d034823365f0b417874391 Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Mon, 18 Nov 2024 13:33:54 -0600 Subject: [PATCH 1/4] Add inline portal node support This adds `createHtmlInlinePortalNode` to the public api, which creates a `` instead of `
` wrapper. This is helpful when portalling into phrasing content. For example, placing a portal inside `

` [0] Without this, React will emit hydration warnings. Resolves #44 [0] https://html.spec.whatwg.org/multipage/grouping-content.html#the-p-element --- src/index.tsx | 62 ++++++++++++++++++++++++++--------------- stories/html.stories.js | 19 ++++++++++++- 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 7c2fa0e..8225464 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,10 +2,11 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; // Internally, the portalNode must be for either HTML or SVG elements -const ELEMENT_TYPE_HTML = 'html'; +const ELEMENT_TYPE_HTML_BLOCK = 'div'; +const ELEMENT_TYPE_HTML_INLINE = 'span'; const ELEMENT_TYPE_SVG = 'svg'; -type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG; +type ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML_BLOCK | typeof ELEMENT_TYPE_HTML_INLINE | typeof ELEMENT_TYPE_SVG; type Options = { attributes: { [key: string]: string }; @@ -32,15 +33,19 @@ interface PortalNodeBase> { // latest placeholder we replaced. This avoids some race conditions. unmount(expectedPlaceholder?: Node): void; } -export interface HtmlPortalNode = Component> extends PortalNodeBase { +export interface HtmlBlockPortalNode = Component> extends PortalNodeBase { element: HTMLElement; - elementType: typeof ELEMENT_TYPE_HTML; + elementType: typeof ELEMENT_TYPE_HTML_BLOCK; +} +export interface HtmlInlinePortalNode = Component> extends PortalNodeBase { + element: HTMLElement; + elementType: typeof ELEMENT_TYPE_HTML_INLINE; } export interface SvgPortalNode = Component> extends PortalNodeBase { element: SVGElement; elementType: typeof ELEMENT_TYPE_SVG; } -type AnyPortalNode = Component> = HtmlPortalNode | SvgPortalNode; +type AnyPortalNode = Component> = HtmlBlockPortalNode | HtmlInlinePortalNode | SvgPortalNode; const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE) => { @@ -48,13 +53,16 @@ const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE) // Cast document to `any` because Typescript doesn't know about the legacy `Document.parentWindow` field, and also // doesn't believe `Window.HTMLElement`/`Window.SVGElement` can be used in instanceof tests. const ownerWindow = ownerDocument.defaultView ?? ownerDocument.parentWindow ?? window; // `parentWindow` for IE8 and earlier - if (elementType === ELEMENT_TYPE_HTML) { - return domElement instanceof ownerWindow.HTMLElement; - } - if (elementType === ELEMENT_TYPE_SVG) { - return domElement instanceof ownerWindow.SVGElement; + + switch (elementType) { + case ELEMENT_TYPE_HTML_BLOCK: + case ELEMENT_TYPE_HTML_INLINE: + return domElement instanceof ownerWindow.HTMLElement; + case ELEMENT_TYPE_SVG: + return domElement instanceof ownerWindow.SVGElement; + default: + throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`); } - throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`); }; // This is the internal implementation: the public entry points set elementType to an appropriate value @@ -68,12 +76,17 @@ const createPortalNode = >( let lastPlaceholder: Node | undefined; let element; - if (elementType === ELEMENT_TYPE_HTML) { - element= document.createElement('div'); - } else if (elementType === ELEMENT_TYPE_SVG){ - element= document.createElementNS(SVG_NAMESPACE, 'g'); - } else { - throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`); + + switch (elementType) { + case ELEMENT_TYPE_HTML_BLOCK: + case ELEMENT_TYPE_HTML_INLINE: + element = document.createElement(elementType); + break; + case ELEMENT_TYPE_SVG: + element = document.createElementNS(SVG_NAMESPACE, 'g'); + break; + default: + throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "div", "span" or "svg".`); } if (options && typeof options === "object") { @@ -186,7 +199,7 @@ type OutPortalProps> = { class OutPortal> extends React.PureComponent> { - private placeholderNode = React.createRef(); + private placeholderNode = React.createRef(); private currentPortalNode?: AnyPortalNode; constructor(props: OutPortalProps) { @@ -236,18 +249,23 @@ class OutPortal> extends React.PureComponent placeholder works fine even for SVG. - return

; + // A placeholder: + // - prevents invalid HTML (e.g. inside

) + // - works fine even for SVG. + return ; } } -const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as - = Component>(options?: Options) => HtmlPortalNode; +const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML_BLOCK) as + = Component>(options?: Options) => HtmlBlockPortalNode; +const createHtmlInlinePortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML_INLINE) as + = Component>(options?: Options) => HtmlInlinePortalNode; const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as = Component>(options?: Options) => SvgPortalNode; export { createHtmlPortalNode, + createHtmlInlinePortalNode, createSvgPortalNode, InPortal, OutPortal, diff --git a/stories/html.stories.js b/stories/html.stories.js index 1bf2986..22bfa69 100644 --- a/stories/html.stories.js +++ b/stories/html.stories.js @@ -2,7 +2,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { createHtmlPortalNode, createSvgPortalNode, InPortal, OutPortal } from '..'; +import { createHtmlPortalNode, createHtmlInlinePortalNode, InPortal, OutPortal } from '..'; const Container = (props) =>

@@ -289,6 +289,23 @@ storiesOf('Portals', module)
}); }) + .add('can render inline portal', () => { + const portalNode = createHtmlInlinePortalNode(); + + return
+

+ Portal defined here: + + Hi! + +

+ +

+ Portal renders here: + +

+
; + }) .add('Example from README', () => { const MyExpensiveComponent = () => 'expensive!'; From b1fb2b62998cbb2abf4bdedaeca00498fcca9c44 Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Mon, 18 Nov 2024 13:44:07 -0600 Subject: [PATCH 2/4] Keep exported types same --- src/index.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 8225464..446280e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,19 +33,15 @@ interface PortalNodeBase> { // latest placeholder we replaced. This avoids some race conditions. unmount(expectedPlaceholder?: Node): void; } -export interface HtmlBlockPortalNode = Component> extends PortalNodeBase { +export interface HtmlPortalNode = Component> extends PortalNodeBase { element: HTMLElement; - elementType: typeof ELEMENT_TYPE_HTML_BLOCK; -} -export interface HtmlInlinePortalNode = Component> extends PortalNodeBase { - element: HTMLElement; - elementType: typeof ELEMENT_TYPE_HTML_INLINE; + elementType: typeof ELEMENT_TYPE_HTML_BLOCK | typeof ELEMENT_TYPE_HTML_INLINE; } export interface SvgPortalNode = Component> extends PortalNodeBase { element: SVGElement; elementType: typeof ELEMENT_TYPE_SVG; } -type AnyPortalNode = Component> = HtmlBlockPortalNode | HtmlInlinePortalNode | SvgPortalNode; +type AnyPortalNode = Component> = HtmlPortalNode | SvgPortalNode; const validateElementType = (domElement: Element, elementType: ANY_ELEMENT_TYPE) => { @@ -257,9 +253,9 @@ class OutPortal> extends React.PureComponent = Component>(options?: Options) => HtmlBlockPortalNode; + = Component>(options?: Options) => HtmlPortalNode; const createHtmlInlinePortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML_INLINE) as - = Component>(options?: Options) => HtmlInlinePortalNode; + = Component>(options?: Options) => HtmlPortalNode; const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as = Component>(options?: Options) => SvgPortalNode; From 7335b7d4ec3739c24e9f80b219a287b65c55831f Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Mon, 18 Nov 2024 13:50:28 -0600 Subject: [PATCH 3/4] Enable noFallthroughCasesInSwitch for switches --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index e0bcc89..e8280bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "rootDir": "./src", "declaration": true, - "declarationDir": "./dist" + "declarationDir": "./dist", + "noFallthroughCasesInSwitch": true }, "include": [ "./src/**/*.tsx" From 6b41a07a8a550f494b0d5585781a426f00c26fba Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Tue, 19 Nov 2024 12:29:41 -0600 Subject: [PATCH 4/4] Update README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0d49163..335f88b 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,9 @@ const portalNode = portals.createHtmlPortalNode({ }); ``` -The div's DOM node is also available at `.element`, so you can mutate that directly with the standard DOM APIs if preferred. +### `portals.createHtmlInlinePortalNode([options])` + +Same as `portal.createHtmlPortalNode`, except it uses `` instead of `
`. This can be helpful to avoid invalid HTML when portalling videos, etc into [phrasing content](https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content), which is invalid HTML markup and triggers [React validateDOMNesting](https://www.dhiwise.com/post/mastering-validatedomnesting-best-practices). ### `portals.createSvgPortalNode([options])`