diff --git a/SUMMARY.md b/SUMMARY.md
index b2adcb25a..3200c2826 100644
--- a/SUMMARY.md
+++ b/SUMMARY.md
@@ -42,6 +42,7 @@
* [first()](/docs/api/ShallowWrapper/first.md)
* [forEach(fn)](/docs/api/ShallowWrapper/forEach.md)
* [get(index)](/docs/api/ShallowWrapper/get.md)
+ * [getWrappingComponent()](/docs/api/ShallowWrapper/getWrappingComponent.md)
* [getElement(index)](/docs/api/ShallowWrapper/getElement.md)
* [getElements(index)](/docs/api/ShallowWrapper/getElements.md)
* [hasClass(className)](/docs/api/ShallowWrapper/hasClass.md)
@@ -104,6 +105,7 @@
* [forEach(fn)](/docs/api/ReactWrapper/forEach.md)
* [get(index)](/docs/api/ReactWrapper/get.md)
* [getDOMNode()](/docs/api/ReactWrapper/getDOMNode.md)
+ * [getWrappingComponent()](/docs/api/ReactWrapper/getWrappingComponent.md)
* [hasClass(className)](/docs/api/ReactWrapper/hasClass.md)
* [hostNodes()](/docs/api/ReactWrapper/hostNodes.md)
* [html()](/docs/api/ReactWrapper/html.md)
diff --git a/docs/api/ReactWrapper/getWrappingComponent.md b/docs/api/ReactWrapper/getWrappingComponent.md
new file mode 100644
index 000000000..4f6f6dd25
--- /dev/null
+++ b/docs/api/ReactWrapper/getWrappingComponent.md
@@ -0,0 +1,45 @@
+# `.getWrappingComponent() => ReactWrapper`
+
+If a `wrappingComponent` was passed in `options`, this methods returns a `ReactWrapper` around the rendered `wrappingComponent`. This `ReactWrapper` can be used to update the `wrappingComponent`'s props, state, etc.
+
+
+#### Returns
+
+`ReactWrapper`: A `ReactWrapper` around the rendered `wrappingComponent`
+
+
+
+#### Examples
+
+```jsx
+import { Provider } from 'react-redux';
+import { Router } from 'react-router';
+import store from './my/app/store';
+import mockStore from './my/app/mockStore';
+
+function MyProvider(props) {
+ const { children, customStore } = props;
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+MyProvider.propTypes = {
+ children: PropTypes.node,
+ customStore: PropTypes.shape({}),
+};
+MyProvider.defaultProps = {
+ children: null,
+ customStore: null,
+};
+
+const wrapper = mount(, {
+ wrappingComponent: MyProvider,
+});
+const provider = wrapper.getWrappingComponent();
+provider.setProps({ customStore: mockStore });
+```
diff --git a/docs/api/ShallowWrapper/getWrappingComponent.md b/docs/api/ShallowWrapper/getWrappingComponent.md
new file mode 100644
index 000000000..d40dc1667
--- /dev/null
+++ b/docs/api/ShallowWrapper/getWrappingComponent.md
@@ -0,0 +1,45 @@
+# `.getWrappingComponent() => ShallowWrapper`
+
+If a `wrappingComponent` was passed in `options`, this methods returns a `ShallowWrapper` around the rendered `wrappingComponent`. This `ShallowWrapper` can be used to update the `wrappingComponent`'s props, state, etc.
+
+
+#### Returns
+
+`ShallowWrapper`: A `ShallowWrapper` around the rendered `wrappingComponent`
+
+
+
+#### Examples
+
+```jsx
+import { Provider } from 'react-redux';
+import { Router } from 'react-router';
+import store from './my/app/store';
+import mockStore from './my/app/mockStore';
+
+function MyProvider(props) {
+ const { children, customStore } = props;
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+MyProvider.propTypes = {
+ children: PropTypes.node,
+ customStore: PropTypes.shape({}),
+};
+MyProvider.defaultProps = {
+ children: null,
+ customStore: null,
+};
+
+const wrapper = shallow(, {
+ wrappingComponent: MyProvider,
+});
+const provider = wrapper.getWrappingComponent();
+provider.setProps({ customStore: mockStore });
+```
diff --git a/docs/api/mount.md b/docs/api/mount.md
index 58b4e1f16..a5bdb704b 100644
--- a/docs/api/mount.md
+++ b/docs/api/mount.md
@@ -49,6 +49,8 @@ describe('', () => {
- `options.context`: (`Object` [optional]): Context to be passed into the component
- `options.attachTo`: (`DOMElement` [optional]): DOM Element to attach the component to.
- `options.childContextTypes`: (`Object` [optional]): Merged contextTypes for all children of the wrapper.
+- `options.wrappingComponent`: (`ComponentType` [optional]): A component that will render as a parent of the `node`. It can be used to provide context to the `node`, among other things. See the [`getWrappingComponent()` docs](ReactWrapper/getWrappingComponent.md) for an example. **Note**: `wrappingComponent` _must_ render its children.
+- `options.wrappingComponentProps`: (`Object` [optional]): Initial props to pass to the `wrappingComponent` if it is specified.
#### Returns
@@ -177,6 +179,9 @@ Manually sets context of the root component.
#### [`.instance() => ReactComponent|DOMComponent`](ReactWrapper/instance.md)
Returns the wrapper's underlying instance.
+#### [`.getWrappingComponent() => ReactWrapper`](ReactWrapper/getWrappingComponent.md)
+Returns a wrapper representing the `wrappingComponent`, if one was passed.
+
#### [`.unmount() => ReactWrapper`](ReactWrapper/unmount.md)
A method that un-mounts the component.
diff --git a/docs/api/shallow.md b/docs/api/shallow.md
index 7fd47d65a..77065a61a 100644
--- a/docs/api/shallow.md
+++ b/docs/api/shallow.md
@@ -50,6 +50,8 @@ describe('', () => {
- `options.disableLifecycleMethods`: (`Boolean` [optional]): If set to true, `componentDidMount`
is not called on the component, and `componentDidUpdate` is not called after
[`setProps`](ShallowWrapper/setProps.md) and [`setContext`](ShallowWrapper/setContext.md). Default to `false`.
+- `options.wrappingComponent`: (`ComponentType` [optional]): A component that will render as a parent of the `node`. It can be used to provide context to the `node`, among other things. See the [`getWrappingComponent()` docs](ShallowWrapper/getWrappingComponent.md) for an example. **Note**: `wrappingComponent` _must_ render its children.
+- `options.wrappingComponentProps`: (`Object` [optional]): Initial props to pass to the `wrappingComponent` if it is specified.
#### Returns
@@ -187,6 +189,9 @@ Manually sets props of the root component.
#### [`.setContext(context) => ShallowWrapper`](ShallowWrapper/setContext.md)
Manually sets context of the root component.
+#### [`.getWrappingComponent() => ShallowWrapper`](ShallowWrapper/getWrappingComponent.md)
+Returns a wrapper representing the `wrappingComponent`, if one was passed.
+
#### [`.instance() => ReactComponent`](ShallowWrapper/instance.md)
Returns the instance of the root component.
diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx
index d23c3df93..895c88245 100644
--- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx
+++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx
@@ -176,6 +176,191 @@ describeWithDOM('mount', () => {
expect(() => wrapper.state('key')).to.throw('ReactWrapper::state("key") requires that `state` not be `null` or `undefined`');
});
+ describeIf(is('>= 0.14'), 'wrappingComponent', () => {
+ const realCreateMountRenderer = getAdapter().createMountRenderer;
+ let wrapper;
+
+ class More extends React.Component {
+ render() {
+ return null;
+ }
+ }
+
+ class TestProvider extends React.Component {
+ getChildContext() {
+ const { value, renderMore } = this.props;
+
+ return {
+ testContext: value || 'Hello world!',
+ renderMore: renderMore || false,
+ };
+ }
+
+ render() {
+ const { children } = this.props;
+
+ return children;
+ }
+ }
+ TestProvider.childContextTypes = {
+ testContext: PropTypes.string,
+ renderMore: PropTypes.bool,
+ };
+
+ class MyWrappingComponent extends React.Component {
+ render() {
+ const { children, contextValue, renderMore } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+ }
+
+ class MyComponent extends React.Component {
+ render() {
+ const { testContext, renderMore } = this.context;
+
+ return (
+
+
Context says: {testContext}
+ {renderMore &&
}
+
+ );
+ }
+ }
+ MyComponent.contextTypes = TestProvider.childContextTypes;
+
+ beforeEach(() => {
+ wrapper = mount(, {
+ wrappingComponent: MyWrappingComponent,
+ });
+ });
+
+ it('mounts the passed node as the root as per usual', () => {
+ expect(wrapper.type()).to.equal(MyComponent);
+ expect(wrapper.parent().exists()).to.equal(false);
+ expect(() => wrapper.setProps({ foo: 'bar' })).not.to.throw();
+ });
+
+ it('renders the root in the wrapping component', () => {
+ // Context will only be set properly if the root node is rendered as a descendent of
+ // the wrapping component.
+ expect(wrapper.text()).to.equal('Context says: Hello world!');
+ });
+
+ it('supports mounting the wrapping component with initial props', () => {
+ wrapper.unmount();
+ wrapper = mount(, {
+ wrappingComponent: MyWrappingComponent,
+ wrappingComponentProps: { contextValue: 'I can be set!' },
+ });
+ expect(wrapper.text()).to.equal('Context says: I can be set!');
+ });
+
+ it('throws an error if the wrappingComponent does not render its children', () => {
+ class BadWrapper extends React.Component {
+ render() {
+ return ;
+ }
+ }
+ expect(() => mount(, {
+ wrappingComponent: BadWrapper,
+ })).to.throw('`wrappingComponent` must render its children!');
+ });
+
+ describe('getWrappingComponent()', () => {
+ let wrappingComponent;
+
+ beforeEach(() => {
+ wrappingComponent = wrapper.getWrappingComponent();
+ });
+
+ it('gets a ReactWrapper for the wrappingComponent', () => {
+ expect(wrappingComponent.type()).to.equal(MyWrappingComponent);
+ expect(wrappingComponent.parent().exists()).to.equal(false);
+
+ wrappingComponent.setProps({ contextValue: 'this is a test.' });
+ expect(wrapper.text()).to.equal('Context says: this is a test.');
+ });
+
+ it('updates the wrapper when the wrappingComponent is updated', () => {
+ wrappingComponent.setProps({ renderMore: true });
+ expect(wrapper.find(More).exists()).to.equal(true);
+ });
+
+ it('updates the wrappingComponent when the root is updated', () => {
+ wrapper.unmount();
+ expect(wrappingComponent.exists()).to.equal(false);
+ });
+
+ it('handles the wrapper being unmounted', () => {
+ wrapper.unmount();
+ wrappingComponent.update();
+ expect(wrappingComponent.exists()).to.equal(false);
+ expect(() => wrappingComponent.setProps({})).to.throw('The wrapping component may not be updated if the root is unmounted.');
+ });
+
+ it('handles a partial prop update', () => {
+ wrappingComponent.setProps({ contextValue: 'hello' });
+ wrappingComponent.setProps({ foo: 'bar' });
+ expect(wrappingComponent.prop('foo')).to.equal('bar');
+ expect(wrappingComponent.prop('contextValue')).to.equal('hello');
+ });
+
+ it('cannot be called on the non-root', () => {
+ expect(() => wrapper.find('div').getWrappingComponent()).to.throw('ReactWrapper::getWrappingComponent() can only be called on the root');
+ });
+
+ it('cannot be called on itself', () => {
+ expect(() => wrappingComponent.getWrappingComponent()).to.throw('ReactWrapper::getWrappingComponent() can only be called on the root');
+ });
+
+ it('throws an error if `wrappingComponent` was not provided', () => {
+ wrapper.unmount();
+ wrapper = mount();
+ expect(() => wrapper.getWrappingComponent()).to.throw('ReactWrapper::getWrappingComponent() can only be called on a wrapper that was originally passed a `wrappingComponent` option');
+ });
+ });
+
+ wrap()
+ .withOverrides(() => getAdapter(), () => ({
+ RootFinder: undefined,
+ createMountRenderer: (...args) => {
+ const renderer = realCreateMountRenderer(...args);
+ delete renderer.getWrappingComponentRenderer;
+ renderer.getNode = () => null;
+ return renderer;
+ },
+ isCustomComponent: undefined,
+ }))
+ .describe('with an old adapter', () => {
+ it('renders fine when wrappingComponent is not passed', () => {
+ wrapper = mount();
+ });
+
+ it('throws an error if wrappingComponent is passed', () => {
+ expect(() => mount(, {
+ wrappingComponent: MyWrappingComponent,
+ })).to.throw('your adapter does not support `wrappingComponent`. Try upgrading it!');
+ });
+ });
+ });
+
+ itIf(is('<=0.13'), 'throws an error if wrappingComponent is passed', () => {
+ class WrappingComponent extends React.Component {
+ render() {
+ const { children } = this.props;
+ return children;
+ }
+ }
+ expect(() => mount(, {
+ wrappingComponent: WrappingComponent,
+ })).to.throw('your adapter does not support `wrappingComponent`. Try upgrading it!');
+ });
+
describeIf(is('>= 16.3'), 'uses the isValidElementType from the Adapter to validate the prop type of Component', () => {
const Foo = () => null;
const Bar = () => null;
diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx
index 86459e854..e3bdd924e 100644
--- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx
+++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx
@@ -166,6 +166,198 @@ describe('shallow', () => {
`.trim());
expect(() => wrapper.state('key')).to.throw('ShallowWrapper::state("key") requires that `state` not be `null` or `undefined`');
});
+
+ describeIf(is('>= 0.14'), 'wrappingComponent', () => {
+ let wrapper;
+
+ class More extends React.Component {
+ render() {
+ return null;
+ }
+ }
+
+ class StateTester extends React.Component {
+ render() {
+ return null;
+ }
+ }
+
+ class TestProvider extends React.Component {
+ getChildContext() {
+ const { value, renderMore, renderStateTester } = this.props;
+
+ return {
+ testContext: value || 'Hello world!',
+ renderMore: renderMore || false,
+ renderStateTester: renderStateTester || false,
+ };
+ }
+
+ render() {
+ const { children } = this.props;
+
+ return {children};
+ }
+ }
+ TestProvider.childContextTypes = {
+ testContext: PropTypes.string,
+ renderMore: PropTypes.bool,
+ renderStateTester: PropTypes.bool,
+ };
+
+ class MyWrappingComponent extends React.Component {
+ constructor() {
+ super();
+ this.state = { renderStateTester: false };
+ }
+
+ render() {
+ const { children, contextValue, renderMore } = this.props;
+ const { renderStateTester } = this.state;
+
+ return (
+
+ );
+ }
+ }
+
+ class MyComponent extends React.Component {
+ render() {
+ const {
+ testContext,
+ renderMore = true,
+ renderStateTester,
+ explicitContext,
+ } = this.context;
+ return (
+
+
Context says: {testContext}{explicitContext}
+ {renderMore &&
}
+ {renderStateTester &&
}
+
+ );
+ }
+ }
+ MyComponent.contextTypes = {
+ ...TestProvider.childContextTypes,
+ explicitContext: PropTypes.bool,
+ };
+
+ beforeEach(() => {
+ wrapper = shallow(, {
+ wrappingComponent: MyWrappingComponent,
+ context: {
+ explicitContext: ' stop!',
+ },
+ });
+ });
+
+ it('mounts the passed node as the root as per usual', () => {
+ expect(wrapper.type()).to.equal('div');
+ expect(wrapper.parent().exists()).to.equal(false);
+ expect(() => wrapper.setProps({ foo: 'bar' })).not.to.throw();
+ });
+
+ it('renders the root in the wrapping component', () => {
+ // Context will only be set properly if the root node is rendered as a descendent of
+ // the wrapping component.
+ expect(wrapper.text()).to.equal('Context says: Hello world! stop!');
+ });
+
+ it('supports mounting the wrapping component with initial props', () => {
+ wrapper.unmount();
+ wrapper = shallow(, {
+ wrappingComponent: MyWrappingComponent,
+ wrappingComponentProps: { contextValue: 'I can be set!' },
+ });
+ expect(wrapper.text()).to.equal('Context says: I can be set!');
+ });
+
+ it('throws an error if the wrappingComponent does not render its children', () => {
+ class BadWrapper extends React.Component {
+ render() {
+ return ;
+ }
+ }
+ expect(() => shallow(, {
+ wrappingComponent: BadWrapper,
+ })).to.throw('`wrappingComponent` must render its children!');
+ });
+
+ describe('getWrappingComponent()', () => {
+ let wrappingComponent;
+
+ beforeEach(() => {
+ wrappingComponent = wrapper.getWrappingComponent();
+ });
+
+ it('gets a ShallowWrapper for the wrappingComponent', () => {
+ expect(wrappingComponent.parent().exists()).to.equal(false);
+ wrappingComponent.setProps({ contextValue: 'this is a test.' });
+ expect(wrapper.text()).to.equal('Context says: this is a test. stop!');
+ });
+
+ it('updates the primary wrapper after a state update', () => {
+ wrappingComponent.setState({ renderStateTester: true });
+ expect(wrapper.find(StateTester).exists()).to.equal(true);
+ expect(wrapper.text()).to.equal('Context says: Hello world! stop!');
+ });
+
+ it('updates the wrapper when the wrappingComponent is updated', () => {
+ wrappingComponent.setProps({ renderMore: true });
+ expect(wrapper.find(More).exists()).to.equal(true);
+ expect(wrapper.text()).to.equal('Context says: Hello world! stop!');
+ });
+
+ it('handles the wrapper being unmounted', () => {
+ wrapper.unmount();
+ expect(wrappingComponent.debug()).to.equal('');
+ });
+
+ it('cannot be called on the non-root', () => {
+ expect(() => wrapper.find('div').getWrappingComponent()).to.throw('ShallowWrapper::getWrappingComponent() can only be called on the root');
+ });
+
+ it('cannot be called on itself', () => {
+ expect(() => wrappingComponent.getWrappingComponent()).to.throw('ShallowWrapper::getWrappingComponent() can only be called on the root');
+ });
+
+ it('throws an error if `wrappingComponent` was not provided', () => {
+ wrapper.unmount();
+ wrapper = shallow();
+ expect(() => wrapper.getWrappingComponent()).to.throw('ShallowWrapper::getWrappingComponent() can only be called on a wrapper that was originally passed a `wrappingComponent` option');
+ });
+ });
+
+ wrap()
+ .withOverrides(() => getAdapter(), () => ({
+ RootFinder: undefined,
+ wrapWithWrappingComponent: undefined,
+ isCustomComponent: undefined,
+ }))
+ .describe('with an old adapter', () => {
+ it('renders fine when wrappingComponent is not passed', () => {
+ wrapper = shallow();
+ expect(wrapper.type()).to.equal('div');
+ });
+
+ it('throws an error if wrappingComponent is passed', () => {
+ expect(() => shallow(, {
+ wrappingComponent: MyWrappingComponent,
+ })).to.throw('your adapter does not support `wrappingComponent`. Try upgrading it!');
+ });
+ });
+ });
});
describe('context', () => {
diff --git a/packages/enzyme/src/ReactWrapper.js b/packages/enzyme/src/ReactWrapper.js
index 1a8f75edc..6ce446934 100644
--- a/packages/enzyme/src/ReactWrapper.js
+++ b/packages/enzyme/src/ReactWrapper.js
@@ -14,6 +14,7 @@ import {
privateSet,
cloneElement,
renderedDive,
+ isCustomComponent,
} from './Utils';
import getAdapter from './getAdapter';
import { debugNodes } from './Debug';
@@ -35,6 +36,9 @@ const UNRENDERED = sym('__unrendered__');
const ROOT = sym('__root__');
const OPTIONS = sym('__options__');
const ROOT_NODES = sym('__rootNodes__');
+const WRAPPING_COMPONENT = sym('__wrappingComponent__');
+const LINKED_ROOTS = sym('__linkedRoots__');
+const UPDATED_BY = sym('__updatedBy__');
/**
* Finds all nodes in the current wrapper nodes' render trees that match the provided predicate
@@ -105,20 +109,35 @@ class ReactWrapper {
throw new TypeError('ReactWrapper can only wrap valid elements');
}
- privateSet(this, UNRENDERED, nodes);
const renderer = adapter.createRenderer({ mode: 'mount', ...options });
privateSet(this, RENDERER, renderer);
renderer.render(nodes, options.context);
privateSet(this, ROOT, this);
privateSetNodes(this, this[RENDERER].getNode());
+ privateSet(this, OPTIONS, options);
+ privateSet(this, LINKED_ROOTS, []);
+
+ if (isCustomComponent(options.wrappingComponent, adapter)) {
+ if (typeof this[RENDERER].getWrappingComponentRenderer !== 'function') {
+ throw new TypeError('your adapter does not support `wrappingComponent`. Try upgrading it!');
+ }
+
+ // eslint-disable-next-line no-use-before-define
+ privateSet(this, WRAPPING_COMPONENT, new WrappingComponentWrapper(
+ this, this[RENDERER].getWrappingComponentRenderer(),
+ ));
+ this[LINKED_ROOTS].push(this[WRAPPING_COMPONENT]);
+ }
} else {
- privateSet(this, UNRENDERED, null);
privateSet(this, RENDERER, root[RENDERER]);
privateSet(this, ROOT, root);
privateSetNodes(this, nodes);
privateSet(this, ROOT_NODES, root[NODES]);
+ privateSet(this, OPTIONS, root[OPTIONS]);
+ privateSet(this, LINKED_ROOTS, []);
}
- privateSet(this, OPTIONS, root ? root[OPTIONS] : options);
+ privateSet(this, UNRENDERED, nodes);
+ privateSet(this, UPDATED_BY, null);
}
/**
@@ -222,6 +241,23 @@ class ReactWrapper {
return this.single('instance', () => this[NODE].instance);
}
+ /**
+ * If a `wrappingComponent` was passed in `options`, this methods returns a `ReactWrapper` around
+ * the rendered `wrappingComponent`. This `ReactWrapper` can be used to update the
+ * `wrappingComponent`'s props, state, etc.
+ *
+ * @returns ReactWrapper
+ */
+ getWrappingComponent() {
+ if (this[ROOT] !== this) {
+ throw new Error('ReactWrapper::getWrappingComponent() can only be called on the root');
+ }
+ if (!this[OPTIONS].wrappingComponent) {
+ throw new Error('ReactWrapper::getWrappingComponent() can only be called on a wrapper that was originally passed a `wrappingComponent` option');
+ }
+ return this[WRAPPING_COMPONENT];
+ }
+
/**
* Forces a re-render. Useful to run before checking the render output if something external
* may be updating the state of the component somewhere.
@@ -236,6 +272,20 @@ class ReactWrapper {
return root.update();
}
privateSetNodes(this, this[RENDERER].getNode());
+ this[LINKED_ROOTS].forEach((linkedRoot) => {
+ if (linkedRoot !== this[UPDATED_BY]) {
+ /* eslint-disable no-param-reassign */
+ // Only update a linked it root if it is not the originator of our update().
+ // This is needed to prevent infinite recursion when there is a bi-directional
+ // link between two roots.
+ linkedRoot[UPDATED_BY] = this;
+ try {
+ linkedRoot.update();
+ } finally {
+ linkedRoot[UPDATED_BY] = null;
+ }
+ }
+ });
return this;
}
@@ -1176,6 +1226,28 @@ class ReactWrapper {
}
}
+/**
+ * A *special* "root" wrapper that represents the component passed as `wrappingComponent`.
+ * It is linked to the primary root such that updates to it will update the primary,
+ * and vice versa.
+ *
+ * @class WrappingComponentWrapper
+ */
+class WrappingComponentWrapper extends ReactWrapper {
+ /* eslint-disable class-methods-use-this */
+ constructor(root, renderer) {
+ super(renderer.getNode(), root);
+
+ privateSet(this, ROOT, this);
+ privateSet(this, RENDERER, renderer);
+ this[LINKED_ROOTS].push(root);
+ }
+
+ getWrappingComponent() {
+ throw new TypeError('ReactWrapper::getWrappingComponent() can only be called on the root');
+ }
+}
+
if (ITERATOR_SYMBOL) {
Object.defineProperty(ReactWrapper.prototype, ITERATOR_SYMBOL, {
configurable: true,
diff --git a/packages/enzyme/src/ShallowWrapper.js b/packages/enzyme/src/ShallowWrapper.js
index fca3632d6..95a8f1c36 100644
--- a/packages/enzyme/src/ShallowWrapper.js
+++ b/packages/enzyme/src/ShallowWrapper.js
@@ -10,6 +10,7 @@ import {
typeOfNode,
isReactElementAlike,
displayNameOfNode,
+ isCustomComponent,
isCustomComponentElement,
ITERATOR_SYMBOL,
makeOptions,
@@ -41,6 +42,9 @@ const OPTIONS = sym('__options__');
const SET_STATE = sym('__setState__');
const ROOT_NODES = sym('__rootNodes__');
const CHILD_CONTEXT = sym('__childContext__');
+const WRAPPING_COMPONENT = sym('__wrappingComponent__');
+const PRIMARY_WRAPPER = sym('__primaryWrapper__');
+const ROOT_FINDER = sym('__rootFinder__');
/**
* Finds all nodes in the current wrapper nodes' render trees that match the provided predicate
@@ -273,6 +277,90 @@ function mockSCUIfgDSFPReturnNonNull(node, state) {
}
}
+/**
+ * Recursively dive()s every custom component in a wrapper until
+ * the target component is found.
+ *
+ * @param {ShallowWrapper} wrapper A ShallowWrapper to search
+ * @param {ComponentType} target A react custom component that, when found, will end recursion
+ * @param {Adapter} adapter An Enzyme adapter
+ * @returns {ShallowWrapper|undefined} A ShallowWrapper for the target, or
+ * undefined if it can't be found
+ */
+function deepRender(wrapper, target, adapter) {
+ const node = wrapper[NODE];
+ const element = node && adapter.nodeToElement(node);
+ if (wrapper.type() === target) {
+ return wrapper.dive();
+ }
+ if (element && isCustomComponentElement(element, adapter)) {
+ return deepRender(wrapper.dive(), target, adapter);
+ }
+ const children = wrapper.children();
+ for (let i = 0; i < children.length; i += 1) {
+ const found = deepRender(children.at(i), target, adapter);
+ if (typeof found !== 'undefined') {
+ return found;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Deep-renders the `wrappingComponent` and returns the context that should
+ * be accessible to the primary wrapper.
+ *
+ * @param {WrappingComponentWrapper} wrapper The `WrappingComponentWrapper` for a
+ * `wrappingComponent`
+ * @param {Adapter} adapter An Enzyme adapter
+ * @returns {object} The context collected
+ */
+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;
+}
+
+/**
+ * Makes options specifically for `ShallowWrapper`. Most of the logic here is around rendering
+ * a `wrappingComponent` (if one was provided) and adding the child context of that component
+ * to `options.context`.
+ *
+ * @param {ReactElement} nodes the nodes passed to `ShallowWrapper`
+ * @param {ShallowWrapper} root this `ShallowWrapper`'s parent. If this is passed, options are
+ * not transformed.
+ * @param {*} passedOptions the options passed to `ShallowWrapper`.
+ * @param {*} wrapper the `ShallowWrapper` itself
+ * @returns {Object} the decorated and transformed options
+ */
+function makeShallowOptions(nodes, root, passedOptions, wrapper) {
+ const options = makeOptions(passedOptions);
+ const adapter = getAdapter(passedOptions);
+ if (root || !isCustomComponent(options.wrappingComponent, adapter)) {
+ return options;
+ }
+ if (typeof adapter.wrapWithWrappingComponent !== 'function') {
+ throw new TypeError('your adapter does not support `wrappingComponent`. Try upgrading it!');
+ }
+ 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,
+ );
+ privateSet(wrapper, WRAPPING_COMPONENT, wrappingComponent);
+ return {
+ ...options,
+ context: {
+ ...options.context,
+ ...wrappingComponentContext,
+ },
+ };
+}
+
+
/**
* @class ShallowWrapper
*/
@@ -280,7 +368,7 @@ class ShallowWrapper {
constructor(nodes, root, passedOptions = {}) {
validateOptions(passedOptions);
- const options = makeOptions(passedOptions);
+ const options = makeShallowOptions(nodes, root, passedOptions, this);
const adapter = getAdapter(options);
const lifecycles = getAdapterLifecycles(adapter);
@@ -414,6 +502,23 @@ class ShallowWrapper {
return this[RENDERER].getNode().instance;
}
+ /**
+ * If a `wrappingComponent` was passed in `options`, this methods returns a `ShallowWrapper`
+ * around the rendered `wrappingComponent`. This `ShallowWrapper` can be used to update the
+ * `wrappingComponent`'s props, state, etc.
+ *
+ * @returns ShallowWrapper
+ */
+ getWrappingComponent() {
+ if (this[ROOT] !== this) {
+ throw new Error('ShallowWrapper::getWrappingComponent() can only be called on the root');
+ }
+ if (!this[OPTIONS].wrappingComponent) {
+ throw new Error('ShallowWrapper::getWrappingComponent() can only be called on a wrapper that was originally passed a `wrappingComponent` option');
+ }
+ return this[WRAPPING_COMPONENT];
+ }
+
/**
* Forces a re-render. Useful to run before checking the render output if something external
* may be updating the state of the component somewhere.
@@ -440,6 +545,9 @@ class ShallowWrapper {
*/
unmount() {
this[RENDERER].unmount();
+ if (this[ROOT][WRAPPING_COMPONENT]) {
+ this[ROOT][WRAPPING_COMPONENT].unmount();
+ }
return this;
}
@@ -1568,6 +1676,60 @@ class ShallowWrapper {
}
}
+/**
+ * Updates the context of the primary wrapper when the
+ * `wrappingComponent` re-renders.
+ */
+function updatePrimaryRootContext(wrappingComponent) {
+ const context = getContextFromWrappingComponent(
+ wrappingComponent,
+ getAdapter(wrappingComponent[OPTIONS]),
+ );
+ wrappingComponent[PRIMARY_WRAPPER].setContext({
+ ...wrappingComponent[PRIMARY_WRAPPER][OPTIONS].context,
+ ...context,
+ });
+}
+
+/**
+ * A *special* "root" wrapper that represents the component passed as `wrappingComponent`.
+ * It is linked to the primary root such that updates to it will update the primary.
+ *
+ * @class WrappingComponentWrapper
+ */
+class WrappingComponentWrapper extends ShallowWrapper {
+ constructor(nodes, root, RootFinder) {
+ super(nodes);
+ privateSet(this, PRIMARY_WRAPPER, root);
+ privateSet(this, ROOT_FINDER, RootFinder);
+ }
+
+ /**
+ * Like rerender() on ShallowWrapper, except it also does a "full render" of
+ * itself and updates the primary ShallowWrapper's context.
+ */
+ rerender(...args) {
+ const result = super.rerender(...args);
+ updatePrimaryRootContext(this);
+ return result;
+ }
+
+ /**
+ * Like setState() on ShallowWrapper, except it also does a "full render" of
+ * itself and updates the primary ShallowWrapper's context.
+ */
+ setState(...args) {
+ const result = super.setState(...args);
+ updatePrimaryRootContext(this);
+ return result;
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ getWrappingComponent() {
+ throw new Error('ShallowWrapper::getWrappingComponent() can only be called on the root');
+ }
+}
+
if (ITERATOR_SYMBOL) {
Object.defineProperty(ShallowWrapper.prototype, ITERATOR_SYMBOL, {
configurable: true,
diff --git a/packages/enzyme/src/Utils.js b/packages/enzyme/src/Utils.js
index 54f1e3674..4637c1181 100644
--- a/packages/enzyme/src/Utils.js
+++ b/packages/enzyme/src/Utils.js
@@ -49,6 +49,13 @@ export function makeOptions(options) {
};
}
+export function isCustomComponent(component, adapter) {
+ if (adapter.isCustomComponent) {
+ return !!adapter.isCustomComponent(component);
+ }
+ return typeof component === 'function';
+}
+
export function isCustomComponentElement(inst, adapter) {
if (adapter.isCustomComponentElement) {
return !!adapter.isCustomComponentElement(inst);