New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to mount()/shallow() a node inside a wrapping component #1960

Open
wants to merge 8 commits into
base: master
from

Conversation

Projects
None yet
2 participants
@minznerjosh
Copy link
Contributor

minznerjosh commented Jan 2, 2019

Overview

This PR adds an API for mount()ing a node inside of a custom wrappingComponent.

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 (
    <Provider store={customStore || store}>
      <Router>
        {children}
      </Router>
    </Provider>
  );
}

const wrapper = mount(<MyComponent />, {
  wrappingComponent: MyProvider,
});

It is also possible to get a ReactWrapper around the wrappingComponent:

const provider = wrapper.getWrappingComponent();
provider.setProps({ customStore: mockStore });
wrapper.update(); // update root wrapper after wrappingComponent props changed

- `.setProviders()` can only be used on a wrapper that was initially created with a call to `mount()`
- `.setProviders()` is only supported in React >= 16.3
- `.setProviders()` requires `enzyme-adapter-react-16@>=1.8` or `enzyme-adapter-react-16.3@>=1.5`

This comment has been minimized.

@minznerjosh

minznerjosh Jan 2, 2019

Contributor

These versions will need to be updated depending on when/if this makes it in.

This comment has been minimized.

@ljharb

ljharb Jan 2, 2019

Member

Let's remove version numbers entirely; there's no need to specify it here.

@ljharb

This comment has been minimized.

Copy link
Member

ljharb commented Jan 2, 2019

Adding createContext support in any form is a major effort; I want to set expectations early that this PR may not make it in at all, and that support is required to be identical for both mount and shallow.

@ljharb
Copy link
Member

ljharb left a comment

Nothing react-specific can go in enzyme itself, only in adapters - as such, a new adapter interface for createContext would have to be added to make this work. Additionally, we'd have to retain compatibility between old enzyme and new adapters, new enzyme and old adapters, and new enzyme and new adapters.


- `.setProviders()` can only be used on a wrapper that was initially created with a call to `mount()`
- `.setProviders()` is only supported in React >= 16.3
- `.setProviders()` requires `enzyme-adapter-react-16@>=1.8` or `enzyme-adapter-react-16.3@>=1.5`

This comment has been minimized.

@ljharb

ljharb Jan 2, 2019

Member

Let's remove version numbers entirely; there's no need to specify it here.

#### Common Gotchas

- `.setProviders()` can only be used on a wrapper that was initially created with a call to `mount()`
- `.setProviders()` is only supported in React >= 16.3

This comment has been minimized.

@ljharb

ljharb Jan 2, 2019

Member

mount itself has no direct tie to React; it should be able to work as long as the adapter supports it (which might not be react at all)

This comment has been minimized.

@minznerjosh

minznerjosh Jan 2, 2019

Contributor

Sounds good. Is there any prior art for what to do in a case where an adapter does not support a feature? For example, I'm assuming we'll never want to add support for this API to adapters that handle react@<16.3.

reversed.reverse();

