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;