diff --git a/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js b/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js index 74a209753..797df863b 100644 --- a/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js +++ b/packages/enzyme-adapter-react-16.3/src/ReactSixteenThreeAdapter.js @@ -15,6 +15,8 @@ import { Element, ForwardRef, Fragment, + isContextConsumer, + isContextProvider, isElement, isForwardRef, isPortal, @@ -241,6 +243,18 @@ function nodeToHostNode(_node) { return mapper(node); } +function getProviderDefaultValue(Provider) { + // React stores references to the Provider's defaultValue differently across versions. + if ('_defaultValue' in Provider._context) { + return Provider._context._defaultValue; + } + throw new Error('Enzyme Internal Error: can’t figure out how to get Provider’s default value'); +} + +function makeFakeElement(type) { + return { $$typeof: Element, type }; +} + const eventOptions = { animation: true }; class ReactSixteenThreeAdapter extends EnzymeAdapter { @@ -357,11 +371,30 @@ class ReactSixteenThreeAdapter extends EnzymeAdapter { let isDOM = false; let cachedNode = null; return { - render(el, context) { + render(el, context, { + providerValues = new Map(), + } = {}) { cachedNode = el; /* eslint consistent-return: 0 */ if (typeof el.type === 'string') { isDOM = true; + } else if (isContextProvider(el)) { + providerValues.set(el.type, el.props.value); + const MockProvider = Object.assign( + props => props.children, + el.type, + ); + return withSetStateAllowed(() => renderer.render({ ...el, type: MockProvider })); + } else if (isContextConsumer(el)) { + const Provider = adapter.getProviderFromConsumer(el.type); + const value = providerValues.has(Provider) + ? providerValues.get(Provider) + : getProviderDefaultValue(Provider); + const MockConsumer = Object.assign( + props => props.children(value), + el.type, + ); + return withSetStateAllowed(() => renderer.render({ ...el, type: MockConsumer })); } else { isDOM = false; const { type: Component } = el; @@ -539,13 +572,19 @@ class ReactSixteenThreeAdapter extends EnzymeAdapter { } isCustomComponent(type) { - const fakeElement = { $$typeof: Element, type }; + const fakeElement = makeFakeElement(type); return !!type && ( typeof type === 'function' || isForwardRef(fakeElement) + || isContextProvider(fakeElement) + || isContextConsumer(fakeElement) ); } + isContextConsumer(type) { + return !!type && isContextConsumer(makeFakeElement(type)); + } + isCustomComponentElement(inst) { if (!inst || !this.isValidElement(inst)) { return false; @@ -553,6 +592,14 @@ class ReactSixteenThreeAdapter extends EnzymeAdapter { return this.isCustomComponent(inst.type); } + getProviderFromConsumer(Consumer) { + const { Provider } = Consumer || {}; + if (Provider) { + return Provider; + } + throw new Error('Enzyme Internal Error: can’t figure out how to get Provider from Consumer'); + } + createElement(...args) { return React.createElement(...args); } diff --git a/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js b/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js index a9d4c6460..9667cc6c0 100644 --- a/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js +++ b/packages/enzyme-adapter-react-16/src/ReactSixteenAdapter.js @@ -18,6 +18,8 @@ import { Element, ForwardRef, Fragment, + isContextConsumer, + isContextProvider, isElement, isForwardRef, isMemo, @@ -303,6 +305,21 @@ function wrapAct(fn) { return returnVal; } +function getProviderDefaultValue(Provider) { + // React stores references to the Provider's defaultValue differently across versions. + if ('_defaultValue' in Provider._context) { + return Provider._context._defaultValue; + } + if ('_currentValue' in Provider._context) { + return Provider._context._currentValue; + } + throw new Error('Enzyme Internal Error: can’t figure out how to get Provider’s default value'); +} + +function makeFakeElement(type) { + return { $$typeof: Element, type }; +} + class ReactSixteenAdapter extends EnzymeAdapter { constructor() { super(); @@ -454,11 +471,30 @@ class ReactSixteenAdapter extends EnzymeAdapter { }; return { - render(el, unmaskedContext) { + render(el, unmaskedContext, { + providerValues = new Map(), + } = {}) { cachedNode = el; /* eslint consistent-return: 0 */ if (typeof el.type === 'string') { isDOM = true; + } else if (isContextProvider(el)) { + providerValues.set(el.type, el.props.value); + const MockProvider = Object.assign( + props => props.children, + el.type, + ); + return withSetStateAllowed(() => renderer.render({ ...el, type: MockProvider })); + } else if (isContextConsumer(el)) { + const Provider = adapter.getProviderFromConsumer(el.type); + const value = providerValues.has(Provider) + ? providerValues.get(Provider) + : getProviderDefaultValue(Provider); + const MockConsumer = Object.assign( + props => props.children(value), + el.type, + ); + return withSetStateAllowed(() => renderer.render({ ...el, type: MockConsumer })); } else { isDOM = false; const { type: Component } = el; @@ -673,13 +709,19 @@ class ReactSixteenAdapter extends EnzymeAdapter { } isCustomComponent(type) { - const fakeElement = { $$typeof: Element, type }; + const fakeElement = makeFakeElement(type); return !!type && ( typeof type === 'function' || isForwardRef(fakeElement) + || isContextProvider(fakeElement) + || isContextConsumer(fakeElement) ); } + isContextConsumer(type) { + return !!type && isContextConsumer(makeFakeElement(type)); + } + isCustomComponentElement(inst) { if (!inst || !this.isValidElement(inst)) { return false; @@ -687,6 +729,22 @@ class ReactSixteenAdapter extends EnzymeAdapter { return this.isCustomComponent(inst.type); } + getProviderFromConsumer(Consumer) { + // React stores references to the Provider on a Consumer differently across versions. + if (Consumer) { + let Provider; + if (Consumer.Provider) { + ({ Provider } = Consumer); + } else if (Consumer._context) { + ({ Provider } = Consumer._context); + } + if (Provider) { + return Provider; + } + } + throw new Error('Enzyme Internal Error: can’t figure out how to get Provider from Consumer'); + } + createElement(...args) { return React.createElement(...args); } diff --git a/packages/enzyme-test-suite/test/Adapter-spec.jsx b/packages/enzyme-test-suite/test/Adapter-spec.jsx index 0745e245b..8f083953a 100644 --- a/packages/enzyme-test-suite/test/Adapter-spec.jsx +++ b/packages/enzyme-test-suite/test/Adapter-spec.jsx @@ -1169,7 +1169,40 @@ Warning: Failed Adapter-spec type: Invalid Adapter-spec \`foo\` of type \`string }); itIf(is('>=16.3'), 'returns true for forward refs', () => { - expect(adapter.isCustomComponent(React.forwardRef(() => null))).to.equal(true); + expect(adapter.isCustomComponent(forwardRef(() => null))).to.equal(true); + }); + }); + + describeIf(is('>= 16.3'), 'isContextConsumer(type)', () => { + it('returns true for createContext() Consumers', () => { + expect(adapter.isContextConsumer(createContext().Consumer)).to.equal(true); + }); + + it('returns false for everything else', () => { + expect(adapter.isContextConsumer(null)).to.equal(false); + expect(adapter.isContextConsumer(true)).to.equal(false); + expect(adapter.isContextConsumer(undefined)).to.equal(false); + expect(adapter.isContextConsumer(false)).to.equal(false); + expect(adapter.isContextConsumer(() =>
)).to.equal(false); + expect(adapter.isContextConsumer(forwardRef(() => null))).to.equal(false); + expect(adapter.isContextConsumer(createContext().Provider)).to.equal(false); + }); + }); + + describeIf(is('>= 16.3'), 'getProviderFromConsumer(Consumer)', () => { + it('gets a createContext() Provider from a Consumer', () => { + const Context = createContext(); + + expect(adapter.getProviderFromConsumer(Context.Consumer)).to.equal(Context.Provider); + }); + + it('throws an internal error if something that is not a Consumer is passed', () => { + expect(() => adapter.getProviderFromConsumer(null)).to.throw( + 'Enzyme Internal Error: can’t figure out how to get Provider from Consumer', + ); + expect(() => adapter.getProviderFromConsumer({})).to.throw( + 'Enzyme Internal Error: can’t figure out how to get Provider from Consumer', + ); }); }); }); diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx index 833b37b46..ebec55c34 100644 --- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx @@ -261,6 +261,53 @@ describeWithDOM('mount', () => { expect(wrapper.text()).to.equal('Context says: I can be set!'); }); + describeIf(is('>= 16.3'), 'with createContext()', () => { + let Context1; + let Context2; + + function WrappingComponent(props) { + const { value1, value2, children } = props; + return ( + + + {children} + + + ); + } + + function Component() { + return ( + + {value1 => ( + + {value2 => ( +
Value 1: {value1}; Value 2: {value2}
+ )} +
+ )} +
+ ); + } + + beforeEach(() => { + Context1 = createContext('default1'); + Context2 = createContext('default2'); + }); + + it('renders', () => { + const wrapper = mount(, { + wrappingComponent: WrappingComponent, + wrappingComponentProps: { + value1: 'one', + value2: 'two', + }, + }); + + expect(wrapper.text()).to.equal('Value 1: one; Value 2: two'); + }); + }); + it('throws an error if the wrappingComponent does not render its children', () => { class BadWrapper extends React.Component { render() { @@ -437,20 +484,50 @@ describeWithDOM('mount', () => { expect(wrapper.context('name')).to.equal(context.name); }); - itIf(is('>= 16.3'), 'finds elements through Context elements', () => { - const { Provider, Consumer } = createContext(''); + describeIf(is('>= 16.3'), 'createContext()', () => { + let Context; - class Foo extends React.Component { - render() { - return ( - {value => {value}} - ); + beforeEach(() => { + Context = createContext('hello'); + }); + + it('finds elements through Context elements', () => { + class Foo extends React.Component { + render() { + return ( + {value => {value}} + ); + } } - } - const wrapper = mount(
); + const wrapper = mount(
); + + expect(wrapper.find('span').text()).to.equal('foo'); + }); - expect(wrapper.find('span').text()).to.equal('foo'); + it('can render a as the root', () => { + const wrapper = mount( + + {value =>
{value}
}
+
, + ); + expect(wrapper.text()).to.equal('cool'); + + wrapper.setProps({ value: 'test' }); + expect(wrapper.text()).to.equal('test'); + }); + + it('can render a as the root', () => { + const wrapper = mount( + {value =>
{value}
}
, + ); + expect(wrapper.text()).to.equal('hello'); + + wrapper.setProps({ + children: value =>
Value is: {value}
, + }); + expect(wrapper.text()).to.equal('Value is: hello'); + }); }); describeIf(is('>= 16.3'), 'forwarded ref Components', () => { diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx index 831f711bb..c3e736e33 100644 --- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx @@ -270,6 +270,54 @@ describe('shallow', () => { expect(wrapper.text()).to.equal('Context says: I can be set!'); }); + describeIf(is('>= 16.3'), 'with createContext()', () => { + let Context1; + let Context2; + beforeEach(() => { + Context1 = createContext('default1'); + Context2 = createContext('default2'); + }); + + function WrappingComponent(props) { + const { value1, value2, children } = props; + return ( + + + {children} + + + ); + } + + function Component() { + return ( + + {value1 => ( + + {value2 => ( +
Value 1: {value1}; Value 2: {value2}
+ )} +
+ )} +
+ ); + } + + it('renders', () => { + const wrapper = shallow(, { + wrappingComponent: WrappingComponent, + wrappingComponentProps: { + value1: 'one', + value2: 'two', + }, + }); + const consumer1 = wrapper.find(Context1.Consumer).dive(); + const consumer2 = consumer1.find(Context2.Consumer).dive(); + + expect(consumer2.text()).to.equal('Value 1: one; Value 2: two'); + }); + }); + it('throws an error if the wrappingComponent does not render its children', () => { class BadWrapper extends React.Component { render() { @@ -415,6 +463,145 @@ describe('shallow', () => { expect(wrapper.find('.child2')).to.have.lengthOf(1); }); + describeIf(is('>= 16.3'), 'createContext()', () => { + describe('rendering as root:', () => { + let Context; + + beforeEach(() => { + Context = createContext('cool'); + }); + + describe('', () => { + it('can be rendered as the root', () => { + const wrapper = shallow( + + + {value =>
{value}
} +
+
, + ); + expect(wrapper.debug()).to.eql(` + + [function] + + `.trim()); + }); + + it('supports changing the value', () => { + const wrapper = shallow( + + + {value =>
{value}
} +
+
, + ); + wrapper.setProps({ value: 'world' }); + expect(wrapper.find(Context.Consumer).dive().text()).to.eql('world'); + }); + }); + + describe('', () => { + function DivRenderer({ children }) { + return
{children}
; + } + it('can be rendered as the root', () => { + const wrapper = shallow( + + {value => {value}} + , + ); + expect(wrapper.debug()).to.eql(` + + cool + + `.trim()); + }); + + it('supports changing the children', () => { + const wrapper = shallow( + + {value => {value}} + , + ); + wrapper.setProps({ children: value => Changed: {value} }); + expect(wrapper.find(DivRenderer).dive().text()).to.eql('Changed: cool'); + }); + }); + }); + + describe('dive() on Provider and Consumer', () => { + let Provider; + let Consumer; + + beforeEach(() => { + ({ Provider, Consumer } = React.createContext('howdy!')); + }); + + class Consumes extends React.Component { + render() { + return ( + + {value => {value}} + + ); + } + } + + class Provides extends React.Component { + render() { + const { children } = this.props; + + return ( +
{children}
+ ); + } + } + + class MyComponent extends React.Component { + render() { + return ( + + ); + } + } + + it('works on a Provider', () => { + const wrapper = shallow(); + const provides = wrapper.find(Provides).dive(); + const provider = provides.find(Provider).dive(); + expect(provider.text()).to.equal(''); + }); + + it('always gives the default provider value if dive()ing directly to a ', () => { + // Diving directly on a consumer will give you the default value + const wrapper = shallow(); + const consumes = wrapper.find(Consumes).dive(); + const consumer = consumes.find(Consumer).dive(); + expect(consumer.text()).to.equal('howdy!'); + }); + + it('gives the actual value if one dive()s it', () => { + const wrapper = shallow(); + const provides = wrapper.find(Provides).dive(); + const provider = provides.find(Provider).dive(); + const consumes = provider.find(Consumes).dive(); + const consumer = consumes.find(Consumer).dive(); + expect(consumer.text()).to.equal('foo'); + }); + + it('does not leak values across roots', () => { + const wrapper = shallow(); + const provides = wrapper.find(Provides).dive(); + const provider = provides.find(Provider).dive(); + expect(provider).to.have.lengthOf(1); + + const consumes = wrapper.find(Consumes).dive(); + const consumer = consumes.find(Consumer).dive(); + expect(consumer.text()).to.equal('howdy!'); + }); + }); + }); + describeIf(is('> 0.13'), 'stateless function components (SFCs)', () => { it('can pass in context', () => { const SimpleComponent = (props, context) => ( diff --git a/packages/enzyme-test-suite/test/shared/methods/getWrappingComponent.jsx b/packages/enzyme-test-suite/test/shared/methods/getWrappingComponent.jsx index b90555c1a..c9e37f5f4 100644 --- a/packages/enzyme-test-suite/test/shared/methods/getWrappingComponent.jsx +++ b/packages/enzyme-test-suite/test/shared/methods/getWrappingComponent.jsx @@ -183,6 +183,31 @@ export default function describeGetWrappingComponent({ } }); + itIf(is('>= 16.3'), 'updates a if it is rendered as root', () => { + const Context = React.createContext(); + function WrappingComponent(props) { + const { value, children } = props; + return ( + + {children} + + ); + } + const wrapper = Wrap(( + + {value =>
{value}
} +
+ ), { + wrappingComponent: WrappingComponent, + wrappingComponentProps: { value: 'hello!' }, + }); + const wrappingComponent = wrapper.getWrappingComponent(); + expect(wrapper.text()).to.equal('hello!'); + + wrappingComponent.setProps({ value: 'goodbye!' }); + expect(wrapper.text()).to.equal('goodbye!'); + }); + itIf(!isShallow, 'handles a partial prop update', () => { const wrapper = Wrap(, { wrappingComponent: MyWrappingComponent, diff --git a/packages/enzyme/src/ShallowWrapper.js b/packages/enzyme/src/ShallowWrapper.js index 4058847fc..c358fac71 100644 --- a/packages/enzyme/src/ShallowWrapper.js +++ b/packages/enzyme/src/ShallowWrapper.js @@ -45,6 +45,7 @@ const CHILD_CONTEXT = sym('__childContext__'); const WRAPPING_COMPONENT = sym('__wrappingComponent__'); const PRIMARY_WRAPPER = sym('__primaryWrapper__'); const ROOT_FINDER = sym('__rootFinder__'); +const PROVIDER_VALUES = sym('__providerValues__'); /** * Finds all nodes in the current wrapper nodes' render trees that match the provided predicate @@ -313,14 +314,18 @@ function deepRender(wrapper, target, adapter) { * @param {WrappingComponentWrapper} wrapper The `WrappingComponentWrapper` for a * `wrappingComponent` * @param {Adapter} adapter An Enzyme adapter - * @returns {object} The context collected + * @returns {object} An object containing an object of legacy context values and a Map of + * `createContext()` Provider values. */ function getContextFromWrappingComponent(wrapper, adapter) { const rootFinder = deepRender(wrapper, wrapper[ROOT_FINDER], adapter); if (!rootFinder) { throw new Error('`wrappingComponent` must render its children!'); } - return rootFinder[OPTIONS].context; + return { + legacyContext: rootFinder[OPTIONS].context, + providerValues: rootFinder[PROVIDER_VALUES], + }; } /** @@ -338,6 +343,7 @@ function getContextFromWrappingComponent(wrapper, adapter) { function makeShallowOptions(nodes, root, passedOptions, wrapper) { const options = makeOptions(passedOptions); const adapter = getAdapter(passedOptions); + privateSet(options, PROVIDER_VALUES, passedOptions[PROVIDER_VALUES]); if (root || !isCustomComponent(options.wrappingComponent, adapter)) { return options; } @@ -347,16 +353,18 @@ function makeShallowOptions(nodes, root, passedOptions, wrapper) { const { node: wrappedNode, RootFinder } = adapter.wrapWithWrappingComponent(nodes, options); // eslint-disable-next-line no-use-before-define const wrappingComponent = new WrappingComponentWrapper(wrappedNode, wrapper, RootFinder); - const wrappingComponentContext = getContextFromWrappingComponent( - wrappingComponent, adapter, - ); + const { + legacyContext: wrappingComponentLegacyContext, + providerValues: wrappingComponentProviderValues, + } = getContextFromWrappingComponent(wrappingComponent, adapter); privateSet(wrapper, WRAPPING_COMPONENT, wrappingComponent); return { ...options, context: { ...options.context, - ...wrappingComponentContext, + ...wrappingComponentLegacyContext, }, + [PROVIDER_VALUES]: wrappingComponentProviderValues, }; } @@ -385,10 +393,12 @@ class ShallowWrapper { privateSet(this, UNRENDERED, nodes); const renderer = adapter.createRenderer({ mode: 'shallow', ...options }); privateSet(this, RENDERER, renderer); - this[RENDERER].render(nodes, options.context); + const providerValues = new Map(options[PROVIDER_VALUES] || []); + this[RENDERER].render(nodes, options.context, { providerValues }); const renderedNode = this[RENDERER].getNode(); privateSetNodes(this, getRootNode(renderedNode)); privateSet(this, OPTIONS, options); + privateSet(this, PROVIDER_VALUES, providerValues); const { instance } = renderedNode; if (instance && !options.disableLifecycleMethods) { @@ -415,6 +425,7 @@ class ShallowWrapper { privateSetNodes(this, nodes); privateSet(this, OPTIONS, root[OPTIONS]); privateSet(this, ROOT_NODES, root[NODES]); + privateSet(this, PROVIDER_VALUES, null); } } @@ -612,7 +623,9 @@ class ShallowWrapper { ); } if (props) this[UNRENDERED] = cloneElement(adapter, this[UNRENDERED], props); - this[RENDERER].render(this[UNRENDERED], nextContext); + this[RENDERER].render(this[UNRENDERED], nextContext, { + providerValues: this[PROVIDER_VALUES], + }); if (shouldComponentUpdateSpy) { shouldRender = shouldComponentUpdateSpy.getLastReturnValue(); shouldComponentUpdateSpy.restore(); @@ -1659,14 +1672,16 @@ class ShallowWrapper { if (!isCustomComponentElement(el, adapter)) { throw new TypeError(`ShallowWrapper::${name}() can only be called on components`); } - return this.wrap(el, null, { + const childOptions = { ...this[OPTIONS], ...options, context: options.context || { ...this[OPTIONS].context, ...this[ROOT][CHILD_CONTEXT], }, - }); + }; + privateSet(childOptions, PROVIDER_VALUES, this[ROOT][PROVIDER_VALUES]); + return this.wrap(el, null, childOptions); }); } @@ -1686,14 +1701,35 @@ class ShallowWrapper { * `wrappingComponent` re-renders. */ function updatePrimaryRootContext(wrappingComponent) { - const context = getContextFromWrappingComponent( - wrappingComponent, - getAdapter(wrappingComponent[OPTIONS]), - ); - wrappingComponent[PRIMARY_WRAPPER].setContext({ + const adapter = getAdapter(wrappingComponent[OPTIONS]); + const primaryWrapper = wrappingComponent[PRIMARY_WRAPPER]; + const primaryRenderer = primaryWrapper[RENDERER]; + const primaryNode = primaryRenderer.getNode(); + const { + legacyContext, + providerValues, + } = getContextFromWrappingComponent(wrappingComponent, adapter); + const prevProviderValues = primaryWrapper[PROVIDER_VALUES]; + + primaryWrapper.setContext({ ...wrappingComponent[PRIMARY_WRAPPER][OPTIONS].context, - ...context, + ...legacyContext, }); + primaryWrapper[PROVIDER_VALUES] = new Map([...prevProviderValues, ...providerValues]); + + if (typeof adapter.isContextConsumer === 'function' && adapter.isContextConsumer(primaryNode.type)) { + const Consumer = primaryNode.type; + // Adapters with an `isContextConsumer` method will definitely have a `getProviderFromConsumer` + // method. + const Provider = adapter.getProviderFromConsumer(Consumer); + const newValue = providerValues.get(Provider); + const oldValue = prevProviderValues.get(Provider); + + // Use referential comparison like React + if (newValue !== oldValue) { + primaryWrapper.rerender(); + } + } } /**