return reversed.reduce((childElement, parentElement) => (
React.cloneElement(parentElement, null, childElement)

This comment has been minimized.

@ljharb

ljharb Jan 2, 2019

Member

cloning elements is very slow and should be avoided. why is it needed here?

This comment has been minimized.

@minznerjosh

minznerjosh Jan 2, 2019

Contributor

The goal is to convert the provider elements the user passes into a tree:

So this:

mount(<MyComponent />, {
  providers: [
    <ContextA.Provider value="A" />,
    <ContextB.Provider value="B" />,
    <ContextC.Provider value="C" />,
  ],
});

Is rendered as:

<ContextA.Provider value="A">
  <ContextB.Provider value="B">
    <ContextC.Provider value="C">
      <MyComponent />
    </ContextC.Provider>
  </ContextB.Provider>
</ContextA.Provider>

And I figured cloneElement() was the most idiomatic way to do this. I'm also working under the assumption that mutating the providers the user passes in would be a bad idea. Would a more manual

// not sure if this will actually work
{
  ...parentElement,
  props: {
    ...parentElement.props,
    children: childElement,
  },
}

be preferable, or is it the copying itself that is too slow to be acceptable?


this.setState({
providers: currentProviders.filter(provider => (
!providerTypes.includes(provider.type)

This comment has been minimized.

@ljharb

ljharb Jan 2, 2019

Member

this API means there's never any way to remove a provider, only to replace existing ones or add new ones.

Separately, providers being an array implies that the order matters - why and how? If it matters because it's nested into a tree, then it seems much simpler to only ever allow a single provider element, and come up with an alternative solution that lets the user determine their own nesting yet also indicate where children should be rendered.

This comment has been minimized.

@minznerjosh

minznerjosh Jan 2, 2019

Contributor

I think you can effectively remove a provider by doing

wrapper.setProviders([<Context.Provider value={undefined} />]);

As for order mattering, it doesn't. I used an array because objects can only have strings as keys and it seemed wrong to require configuration options that do not have a literal syntax (like Set or Map.) I wrote a bit about my thought process when coming up with the API in the PR description.)

But I'm open to an entirely different API if you have some ideas. 😊

This comment has been minimized.

@ljharb

ljharb Jan 2, 2019

Member

I'm a bit confused why multiple providers are needed, since you can do:

<Provider1>
  <Provider2 />
</Provider1>

and provide a single provider

This comment has been minimized.

@minznerjosh

minznerjosh Jan 3, 2019

Contributor

Ah got it! I was a bit confused by what you meant by "a single provider". I was thinking about the new context API in terms of individual values (as is the case with the old API), but perhaps that was me trying to make the new API into something that it is not.

The way you compose multiple contexts in the new React API is by nesting components—perhaps the enzyme API should reflect that, as you've pointed out! So, what do you think about this:

mount(<MyComponent />, {
  providers: (
    <ContextA.Provider value="A">
      <ContextB.Provider value="B">
        <ContextC.Provider value="C" />
      </ContextB.Provider>
    </ContextA.Provier>
  ),
});

And then changing via context by

wrapper.setProviders(
  <ContextA.Provider value="A">
    <ContextB.Provider value="foo">
      <ContextC.Provider value="C" />
    </ContextB.Provider>
  </ContextA.Provier>
);

My only concern is that this API makes it a bit challenging to change just a single provider's value without having to re-specify the entire tree. I think it will be common to be dealing with a bunch of different contexts from different libraries, all of which are required for a component to render. But each individual spec will deal with the how the component responds to just one of those contexts changing. So, what do you think about (in addition to the .setProviders() API) having an API like this:

wrapper.updateProvider(<ContextB.Provider value="foo" />);

Which would update just that provider, wherever it is in the tree, and throw an error if the provider is not already in the tree.

Also, I don't feel strongly about any of these names if you'd like them to be different. I am leaning towards keeping the plural "providers" because I think it makes it a bit more clear that the element can have many nested providers, though I do realize you are only passing a single react element.

This comment has been minimized.

@ljharb

ljharb Jan 3, 2019

Member

Re-specifying the entire tree is already the case if you want to change a child of the element you wrapped, for example - i'm also not clear on the use case of changing providers after initially wrapping.

Maybe instead of this feature being specific to createContext providers, it could be more general - something like, "wrapWith". A single render prop function that gets passed a child, and wraps it as needed.

Example:

const wrapper = mount(<Foo />, {
  wrapWith(root) {
    return (
      <ContextA.Provider value="A">
        <ContextB.Provider value="foo">
          <ContextC.Provider value="C">
            {root}
          </ContextC.Provider>
        </ContextB.Provider>
      </ContextA.Provider>
    );
  },
});

The API could throw if your wrapWith function failed to render the child (solo, ie, with no siblings), and then wrapper in this case would be <Foo />, not any of the providers, but they'd still be exercised in the rendering path.

This comment has been minimized.

@minznerjosh

minznerjosh Jan 3, 2019

Contributor

I think the use-case is something like this:

Imagine you have a react-flag-like library and some future version of react-redux that uses the new context API. You'd render your component like this (using the wrapWith API):

// my app's feature flags
const flags = {
  foo: false,
  bar: false,
};

const wrapper = mount(<Foo />, {
  wrapWith: root => (
    <FlagProvider value={flags}>
      <ReduxProvider value={store}>{root}</ReduxProvider>
    </FlagProvider>
  ),
});

And now we want to test that some side-effect in <Foo />'s componentDidUpdate gets called when the feature flag changes. We need some way of changing <FlagProvider />'s value, and it would be nice if we didn't have to re-setup the <ReduxProvider /> when we do that.

Also, if our component starts depending on a new provider in the future, it'd be really nice to only have to add it to the options we pass enzyme, and not every place where we're just changing a single provider's value.

I'm guessing the API for changing the wrapWith function could be something like

wrapper.setWrapWith(root => (
  <FlagProvider value={{ ...flags, bar: true }}>
    <ReduxProvider value={store}>{root}</ReduxProvider>
  </FlagProvider>
));

But that doesn't address my concern about having to respecify the entire tree. One solution that comes to mind (just spitballing here) is making wrapWith a component:

const wrapper = mount(<Foo />, {
  wrapper: {
    component: (props) => {
      const { flags, store, children } = props;

      return (
        <FlagProvider value={{ ...flags, bar: true }}>
          <ReduxProvider value={store}>{children}</ReduxProvider>
        </FlagProvider>
      );
    },
    props: { store: createStore, flags },
  },
});

And then having an API that lets you set the wrapper component's props:

wrapper.setWrapperProps({
  flags: {
    ...flags,
    foo: true,
  },
});

(wrapWith seemed like a better name for a function than a component so I changed it to just wrapper; but it also seems a bit confusing because wrapper is the conventional name for a ReactWrapper instance.)

This comment has been minimized.

@ljharb

ljharb Jan 3, 2019

Member

(We can bikeshed the names later)

OK, so you're suggesting the user be able to provide a "wrapping component", and provide explicit setProps/setState access on it - what about instead, wrapper.getWrappingComponent(), that returned a normal enzyme wrapper around that component - providing you with the same APIs?

This comment has been minimized.

@minznerjosh

minznerjosh Jan 3, 2019

Contributor

I like that idea a lot! I'll implement and then drop you a comment here so we can discuss. I really appreciate the time you've taken to work through this with me. Thank you!

@minznerjosh

This comment has been minimized.

Copy link
Contributor

minznerjosh commented Jan 2, 2019

@ljharb I figured that'd be the case! Just wanted to get something up here to get a conversation started.

@minznerjosh

This comment has been minimized.

Copy link
Contributor

minznerjosh commented Jan 2, 2019

@ljharb So, I'm sure you've noticed that this PR does not add support for shallow(). I'll give that a whirl, but I think it'd be best to hammer out an API before I do. Thanks again for such a speedy first review! Cheers!

@minznerjosh minznerjosh force-pushed the trialspark:josh__mount-new-context branch from 02c9040 to e675ca9 Jan 4, 2019

@minznerjosh minznerjosh changed the title Add a mount() API for manipulating React's new context Add ability to mount() a node inside a wrapping component Jan 4, 2019

}
RootFinder.propTypes = {
children: PropTypes.node.isRequired,
};

This comment has been minimized.

@minznerjosh

minznerjosh Jan 4, 2019

Contributor

This component is only ever used in createMountWrapper, so I think the code would actually be a bit easier to understand if it just lived in that file. But eslint forbids that so I stuck it here. Do you think this could warrant a rule override?

if (WrappingComponent) {
return (
<WrappingComponent {...wrappingComponentProps}>
<RootFinder ref={this.setRootFinderInstance}>{component}</RootFinder>

This comment has been minimized.

@minznerjosh

minznerjosh Jan 4, 2019

Contributor

We need a reliable way to get the node that was passed as the first argument to mount() and this seemed like the simplest way. Is it acceptable? Are there going to be performance implications of using a ref?

This comment has been minimized.

@ljharb

ljharb Jan 4, 2019

Member

we should avoid using ref, yes.

@@ -164,6 +164,156 @@ describeWithDOM('mount', () => {
expect(() => wrapper.state('key')).to.throw('ReactWrapper::state("key") requires that `state` not be `null` or `undefined`');
});

describeIf(is('>= 0.14') ,'wrappingComponent', () => {

This comment has been minimized.

@minznerjosh

minznerjosh Jan 4, 2019

Contributor

There's no reason this feature couldn't work in react 13, but the tests don't because react 13 uses owner-based context instead of parent-based. Is it worth coming up with a different test methodology for react 0.13, or are we okay with not supporting this feature in the 0.13 adapter? (I'm not sure how useful this feature is without parent-based context.)

This comment has been minimized.

@ljharb

ljharb Jan 4, 2019

Member

ideally we'll solve it for react 13 as well; it's probably OK if we defer for now as long as we're confident this enzyme API won't need to change to support it.

privateSet(this, ROOT, root);
privateSetNodes(this, nodes);
privateSet(this, ROOT_NODES, root[NODES]);
}
privateSet(this, UNRENDERED, nodes);

This comment has been minimized.

@minznerjosh

minznerjosh Jan 4, 2019

Contributor

UNRENDERED needs to be set to call .setProps() on the wrappingComponent. It didn't seem like there was any harm in setting it to nodes regardless of !root. (All the tests still passed.)

const node = this[WRAPPING_COMPONENT_RENDERER].getNode();
const wrapper = this.wrap(node);
privateSet(wrapper, ROOT, wrapper);
privateSet(wrapper, RENDERER, this[WRAPPING_COMPONENT_RENDERER]);

This comment has been minimized.

@minznerjosh

minznerjosh Jan 4, 2019

Contributor

This is a bit hacky. Is it acceptable? We don't want this new ReactWrapper to render itself, but we do want it to be a root (so we can .setProps() on it.)

We could also decide to not support .setProps() and make it a non-root. In that case, we could just get by using .setState() to change the context.

@minznerjosh

This comment has been minimized.

Copy link
Contributor

minznerjosh commented Jan 4, 2019

@ljharb

TL;DR;

  • I implemented the API we discussed for mount()
  • Some of the things I did might be a bit too hacky
  • Things could be simplified and made less-hacky if .getWrappingComponent() did not return a root
  • I'm not sure how much sense this API makes for shallow(); any possibility of moving forward without shallow()?

I took a stab at implementing the API we discussed for mount(). Because I wanted .setProps() to work, I made the .getWrappingComponent() wrapper a root (of sorts.) This led me to do some things that might be a bit questionable. Because we can now .setState() non-roots, perhaps we can live with .getWrappingComponent() not returning a root. That, however, would create complications around .getWrappingComponent().update(). (It would update the root, not the wrappingComponent.)

I don't use shallow() much in my day-to-day, but as I started digging into how it works, I started to doubt how useful this feature would be for shallow(). The only reason I can think of for the wrappingComponent feature to exist is for users can set up context (new or legacy) without changing the root wrapper. I think this is a need that arises from integration-style testing, whereas shallow() is strictly for unit-style tests. If we were to just shallow-render the wrappingComponent and then effectively .dive() to the root, the effect would be as if we didn't have a wrappingComponent at all.

Perhaps this is because, as others have pointed out, .dive() doesn't currently call .getChildContext() or look at .contextTypes. I imagine we could do something where we dive through every component in the wrappingComponent, collect its context, and then pass that context when we shallow render the root node, but that seems a bit complicated and would only work for legacy context, not the new context API. Perhaps we could do something similar with the new context Provider/Consumer, though.

But I still doubt how much sense wrappingComponent makes as a shallow() option. If shallow() exists to isolate components from each other, it doesn't seem intuitive to me to support wrapping your component in a wrappingComponent because shallow rendering, by definition, will shield the root component from the effects of wrappingComponent.

So, my question is, is there any possibility you'd be open to this being a mount()-only feature? If shallow() support really is required, what should the behavior for it be? If we decide that the wrappingComponent feature only exists for providing context, then there would be a lot of work required in shallow() to support that. If wrappingComponent exists literally just to wrap the enzyme root in another component, and follow the existing behavior for doing that with mount()/shallow(), then I don't think it's a useful feature for shallow().

@ljharb

This comment has been minimized.

Copy link
Member

ljharb commented Jan 4, 2019

shallow needs providers too; I can't see how it wouldn't make just as much sense as for mount. (and i'd suggest using shallow always, and only resorting to mount when necessary, but that's a more subjective discussion :-) ) shallow isn't just for "unit-style tests" - everything is an integration test, and shallow with a wrapping component would be for testing what's shallow-wrapped, but with certain context values and certain state-populating lifecycle methods to be called.

It's a serious issue that .dive() doesn't handle context properly, and my hope is that this API can lead us to a path that would allow that bug to be fixed.

@minznerjosh

This comment has been minimized.

Copy link
Contributor

minznerjosh commented Jan 4, 2019

So, is this the strategy you're looking for in shallow()?

  1. Do a full render of the wrappingComponent, but without a dependency on the DOM, stopping when we reach the node passed to shallow(node)
  2. Collect all the context values that were set in that full render of wrappingComponent (both new and legacy)
  3. Shallow-render the node, passing in all the context we collected in step 2 (using some new functionality that doesn't exist yet for new context)
@ljharb

This comment has been minimized.

Copy link
Member

ljharb commented Jan 4, 2019

Yep! sounds right to me (where "full render" is actually multiple shallow renders and dives)

@minznerjosh

This comment has been minimized.

Copy link
Contributor

minznerjosh commented Jan 4, 2019

Yup yup. Okay, in that case, I think I'm going to hit the pause button on this PR and try to add support for dive()ing <Consumer />s and <Provider />s in a separate PR. That seems like a separate issue that should be fixed independently, and fixing that issue should lay the foundation for implementing this feature.

@ljharb

This comment has been minimized.

Copy link
Member

ljharb commented Jan 4, 2019

That sounds like an awesome plan!

@minznerjosh minznerjosh changed the title Add ability to mount() a node inside a wrapping component Add ability to mount()/shallow() a node inside a wrapping component Jan 12, 2019

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment