diff --git a/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js b/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js index bcc1f48aa..896668a8f 100644 --- a/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js +++ b/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js @@ -32,6 +32,7 @@ const HostText = 6; const Mode = 11; const ContextConsumer = 12; const ContextProvider = 13; +const ForwardRef = 14; function nodeAndSiblingsArray(nodeWithSibling) { const array = []; @@ -112,10 +113,11 @@ function toTree(vnode) { } case HostText: // 6 return node.memoizedProps; - case Fragment: // 10 - case Mode: // 11 - case ContextProvider: // 13 - case ContextConsumer: // 12 + case Fragment: + case ContextProvider: + case ContextConsumer: + case Mode: + case ForwardRef: return childrenToTree(node.child); default: throw new Error(`Enzyme Internal Error: unknown node with tag ${node.tag}`); diff --git a/packages/enzyme-adapter-utils/src/createMountWrapper.jsx b/packages/enzyme-adapter-utils/src/createMountWrapper.jsx index 0bc127eed..6f44d5368 100644 --- a/packages/enzyme-adapter-utils/src/createMountWrapper.jsx +++ b/packages/enzyme-adapter-utils/src/createMountWrapper.jsx @@ -3,6 +3,11 @@ import PropTypes from 'prop-types'; /* eslint react/forbid-prop-types: 0 */ +// TODO: use react-is? +const specialType = PropTypes.shape({ + $$typeof: PropTypes.any.isRequired, +}).isRequired; + /** * This is a utility component to wrap around the nodes we are * passing in to `mount()`. Theoretically, you could do everything @@ -55,7 +60,7 @@ export default function createMountWrapper(node, options = {}) { } } WrapperComponent.propTypes = { - Component: PropTypes.oneOfType([PropTypes.func, PropTypes.string]).isRequired, + Component: PropTypes.oneOfType([PropTypes.func, PropTypes.string, specialType]).isRequired, props: PropTypes.object.isRequired, context: PropTypes.object, }; diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx index cc48122ac..f285be0da 100644 --- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx @@ -11,7 +11,7 @@ import { import { ITERATOR_SYMBOL, sym } from 'enzyme/build/Utils'; import './_helpers/setupAdapters'; -import { createClass, createContext, createPortal } from './_helpers/react-compat'; +import { createClass, createContext, createPortal, forwardRef } from './_helpers/react-compat'; import { describeWithDOM, describeIf, @@ -181,6 +181,52 @@ describeWithDOM('mount', () => { expect(wrapper.find('span').text()).to.equal('foo'); }); + describeIf(REACT163, 'forwarded ref Components', () => { + + it('should mount without complaint', () => { + const warningStub = sinon.stub(console, 'error'); + + const SomeComponent = forwardRef((props, ref) => ( +
+ )); + + mount(); + + expect(warningStub.called).to.equal(false); + + warningStub.restore(); + }); + + it('should find elements through forwardedRef elements', () => { + const testRef = () => {}; + const SomeComponent = forwardRef((props, ref) => ( +
+ + +
+ )); + + const wrapper = mount(
); + + expect(wrapper.find('.child2')).to.have.length(1); + }); + + it('should find the forwardRef element itself', () => { + const testRef = () => {}; + const OtherComponent = () => ( +
+ ); + const SomeComponent = forwardRef((props, ref) => ( + + )); + + const wrapper = mount(
); + + expect(wrapper.find(SomeComponent)).to.have.length(1); + expect(wrapper.find(OtherComponent)).to.have.length(1); + }); + }); + describeIf(!REACT013, 'stateless components', () => { it('can pass in context', () => { const SimpleComponent = (props, context) => ( diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx index 8aaf196e1..b1416147f 100644 --- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx @@ -6,7 +6,7 @@ import { shallow, render, ShallowWrapper, mount } from 'enzyme'; import { ITERATOR_SYMBOL, withSetStateAllowed, sym } from 'enzyme/build/Utils'; import './_helpers/setupAdapters'; -import { createClass, createContext } from './_helpers/react-compat'; +import { createClass, createContext, forwardRef, createPortal } from './_helpers/react-compat'; import { describeIf, itIf, itWithData, generateEmptyRenderData } from './_helpers'; import { REACT013, REACT014, REACT15, REACT150_4, REACT16, REACT163, is } from './_helpers/version'; @@ -119,7 +119,19 @@ describe('shallow', () => { expect(shallow().find('span')).to.have.length(1); expect(shallow().find(Consumes)).to.have.length(1); + }); + + itIf(REACT163, 'should find elements through forwardedRef elements', () => { + const SomeComponent = forwardRef((props, ref) => ( +
+ + +
+ )); + + const wrapper = shallow(); + expect(wrapper.find('.child2')).to.have.length(1); }); describeIf(!REACT013, 'stateless function components', () => { @@ -2945,6 +2957,36 @@ describe('shallow', () => { it('should pass through to the debugNodes function', () => { expect(shallow(
).debug()).to.equal('
'); }); + + itIf(REACT163, 'should handle internal types gracefully', () => { + const { Provider, Consumer } = createContext(null); + // eslint-disable-next-line prefer-arrow-callback + const Forwarded = forwardRef(function MyComponent(props) { + return ( + + +
+ + {() =>
} + {createPortal(, { nodeType: 1 })} + +
+ + ); + }); + + expect(shallow().debug()).to.equal(` + +
+ + + [function child] + + + +
+
`); + }); }); describe('.html()', () => { diff --git a/packages/enzyme-test-suite/test/_helpers/react-compat.js b/packages/enzyme-test-suite/test/_helpers/react-compat.js index a63fbc15a..c5c628f15 100644 --- a/packages/enzyme-test-suite/test/_helpers/react-compat.js +++ b/packages/enzyme-test-suite/test/_helpers/react-compat.js @@ -10,6 +10,7 @@ let createClass; let renderToString; let createPortal; let createContext; +let forwardRef; if (is('>=15.5 || ^16.0.0-alpha || ^16.3.0-alpha')) { // eslint-disable-next-line import/no-extraneous-dependencies @@ -37,9 +38,16 @@ if (is('^16.3.0-0')) { createContext = null; } +if (is('^16.3.0-0')) { + ({ forwardRef } = require('react')); +} else { + forwardRef = null; +} + export { createClass, renderToString, createPortal, createContext, + forwardRef, }; diff --git a/packages/enzyme/src/Debug.js b/packages/enzyme/src/Debug.js index 27ccb372a..5cc23f400 100644 --- a/packages/enzyme/src/Debug.js +++ b/packages/enzyme/src/Debug.js @@ -12,13 +12,41 @@ import { propsOfNode, childrenOfNode, } from './RSTTraversal'; +import { + typeOf, + AsyncMode, + ContextProvider, + ContextConsumer, + Element, + ForwardRef, + Fragment, + Portal, + StrictMode, +} from './Utils'; const booleanValue = Function.bind.call(Function.call, Boolean.prototype.valueOf); + export function typeName(node) { - return typeof node.type === 'function' - ? (node.type.displayName || functionName(node.type) || 'Component') - : node.type; + const { type } = node; + switch (typeOf(node)) { + case AsyncMode: return 'AsyncMode'; + case ContextProvider: return 'ContextProvider'; + case ContextConsumer: return 'ContextConsumer'; + case Portal: return 'Portal'; + case StrictMode: return 'StrictMode'; + case ForwardRef: { + const name = type.render.displayName || functionName(type.render); + return name ? `ForwardRef(${name})` : 'ForwardRef'; + } + case Fragment: + return 'Fragment'; + case Element: + default: + return typeof node.type === 'function' + ? (type.displayName || functionName(type) || 'Component') + : type || 'unknown'; + } } export function spaces(n) { @@ -66,6 +94,7 @@ function indentChildren(childrenStrs, indentLength) { export function debugNode(node, indentLength = 2, options = {}) { if (typeof node === 'string' || typeof node === 'number') return escape(node); + if (typeof node === 'function') return '[function child]'; if (!node) return ''; const childrenStrs = compact(childrenOfNode(node).map(n => debugNode(n, indentLength, options))); diff --git a/packages/enzyme/src/Utils.js b/packages/enzyme/src/Utils.js index c479f6313..c9da05f63 100644 --- a/packages/enzyme/src/Utils.js +++ b/packages/enzyme/src/Utils.js @@ -7,8 +7,47 @@ import configuration from './configuration'; import validateAdapter from './validateAdapter'; import { childrenOfNode } from './RSTTraversal'; +const hasSymbol = typeof Symbol === 'function' && Symbol.for; + export const ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; +// TODO use react-is when a version is released with no peer dependency on React +export const AsyncMode = hasSymbol ? Symbol.for('react.async_mode') : 0xeacf; +export const ContextProvider = hasSymbol ? Symbol.for('react.provider') : 0xeacd; +export const ContextConsumer = hasSymbol ? Symbol.for('react.context') : 0xeace; +export const Element = hasSymbol ? Symbol.for('react.element') : 0xeac7; +export const ForwardRef = hasSymbol ? Symbol.for('react.forward_ref') : 0xead0; +export const Fragment = hasSymbol ? Symbol.for('react.fragment') : 0xeacb; +export const Portal = hasSymbol ? Symbol.for('react.portal') : 0xeaca; +export const StrictMode = hasSymbol ? Symbol.for('react.strict_mode') : 0xeacc; + +export function typeOf(node) { + if (node !== null) { + const { type, $$typeof } = node; + + switch (type || $$typeof) { + case AsyncMode: + case Fragment: + case StrictMode: + return type; + default: { + const $$typeofType = type && type.$$typeof; + + switch ($$typeofType) { + case ContextConsumer: + case ForwardRef: + case Portal: + case ContextProvider: + return $$typeofType; + default: + return type || $$typeof; + } + } + } + } + return undefined; +} + export function getAdapter(options = {}) { if (options.adapter) { validateAdapter(options.adapter); diff --git a/packages/enzyme/src/selectors.js b/packages/enzyme/src/selectors.js index 8342dde1a..358f056e7 100644 --- a/packages/enzyme/src/selectors.js +++ b/packages/enzyme/src/selectors.js @@ -13,7 +13,7 @@ import { childrenOfNode, hasClassName, } from './RSTTraversal'; -import { nodeHasType, propsOfNode } from './Utils'; +import { nodeHasType, propsOfNode, typeOf, ForwardRef } from './Utils'; // our CSS selector parser instance const parser = createParser(); @@ -227,6 +227,12 @@ export function buildPredicate(selector) { if (hasUndefinedValues) { throw new TypeError('Enzyme::Props can’t have `undefined` values. Try using ‘findWhere()’ instead.'); } + // the selector could also be a forwardRef + if (typeOf(selector) === ForwardRef) { + // re-build the predicate based on what is wrapped by forwardRef + // rather than the forwardRef itself + return buildPredicate(selector.render().props); + } return node => nodeMatchesObjectProps(node, selector); } throw new TypeError('Enzyme::Selector does not support an array, null, or empty object as a selector');