diff --git a/src/isomorphic/ReactIntrospection.js b/src/isomorphic/ReactIntrospection.js new file mode 100644 index 00000000000..e3c50e2d609 --- /dev/null +++ b/src/isomorphic/ReactIntrospection.js @@ -0,0 +1,91 @@ +/** + * Copyright 2016-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 ReactIntrospection + */ + +'use strict'; + +var invariant = require('invariant'); + +function validateInstance(instance) { + invariant( + typeof instance.mountComponent === 'function', + 'ReactIntrospection: Argument is not an internal instance.' + ); +} + +function getChildren(instance) { + if (instance == null) { + return []; + } + validateInstance(instance); + + if (instance._renderedComponent) { + if (instance._renderedComponent._currentElement) { + return [instance._renderedComponent]; + } else { + return []; + } + } else if (instance._renderedChildren) { + var children = []; + for (var key in instance._renderedChildren) { + children.push(instance._renderedChildren[key]); + } + return children; + } else { + return []; + } +} + +function getDisplayName(instance) { + if (instance == null) { + return 'Unknown'; + } + validateInstance(instance); + + var name; + if (instance.getName) { + name = instance.getName(); + } + if (name) { + return name; + } + var element = instance._currentElement; + if (element == null) { + name = '#empty'; + } else if (typeof element === 'string' || typeof element === 'number') { + name = '#text'; + } else if (typeof element.type === 'string') { + name = element.type; + } else { + name = element.type.displayName || element.type.name; + } + return name || 'Unknown'; +} + +function isComposite(instance) { + if (instance == null) { + return false; + } + validateInstance(instance); + + var element = instance._currentElement; + if (element == null) { + return false; + } + return typeof element.type === 'function'; +} + +var ReactIntrospection = { + getChildren, + getDisplayName, + isComposite, +}; + +module.exports = ReactIntrospection; diff --git a/src/isomorphic/__tests__/ReactIntrospection-test.js b/src/isomorphic/__tests__/ReactIntrospection-test.js new file mode 100644 index 00000000000..ec2937e4ab5 --- /dev/null +++ b/src/isomorphic/__tests__/ReactIntrospection-test.js @@ -0,0 +1,949 @@ +/** + * Copyright 2016-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. + * + * @emails react-core + */ + +'use strict'; + +describe('ReactIntrospection', () => { + var React; + var ReactIntrospection; + var ReactDOMComponentTree; + var ReactInstanceMap; + var ReactTestUtils; + + beforeEach(() => { + jest.resetModuleRegistry(); + + React = require('React'); + ReactIntrospection = require('ReactIntrospection'); + ReactDOMComponentTree = require('ReactDOMComponentTree'); + ReactInstanceMap = require('ReactInstanceMap'); + ReactTestUtils = require('ReactTestUtils'); + }); + + // Returns the children rendered by a native internal instance to an array. + // We use it to find internal instances that have no corresponding public + // instances, e.g. text nodes or functional components. + function getChildrenRenderedByNative(nativeInstance) { + var result = []; + var children = nativeInstance._renderedChildren; + for (var key in children) { + if (children.hasOwnProperty(key)) { + result.push(children[key]); + } + } + return result; + } + + // Extracts the single child rendered by a composite component. + function getChildRenderedByComposite(compositeInstance) { + return compositeInstance._renderedComponent; + } + + // Wraps a callback ref so that it receives the corresponding internal + // instance rather than a DOM node or a public instance. + function extractInstance(cb) { + return (ref) => { + if (!ref) { + return; + } else if (ref instanceof Node) { + cb(ReactDOMComponentTree.getInstanceFromNode(ref)); + } else if (ref.render) { + cb(ReactInstanceMap.get(ref)); + } + }; + } + + describe('missing value', () => { + describe('isComposite()', () => { + it('returns false', () => { + expect(ReactIntrospection.isComposite(null)).toBe(false); + }); + }); + + describe('getDisplayName()', () => { + it('returns Unknown', () => { + expect(ReactIntrospection.getDisplayName(null)).toBe('Unknown'); + }); + }); + + describe('getChildren()', () => { + it('returns no children', () => { + expect(ReactIntrospection.getChildren(null)).toEqual([]); + }); + }); + }); + + describe('invalid component', () => { + describe('isComposite()', () => { + it('throws', () => { + expect(() => ReactIntrospection.isComposite({})).toThrow( + 'ReactIntrospection: Argument is not an internal instance.' + ); + }); + }); + + describe('getDisplayName()', () => { + it('throws', () => { + expect(() => ReactIntrospection.getDisplayName({})).toThrow( + 'ReactIntrospection: Argument is not an internal instance.' + ); + }); + }); + + describe('getChildren()', () => { + it('throws', () => { + expect(() => ReactIntrospection.getChildren({})).toThrow( + 'ReactIntrospection: Argument is not an internal instance.' + ); + }); + }); + }); + + describe('empty component', () => { + var emptyInst; + + beforeEach(() => { + class ComponentThatReturnsNull extends React.Component { + render() { + return null; + } + } + ReactTestUtils.renderIntoDocument( + { + emptyInst = getChildRenderedByComposite(inst); + })} /> + ); + }); + + describe('isComposite()', () => { + it('returns false', () => { + expect(ReactIntrospection.isComposite(emptyInst)).toBe(false); + }); + }); + + describe('getDisplayName()', () => { + it('returns #empty', () => { + expect(ReactIntrospection.getDisplayName(emptyInst)).toBe('#empty'); + }); + }); + + describe('getChildren()', () => { + it('returns no children', () => { + expect(ReactIntrospection.getChildren(emptyInst)).toEqual([]); + }); + }); + }); + + describe('text component', () => { + var stringInst; + var numberInst; + + beforeEach(() => { + ReactTestUtils.renderIntoDocument( +
{ + var children = getChildrenRenderedByNative(inst); + stringInst = children[0]; + numberInst = children[1]; + })}> + {'1'}{2} +
+ ); + }); + + describe('isComposite()', () => { + it('returns false', () => { + expect(ReactIntrospection.isComposite(stringInst)).toBe(false); + expect(ReactIntrospection.isComposite(numberInst)).toBe(false); + }); + }); + + describe('getDisplayName()', () => { + it('returns #text', () => { + expect(ReactIntrospection.getDisplayName(stringInst)).toBe('#text'); + expect(ReactIntrospection.getDisplayName(numberInst)).toBe('#text'); + }); + }); + + describe('getChildren()', () => { + it('returns no children', () => { + expect(ReactIntrospection.getChildren(stringInst)).toEqual([]); + expect(ReactIntrospection.getChildren(numberInst)).toEqual([]); + }); + }); + }); + + describe('native component', () => { + describe('isComposite()', () => { + it('returns false', () => { + var divInst; + ReactTestUtils.renderIntoDocument( +
divInst = inst)} /> + ); + expect(ReactIntrospection.isComposite(divInst)).toBe(false); + }); + }); + + describe('getDisplayName()', () => { + it('returns its type', () => { + var divInst; + ReactTestUtils.renderIntoDocument( +
divInst = inst)} /> + ); + expect(ReactIntrospection.getDisplayName(divInst)).toBe('div'); + }); + }); + + describe('getChildren()', () => { + it('returns no children for empty div', () => { + var divInst; + ReactTestUtils.renderIntoDocument( +
divInst = inst)} /> + ); + expect(ReactIntrospection.getChildren(divInst)).toEqual([]); + }); + + it('returns no children for div with inlined text content', () => { + var divInst; + ReactTestUtils.renderIntoDocument( +
divInst = inst)}> + Hi. +
+ ); + expect(ReactIntrospection.getChildren(divInst)).toEqual([]); + }); + + it('returns two children for div with two text children', () => { + var divInst; + ReactTestUtils.renderIntoDocument( +
divInst = inst)}> + 42{'10'} +
+ ); + + var children = ReactIntrospection.getChildren(divInst); + expect(children.length).toBe(2); + expect(ReactIntrospection.getDisplayName(children[0])).toBe('#text'); + expect(ReactIntrospection.getDisplayName(children[1])).toBe('#text'); + }); + + it('returns single child for div with a single native child', () => { + var parentDivInst; + var childSpanInst; + ReactTestUtils.renderIntoDocument( +
parentDivInst = inst)}> + childSpanInst = inst)} /> +
+ ); + + var children = ReactIntrospection.getChildren(parentDivInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(childSpanInst); + }); + + it('returns single child for div with a single composite child', () => { + class Foo extends React.Component { + render() { + return null; + } + } + + var parentDivInst; + var chidlFooInst; + ReactTestUtils.renderIntoDocument( +
parentDivInst = inst)}> + chidlFooInst = inst)} /> +
+ ); + + var children = ReactIntrospection.getChildren(parentDivInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(chidlFooInst); + }); + + it('returns all children for div with mixed children', () => { + class Foo extends React.Component { + render() { + return null; + } + } + + var parentDivInst; + var childSpanInst; + var chidlFooInst; + ReactTestUtils.renderIntoDocument( +
parentDivInst = inst)}> + {'hi'} + {false} + {42} + childSpanInst = inst)} /> + {null} + chidlFooInst = inst)} /> +
+ ); + + var children = ReactIntrospection.getChildren(parentDivInst); + expect(children.length).toBe(4); + expect(ReactIntrospection.getDisplayName(children[0])).toBe('#text'); + expect(ReactIntrospection.getDisplayName(children[1])).toBe('#text'); + expect(children[2]).toBe(childSpanInst); + expect(children[3]).toBe(chidlFooInst); + }); + }); + }); + + describe('classic component', () => { + describe('isComposite()', () => { + it('returns true', () => { + var Foo = React.createClass({ + render() { + return null; + }, + }); + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.isComposite(fooInst)).toBe(true); + }); + }); + + describe('getDisplayName()', () => { + it('prefers displayName', () => { + var Foo = React.createClass({ + render() { + return null; + }, + }); + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Foo'); + }); + + it('falls back to Unknown without displayName', () => { + var Foo = React.createClass({ + render() { + return null; + }, + }); + delete Foo.displayName; + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Unknown'); + }); + }); + + describe('getChildren()', () => { + it('returns no children for composite returning null', () => { + var Foo = React.createClass({ + render() { + return null; + }, + }); + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + expect(ReactIntrospection.getChildren(fooInst)).toEqual([]); + }); + + it('returns single child for composite returning native', () => { + var childSpanInst; + var Foo = React.createClass({ + render() { + return ( + childSpanInst = inst)} /> + ); + }, + }); + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + var children = ReactIntrospection.getChildren(fooInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(childSpanInst); + }); + + it('returns single child for composite returning composite', () => { + var Bar = React.createClass({ + render() { + return ; + }, + }); + var barInst; + var Foo = React.createClass({ + render() { + return ( + barInst = inst)} /> + ); + }, + }); + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + var children = ReactIntrospection.getChildren(fooInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(barInst); + }); + }); + }); + + describe('modern component', () => { + describe('isComposite()', () => { + it('returns true', () => { + class Foo extends React.Component { + render() { + return null; + } + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.isComposite(fooInst)).toBe(true); + }); + }); + + describe('getDisplayName()', () => { + it('normally uses name', () => { + class Foo extends React.Component { + render() { + return null; + } + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Foo'); + }); + + it('prefers displayName if specified', () => { + class Foo extends React.Component { + render() { + return null; + } + } + Foo.displayName = 'Bar'; + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Bar'); + }); + + it('falls back to ReactComponent', () => { + class Foo extends React.Component { + render() { + return null; + } + } + delete Foo.name; + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + // Ideally this should return Unknown for consistency. + // For now, this test documents the existing behavior. + expect( + ReactIntrospection.getDisplayName(fooInst) + ).toBe('ReactComponent'); + }); + }); + + describe('getChildren()', () => { + it('returns no children for composite returning null', () => { + class Foo extends React.Component { + render() { + return null; + } + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + expect(ReactIntrospection.getChildren(fooInst)).toEqual([]); + }); + + it('returns single child for composite returning native', () => { + var childSpanInst; + class Foo extends React.Component { + render() { + return ( + childSpanInst = inst)} /> + ); + } + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + var children = ReactIntrospection.getChildren(fooInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(childSpanInst); + }); + + it('returns single child for composite returning composite', () => { + class Bar extends React.Component { + render() { + return null; + } + } + var barInst; + class Foo extends React.Component { + render() { + return ( + barInst = inst)} /> + ); + } + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + var children = ReactIntrospection.getChildren(fooInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(barInst); + }); + }); + }); + + describe('factory component', () => { + describe('isComposite()', () => { + it('returns true', () => { + function Foo() { + return { + render() { + return null; + }, + }; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.isComposite(fooInst)).toBe(true); + }); + }); + + describe('getDisplayName()', () => { + it('normally returns name', () => { + function Foo() { + return { + render() { + return null; + }, + }; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Foo'); + }); + + it('prefers displayName if specified', () => { + function Foo() { + return { + render() { + return null; + }, + }; + } + Foo.displayName = 'Bar'; + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Bar'); + }); + + it('falls back to Object', () => { + function Foo() { + return { + render() { + return null; + }, + }; + } + delete Foo.name; + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)} /> + ); + + // Ideally this should return Unknown for consistency. + // For now, this test documents the existing behavior. + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Object'); + }); + }); + + describe('getChildren()', () => { + it('returns no children for composite returning null', () => { + function Foo() { + return { + render() { + return null; + }, + }; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + expect(ReactIntrospection.getChildren(fooInst)).toEqual([]); + }); + + it('returns single child for composite returning native', () => { + var childSpanInst; + + function Foo() { + return { + render() { + return ( + childSpanInst = inst)} /> + ); + }, + }; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + var children = ReactIntrospection.getChildren(fooInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(childSpanInst); + }); + + it('returns single child for composite returning composite', () => { + function Bar() { + return { + render() { + return null; + }, + }; + } + var barInst; + function Foo() { + return { + render() { + return ( + barInst = inst)} /> + ); + }, + }; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( + fooInst = inst)}> + (it ignores props.children) + + ); + + var children = ReactIntrospection.getChildren(fooInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(barInst); + }); + }); + }); + + describe('functional component', () => { + describe('isComposite()', () => { + it('returns true', () => { + function Foo() { + return null; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( +
{ + fooInst = getChildrenRenderedByNative(inst)[0]; + })}> + +
+ ); + + expect(ReactIntrospection.isComposite(fooInst)).toBe(true); + }); + }); + + describe('getDisplayName()', () => { + it('normally returns name', () => { + function Foo() { + return null; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( +
{ + fooInst = getChildrenRenderedByNative(inst)[0]; + })}> + +
+ ); + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Foo'); + }); + + it('prefers displayName if specified', () => { + function Foo() { + return null; + } + Foo.displayName = 'Bar'; + + var fooInst; + ReactTestUtils.renderIntoDocument( +
{ + fooInst = getChildrenRenderedByNative(inst)[0]; + })}> + +
+ ); + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Bar'); + }); + + it('falls back to StatelessComponent', () => { + function Foo() { + return null; + } + delete Foo.name; + + var fooInst; + ReactTestUtils.renderIntoDocument( +
{ + fooInst = getChildrenRenderedByNative(inst)[0]; + })}> + +
+ ); + + // Ideally this should return Unknown instead. + // For now, this test documents the existing behavior. + expect( + ReactIntrospection.getDisplayName(fooInst) + ).toBe('StatelessComponent'); + }); + }); + + describe('getChildren()', () => { + it('returns no children for composite returning null', () => { + function Foo() { + return null; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( +
{ + fooInst = getChildrenRenderedByNative(inst)[0]; + })}> + + (it ignores props.children) + +
+ ); + + expect(ReactIntrospection.getChildren(fooInst)).toEqual([]); + }); + + it('returns single child for composite returning native', () => { + var childSpanInst; + + function Foo() { + return ( + childSpanInst = inst)} + /> + ); + } + + var fooInst; + ReactTestUtils.renderIntoDocument( +
{ + fooInst = getChildrenRenderedByNative(inst)[0]; + })}> + + (it ignores props.children) + +
+ ); + + var children = ReactIntrospection.getChildren(fooInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(childSpanInst); + }); + + it('returns single child for composite returning composite', () => { + class Bar extends React.Component { + render() { + return null; + } + } + var barInst; + function Foo() { + return { + render() { + return ( + barInst = inst)} /> + ); + }, + }; + } + + var fooInst; + ReactTestUtils.renderIntoDocument( +
{ + fooInst = getChildrenRenderedByNative(inst)[0]; + })}> + fooInst = inst)}> + (it ignores props.children) + +
+ ); + + var children = ReactIntrospection.getChildren(fooInst); + expect(children.length).toBe(1); + expect(children[0]).toBe(barInst); + }); + }); + }); + + describe('custom internal component', () => { + describe('isComposite()', () => { + it('returns true if the element type is a function', () => { + function Foo() { + return null; + } + + var fooInst = { + _currentElement: React.createElement(Foo), + mountComponent() {}, + }; + + expect(ReactIntrospection.isComposite(fooInst)).toBe(true); + }); + + it('returns false if the element type is a string', () => { + var fooInst = { + _currentElement: React.createElement('foo'), + mountComponent() {}, + }; + + expect(ReactIntrospection.isComposite(fooInst)).toBe(false); + }); + }); + + describe('getDisplayName()', () => { + it('delegates to getName() if defined', () => { + var fooInst = { + _currentElement: React.createElement('foo'), + getName() { + return 'Bar'; + }, + mountComponent() {}, + }; + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Bar'); + }); + + it('returns type string if there is no getName()', () => { + var fooInst = { + _currentElement: React.createElement('foo'), + mountComponent() {}, + }; + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('foo'); + }); + + it('returns type function name if there is no getName()', () => { + function Foo() { + return null; + } + var fooInst = { + _currentElement: React.createElement(Foo), + mountComponent() {}, + }; + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Foo'); + }); + + it('falls back to Unknown', () => { + function Foo() { + return null; + } + delete Foo.name; + + var fooInst = { + _currentElement: React.createElement(Foo), + mountComponent() {}, + }; + + expect(ReactIntrospection.getDisplayName(fooInst)).toBe('Unknown'); + }); + }); + }); +});