From 7ea95a30ff56b5b76a2f3ab4ba0f1070dcb9dc17 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 22 Nov 2016 22:08:13 +0000 Subject: [PATCH] [Fiber] Add ReactDOMFiber.unstable_createPortal() (#8386) * [Fiber] Add ReactDOMFiber.unstable_createPortal() While #8368 added a version of `ReactDOM.unstable_renderSubtreeIntoContainer()` to Fiber, it is a bit hacky and, more importantly, incompatible with Fiber goals. Since it encourages performing portal work in lifecycles, it stretches the commit phase and prevents slicing that work, potentially negating Fiber benefits. This PR adds a first version of a declarative API meant to replace `ReactDOM.unstable_renderSubtreeIntoContainer()`. The API is a declarative way to render subtrees into DOM node containers. * Remove hacks related to output field --- scripts/fiber/tests-passing.txt | 4 + src/renderers/dom/fiber/ReactDOMFiber.js | 7 + .../dom/fiber/__tests__/ReactDOMFiber-test.js | 221 ++++++++++++++++++ src/renderers/shared/fiber/ReactChildFiber.js | 89 +++++++ src/renderers/shared/fiber/ReactFiber.js | 13 ++ .../shared/fiber/ReactFiberBeginWork.js | 9 + .../shared/fiber/ReactFiberCommitWork.js | 16 ++ .../shared/fiber/ReactFiberCompleteWork.js | 6 + src/renderers/shared/fiber/ReactTypeOfWork.js | 3 +- .../shared/fiber/isomorphic/ReactPortal.js | 60 +++++ .../shared/fiber/isomorphic/ReactTypes.js | 3 +- 11 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 src/renderers/shared/fiber/isomorphic/ReactPortal.js diff --git a/scripts/fiber/tests-passing.txt b/scripts/fiber/tests-passing.txt index 3cec34968c00b..080b89a7dfcbd 100644 --- a/scripts/fiber/tests-passing.txt +++ b/scripts/fiber/tests-passing.txt @@ -488,6 +488,10 @@ src/renderers/dom/fiber/__tests__/ReactDOMFiber-test.js * finds the first child when a component returns a fragment * finds the first child even when fragment is nested * finds the first child even when first child renders null +* should render portal children +* should pass portal context when rendering subtree elsewhere +* should update portal context if it changes due to setState +* should update portal context if it changes due to re-render src/renderers/dom/shared/__tests__/CSSProperty-test.js * should generate browser prefixes for its `isUnitlessNumber` diff --git a/src/renderers/dom/fiber/ReactDOMFiber.js b/src/renderers/dom/fiber/ReactDOMFiber.js index 331646c1529c3..4cc6c2ac913e1 100644 --- a/src/renderers/dom/fiber/ReactDOMFiber.js +++ b/src/renderers/dom/fiber/ReactDOMFiber.js @@ -14,6 +14,7 @@ import type { Fiber } from 'ReactFiber'; import type { HostChildren } from 'ReactFiberReconciler'; +import type { ReactNodeList } from 'ReactTypes'; var ReactControlledComponent = require('ReactControlledComponent'); var ReactDOMComponentTree = require('ReactDOMComponentTree'); @@ -22,6 +23,7 @@ var ReactDOMFiberComponent = require('ReactDOMFiberComponent'); var ReactDOMInjection = require('ReactDOMInjection'); var ReactFiberReconciler = require('ReactFiberReconciler'); var ReactInstanceMap = require('ReactInstanceMap'); +var ReactPortal = require('ReactPortal'); var findDOMNode = require('findDOMNode'); var invariant = require('invariant'); @@ -192,6 +194,11 @@ var ReactDOM = { findDOMNode: findDOMNode, + unstable_createPortal(children: ReactNodeList, container : DOMContainerElement, key : ?string = null) { + // TODO: pass ReactDOM portal implementation as third argument + return ReactPortal.createPortal(children, container, null, key); + }, + unstable_batchedUpdates(fn : () => A) : A { return DOMRenderer.batchedUpdates(fn); }, diff --git a/src/renderers/dom/fiber/__tests__/ReactDOMFiber-test.js b/src/renderers/dom/fiber/__tests__/ReactDOMFiber-test.js index f7f44bff0252a..84e6de3271577 100644 --- a/src/renderers/dom/fiber/__tests__/ReactDOMFiber-test.js +++ b/src/renderers/dom/fiber/__tests__/ReactDOMFiber-test.js @@ -186,4 +186,225 @@ describe('ReactDOMFiber', () => { expect(firstNode.tagName).toBe('DIV'); }); } + + if (ReactDOMFeatureFlags.useFiber) { + it('should render portal children', () => { + var portalContainer1 = document.createElement('div'); + var portalContainer2 = document.createElement('div'); + + var ops = []; + class Child extends React.Component { + componentDidMount() { + ops.push(`${this.props.name} componentDidMount`); + } + componentDidUpdate() { + ops.push(`${this.props.name} componentDidUpdate`); + } + componentWillUnmount() { + ops.push(`${this.props.name} componentWillUnmount`); + } + render() { + return
{this.props.name}
; + } + } + + class Parent extends React.Component { + componentDidMount() { + ops.push(`Parent:${this.props.step} componentDidMount`); + } + componentDidUpdate() { + ops.push(`Parent:${this.props.step} componentDidUpdate`); + } + componentWillUnmount() { + ops.push(`Parent:${this.props.step} componentWillUnmount`); + } + render() { + const {step} = this.props; + return [ + , + ReactDOM.unstable_createPortal( + , + portalContainer1 + ), + , + ReactDOM.unstable_createPortal( + [ + , + , + ], + portalContainer2 + ), + ]; + } + } + + ReactDOM.render(, container); + expect(portalContainer1.innerHTML).toBe('
portal1[0]:a
'); + expect(portalContainer2.innerHTML).toBe('
portal2[0]:a
portal2[1]:a
'); + expect(container.innerHTML).toBe('
normal[0]:a
normal[1]:a
'); + expect(ops).toEqual([ + 'normal[0]:a componentDidMount', + 'portal1[0]:a componentDidMount', + 'normal[1]:a componentDidMount', + 'portal2[0]:a componentDidMount', + 'portal2[1]:a componentDidMount', + 'Parent:a componentDidMount', + ]); + + ops.length = 0; + ReactDOM.render(, container); + expect(portalContainer1.innerHTML).toBe('
portal1[0]:b
'); + expect(portalContainer2.innerHTML).toBe('
portal2[0]:b
portal2[1]:b
'); + expect(container.innerHTML).toBe('
normal[0]:b
normal[1]:b
'); + expect(ops).toEqual([ + 'normal[0]:b componentDidUpdate', + 'portal1[0]:b componentDidUpdate', + 'normal[1]:b componentDidUpdate', + 'portal2[0]:b componentDidUpdate', + 'portal2[1]:b componentDidUpdate', + 'Parent:b componentDidUpdate', + ]); + + ops.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(portalContainer1.innerHTML).toBe(''); + expect(portalContainer2.innerHTML).toBe(''); + expect(container.innerHTML).toBe(''); + expect(ops).toEqual([ + 'Parent:b componentWillUnmount', + 'normal[0]:b componentWillUnmount', + 'portal1[0]:b componentWillUnmount', + 'normal[1]:b componentWillUnmount', + 'portal2[0]:b componentWillUnmount', + 'portal2[1]:b componentWillUnmount', + ]); + }); + + it('should pass portal context when rendering subtree elsewhere', () => { + var portalContainer = document.createElement('div'); + + class Component extends React.Component { + static contextTypes = { + foo: React.PropTypes.string.isRequired, + }; + + render() { + return
{this.context.foo}
; + } + } + + class Parent extends React.Component { + static childContextTypes = { + foo: React.PropTypes.string.isRequired, + }; + + getChildContext() { + return { + foo: 'bar', + }; + } + + render() { + return ReactDOM.unstable_createPortal( + , + portalContainer + ); + } + } + + ReactDOM.render(, container); + expect(container.innerHTML).toBe(''); + expect(portalContainer.innerHTML).toBe('
bar
'); + }); + + it('should update portal context if it changes due to setState', () => { + var portalContainer = document.createElement('div'); + + class Component extends React.Component { + static contextTypes = { + foo: React.PropTypes.string.isRequired, + getFoo: React.PropTypes.func.isRequired, + }; + + render() { + return
{this.context.foo + '-' + this.context.getFoo()}
; + } + } + + class Parent extends React.Component { + static childContextTypes = { + foo: React.PropTypes.string.isRequired, + getFoo: React.PropTypes.func.isRequired, + }; + + state = { + bar: 'initial', + }; + + getChildContext() { + return { + foo: this.state.bar, + getFoo: () => this.state.bar, + }; + } + + render() { + return ReactDOM.unstable_createPortal( + , + portalContainer + ); + } + } + + var instance = ReactDOM.render(, container); + expect(portalContainer.innerHTML).toBe('
initial-initial
'); + expect(container.innerHTML).toBe(''); + instance.setState({bar: 'changed'}); + expect(portalContainer.innerHTML).toBe('
changed-changed
'); + expect(container.innerHTML).toBe(''); + }); + + it('should update portal context if it changes due to re-render', () => { + var portalContainer = document.createElement('div'); + + class Component extends React.Component { + static contextTypes = { + foo: React.PropTypes.string.isRequired, + getFoo: React.PropTypes.func.isRequired, + }; + + render() { + return
{this.context.foo + '-' + this.context.getFoo()}
; + } + } + + class Parent extends React.Component { + static childContextTypes = { + foo: React.PropTypes.string.isRequired, + getFoo: React.PropTypes.func.isRequired, + }; + + getChildContext() { + return { + foo: this.props.bar, + getFoo: () => this.props.bar, + }; + } + + render() { + return ReactDOM.unstable_createPortal( + , + portalContainer + ); + } + } + + ReactDOM.render(, container); + expect(portalContainer.innerHTML).toBe('
initial-initial
'); + expect(container.innerHTML).toBe(''); + ReactDOM.render(, container); + expect(portalContainer.innerHTML).toBe('
changed-changed
'); + expect(container.innerHTML).toBe(''); + }); + } }); diff --git a/src/renderers/shared/fiber/ReactChildFiber.js b/src/renderers/shared/fiber/ReactChildFiber.js index 7385372dd86cf..fad819060145e 100644 --- a/src/renderers/shared/fiber/ReactChildFiber.js +++ b/src/renderers/shared/fiber/ReactChildFiber.js @@ -13,6 +13,7 @@ 'use strict'; import type { ReactCoroutine, ReactYield } from 'ReactCoroutine'; +import type { ReactPortal } from 'ReactPortal'; import type { Fiber } from 'ReactFiber'; import type { PriorityLevel } from 'ReactPriorityLevel'; @@ -21,6 +22,9 @@ var { REACT_COROUTINE_TYPE, REACT_YIELD_TYPE, } = require('ReactCoroutine'); +var { + REACT_PORTAL_TYPE, +} = require('ReactPortal'); var ReactFiber = require('ReactFiber'); var ReactReifiedYield = require('ReactReifiedYield'); @@ -37,6 +41,7 @@ const { createFiberFromText, createFiberFromCoroutine, createFiberFromYield, + createFiberFromPortal, } = ReactFiber; const { @@ -52,6 +57,7 @@ const { CoroutineComponent, YieldComponent, Fragment, + Portal, } = ReactTypeOfWork; const { @@ -303,6 +309,31 @@ function ChildReconciler(shouldClone, shouldTrackSideEffects) { } } + function updatePortal( + returnFiber : Fiber, + current : ?Fiber, + portal : ReactPortal, + priority : PriorityLevel + ) : Fiber { + if ( + current == null || + current.tag !== Portal || + current.stateNode.containerInfo !== portal.containerInfo || + current.stateNode.implementation !== portal.implementation + ) { + // Insert + const created = createFiberFromPortal(portal, priority); + created.return = returnFiber; + return created; + } else { + // Update + const existing = useFiber(current, priority); + existing.pendingProps = portal.children; + existing.return = returnFiber; + return existing; + } + } + function updateFragment( returnFiber : Fiber, current : ?Fiber, @@ -359,6 +390,12 @@ function ChildReconciler(shouldClone, shouldTrackSideEffects) { created.return = returnFiber; return created; } + + case REACT_PORTAL_TYPE: { + const created = createFiberFromPortal(newChild, priority); + created.return = returnFiber; + return created; + } } if (isArray(newChild) || getIteratorFn(newChild)) { @@ -468,6 +505,13 @@ function ChildReconciler(shouldClone, shouldTrackSideEffects) { ) || null; return updateYield(returnFiber, matchedFiber, newChild, priority); } + + case REACT_PORTAL_TYPE: { + const matchedFiber = existingChildren.get( + newChild.key === null ? newIdx : newChild.key + ) || null; + return updatePortal(returnFiber, matchedFiber, newChild, priority); + } } if (isArray(newChild) || getIteratorFn(newChild)) { @@ -769,6 +813,43 @@ function ChildReconciler(shouldClone, shouldTrackSideEffects) { return created; } + function reconcileSinglePortal( + returnFiber : Fiber, + currentFirstChild : ?Fiber, + portal : ReactPortal, + priority : PriorityLevel + ) : Fiber { + const key = portal.key; + let child = currentFirstChild; + while (child) { + // TODO: If key === null and child.key === null, then this only applies to + // the first item in the list. + if (child.key === key) { + if ( + child.tag === Portal && + child.stateNode.containerInfo === portal.containerInfo && + child.stateNode.implementation === portal.implementation + ) { + deleteRemainingChildren(returnFiber, child.sibling); + const existing = useFiber(child, priority); + existing.pendingProps = portal.children; + existing.return = returnFiber; + return existing; + } else { + deleteRemainingChildren(returnFiber, child); + break; + } + } else { + deleteChild(returnFiber, child); + } + child = child.sibling; + } + + const created = createFiberFromPortal(portal, priority); + created.return = returnFiber; + return created; + } + // This API will tag the children with the side-effect of the reconciliation // itself. They will be added to the side-effect list as we pass through the // children and the parent. @@ -817,6 +898,14 @@ function ChildReconciler(shouldClone, shouldTrackSideEffects) { newChild, priority )); + + case REACT_PORTAL_TYPE: + return placeSingleChild(reconcileSinglePortal( + returnFiber, + currentFirstChild, + newChild, + priority + )); } if (isArray(newChild)) { diff --git a/src/renderers/shared/fiber/ReactFiber.js b/src/renderers/shared/fiber/ReactFiber.js index 19e42dfbe8759..349826a4e3cbb 100644 --- a/src/renderers/shared/fiber/ReactFiber.js +++ b/src/renderers/shared/fiber/ReactFiber.js @@ -14,6 +14,7 @@ import type { ReactFragment } from 'ReactTypes'; import type { ReactCoroutine, ReactYield } from 'ReactCoroutine'; +import type { ReactPortal } from 'ReactPortal'; import type { TypeOfWork } from 'ReactTypeOfWork'; import type { TypeOfSideEffect } from 'ReactTypeOfSideEffect'; import type { PriorityLevel } from 'ReactPriorityLevel'; @@ -29,6 +30,7 @@ var { CoroutineComponent, YieldComponent, Fragment, + Portal, } = ReactTypeOfWork; var { @@ -338,3 +340,14 @@ exports.createFiberFromYield = function(yieldNode : ReactYield, priorityLevel : fiber.pendingProps = {}; return fiber; }; + +exports.createFiberFromPortal = function(portal : ReactPortal, priorityLevel : PriorityLevel) : Fiber { + const fiber = createFiber(Portal, portal.key); + fiber.pendingProps = portal.children; + fiber.pendingWorkPriority = priorityLevel; + fiber.stateNode = { + containerInfo: portal.containerInfo, + implementation: portal.implementation, + }; + return fiber; +}; diff --git a/src/renderers/shared/fiber/ReactFiberBeginWork.js b/src/renderers/shared/fiber/ReactFiberBeginWork.js index 368bc19d06576..1a492a46dd14d 100644 --- a/src/renderers/shared/fiber/ReactFiberBeginWork.js +++ b/src/renderers/shared/fiber/ReactFiberBeginWork.js @@ -45,6 +45,7 @@ var { CoroutineHandlerPhase, YieldComponent, Fragment, + Portal, } = ReactTypeOfWork; var { NoWork, @@ -298,6 +299,10 @@ module.exports = function( reconcileChildren(current, workInProgress, coroutine.children); } + function updatePortalComponent(current, workInProgress) { + reconcileChildren(current, workInProgress, workInProgress.pendingProps); + } + /* function reuseChildrenEffects(returnFiber : Fiber, firstChild : Fiber) { let child = firstChild; @@ -450,6 +455,10 @@ module.exports = function( // A yield component is just a placeholder, we can just run through the // next one immediately. return null; + case Portal: + updatePortalComponent(current, workInProgress); + // TODO: is this right? + return workInProgress.child; case Fragment: updateFragment(current, workInProgress); return workInProgress.child; diff --git a/src/renderers/shared/fiber/ReactFiberCommitWork.js b/src/renderers/shared/fiber/ReactFiberCommitWork.js index a503ddd7007a6..6886f5ce2e9b8 100644 --- a/src/renderers/shared/fiber/ReactFiberCommitWork.js +++ b/src/renderers/shared/fiber/ReactFiberCommitWork.js @@ -22,6 +22,7 @@ var { HostContainer, HostComponent, HostText, + Portal, } = ReactTypeOfWork; var { callCallbacks } = require('ReactFiberUpdateQueue'); @@ -253,6 +254,11 @@ module.exports = function( detachRef(current); return; } + case Portal: { + const containerInfo : C = current.stateNode.containerInfo; + updateContainer(containerInfo, null); + return; + } } } @@ -291,6 +297,12 @@ module.exports = function( commitTextUpdate(textInstance, oldText, newText); return; } + case Portal: { + const children = finishedWork.child; + const containerInfo : C = finishedWork.stateNode.containerInfo; + updateContainer(containerInfo, children); + return; + } default: throw new Error('This unit of work tag should not have side-effects.'); } @@ -353,6 +365,10 @@ module.exports = function( // We have no life-cycles associated with text. return; } + case Portal: { + // We have no life-cycles associated with portals. + return; + } default: throw new Error('This unit of work tag should not have side-effects.'); } diff --git a/src/renderers/shared/fiber/ReactFiberCompleteWork.js b/src/renderers/shared/fiber/ReactFiberCompleteWork.js index 544245f11d418..dc4ab68443318 100644 --- a/src/renderers/shared/fiber/ReactFiberCompleteWork.js +++ b/src/renderers/shared/fiber/ReactFiberCompleteWork.js @@ -36,6 +36,7 @@ var { CoroutineHandlerPhase, YieldComponent, Fragment, + Portal, } = ReactTypeOfWork; var { Update, @@ -251,6 +252,11 @@ module.exports = function(config : HostConfig) { case Fragment: transferOutput(workInProgress.child, workInProgress); return null; + case Portal: + markUpdate(workInProgress); + workInProgress.output = null; + workInProgress.memoizedProps = workInProgress.pendingProps; + return null; // Error cases case IndeterminateComponent: diff --git a/src/renderers/shared/fiber/ReactTypeOfWork.js b/src/renderers/shared/fiber/ReactTypeOfWork.js index 97830a8aea291..8098417683bf1 100644 --- a/src/renderers/shared/fiber/ReactTypeOfWork.js +++ b/src/renderers/shared/fiber/ReactTypeOfWork.js @@ -12,7 +12,7 @@ 'use strict'; -export type TypeOfWork = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; +export type TypeOfWork = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; module.exports = { IndeterminateComponent: 0, // Before we know whether it is functional or class @@ -25,4 +25,5 @@ module.exports = { CoroutineHandlerPhase: 7, YieldComponent: 8, Fragment: 9, + Portal: 10, // A subtree. Could be an entry point to a different renderer. }; diff --git a/src/renderers/shared/fiber/isomorphic/ReactPortal.js b/src/renderers/shared/fiber/isomorphic/ReactPortal.js new file mode 100644 index 0000000000000..5cb03f96b785f --- /dev/null +++ b/src/renderers/shared/fiber/isomorphic/ReactPortal.js @@ -0,0 +1,60 @@ +/** + * Copyright 2014-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule ReactPortal + * @flow + */ + +'use strict'; + +import type { ReactNodeList } from 'ReactTypes'; + +// The Symbol used to tag the special React types. If there is no native Symbol +// nor polyfill, then a plain number is used for performance. +var REACT_PORTAL_TYPE = + (typeof Symbol === 'function' && Symbol.for && Symbol.for('react.portal')) || + 0xeaca; + +export type ReactPortal = { + $$typeof: Symbol | number, + key: null | string, + containerInfo: any, + children : ReactNodeList, + // TODO: figure out the API for cross-renderer implementation. + implementation: any, +}; + +exports.createPortal = function( + children : ReactNodeList, + containerInfo : any, + // TODO: figure out the API for cross-renderer implementation. + implementation: any, + key : ?string = null +) : ReactPortal { + return { + // This tag allow us to uniquely identify this as a React Portal + $$typeof: REACT_PORTAL_TYPE, + key: key == null ? null : '' + key, + children, + containerInfo, + implementation, + }; +}; + +/** + * Verifies the object is a portal object. + */ +exports.isPortal = function(object : mixed) : boolean { + return ( + typeof object === 'object' && + object !== null && + object.$$typeof === REACT_PORTAL_TYPE + ); +}; + +exports.REACT_PORTAL_TYPE = REACT_PORTAL_TYPE; diff --git a/src/renderers/shared/fiber/isomorphic/ReactTypes.js b/src/renderers/shared/fiber/isomorphic/ReactTypes.js index 643048b46941e..4b90269000c12 100644 --- a/src/renderers/shared/fiber/isomorphic/ReactTypes.js +++ b/src/renderers/shared/fiber/isomorphic/ReactTypes.js @@ -13,8 +13,9 @@ 'use strict'; import type { ReactCoroutine, ReactYield } from 'ReactCoroutine'; +import type { ReactPortal } from 'ReactPortal'; -export type ReactNode = ReactElement | ReactCoroutine | ReactYield | ReactText | ReactFragment; +export type ReactNode = ReactElement | ReactCoroutine | ReactYield | ReactPortal | ReactText | ReactFragment; export type ReactFragment = ReactEmpty | Iterable;