From 4e0f8f785fbfb1147df6dfbfc74edcb0d5ef3c2e Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 8 Apr 2016 20:49:36 +0100 Subject: [PATCH] Add ReactIntrospection This is another change extracted from #6046 and covered by tests. Here, we add `ReactIntrospection` which will be used by the new ReactPerf, as well as (eventually) by React DevTools and third-party React tooling. It abstracts away the implementation details of internal instances so those tools won't rely on these details. Functions in this module take and return internal instances directly for the ease of testing. We will add a separate `ReactDebugInstrumentation` facade later that takes and returns IDs introduced in #6389. The plan is to not expose the internal instances to React DevTools and ReactPerf, but to only expose IDs. --- src/isomorphic/ReactIntrospection.js | 91 ++ .../__tests__/ReactIntrospection-test.js | 949 ++++++++++++++++++ 2 files changed, 1040 insertions(+) create mode 100644 src/isomorphic/ReactIntrospection.js create mode 100644 src/isomorphic/__tests__/ReactIntrospection-test.js 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'); + }); + }); + }); +});