Repository: mzgoddard/preact-render-spy
Pull request #43 (2017-09-13 17:46:22)

Add component(), setState(), state() methods
This is a PR to try to address #39.

It allows getting the component and manipulating states from a single node FindWrapper.

Thoughts @mzgoddard ?

@brunoscopelliti can you test out the branch and see if it helps your issue?


gnarf: An intersting thing to note in the documentation, is that the `.component()` returns the "Spied" subclass, but I'm not sure theirs any way around this.... It shouldn't matter much anyway since it's a subclass.
brunoscopelliti: @gnarf Sure!
brunoscopelliti: I've tried to use the methods you've now exposed, and they seem to work fine.
I get the result I expected.

Thanks for having taken immediate action on this.
mzgoddard: @gnarf I think we need to add a `componentWillUnmount` override to the class spies to unset the componentMap key for that spy when a context is re-rendered. We need to ensure you can't get a component with `.component` that doesn't exist anymore.

Maybe we don't need the `Unmount` handle. We need a test to ensure you can't get a no longer existing component. I believe in the current change in this PR you could get the `.component()` for an existing FindWrapper, even if the component no longer exists in the spied dom.
gnarf: > Maybe we don't need the Unmount handle. We need a test to ensure you can't get a no longer existing component. I believe in the current change in this PR you could get the .component() for an existing FindWrapper, even if the component no longer exists in the spied dom.

Hrm... so this brings up an interesting scenario...

```jsx

const context = shallow(<div><MyComponent /></div>);

const myComponent = context.find('MyComponent');
context.render(<div><MyComponent /></div>);
```
technically, at this point, `MyComponent` no longer maps to that first "found" `MyComponent` vdom instance, it's now tied to the new one.

I can totally see the "unmount" issue as well, but should all the old "find"s be invalidated for the components?

gnarf: I think the "interesting" thing is that basically holding onto any `.find()` result is probably a bad idea, if anything re-renders, you're working against an "old vdom" version.
mzgoddard: Yeah I think we should advise against holding `.find()`s between renders or state changes. Outside cases we explicitly make error there could still be issues leading to tests, testing the old environment.

I think it'd be fine to invalidate all `.find()`s after any change to the dom. That would handle this case and any other ones that could come up and give false positives or false negatives.
mzgoddard: We could invalidate by having a `renderRevision` on the `Context` and on the `Wrappers`. If the revision is different between the wrapper and its context, it should error on any request.
brunoscopelliti: At the moment I'm not of help with the code, but if you need any help with testing just give me a shout.

Patch:
diff --git a/README.md b/README.md
index 0f85b4d..413b685 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@ This will return you a [`FindWrapper`](#findwrapper) which has other useful meth
 Like [`#find(selector)`](#rendercontextfindselector) `RenderContext` has the rest of `FindWrapper`'s methods.
 
 ### `RenderContext#render(jsx)`
-Re-renders the root level jsx node using the same depth initially requested.  This can be useful for testing
+Renders the root level jsx node using the same depth initially requested.  This can be useful for testing
 `componentWillReceiveProps` hooks.
 
 Example:
@@ -125,6 +125,9 @@ context.render(<Node name="second" />);
 expect(context.find('div').text()).toBe('second');
 ```
 
+### `RenderContext.rerender()`
+Calls `preact.rerender()` which performs any state changes in the render queue.
+
 ### `FindWrapper`
 Contains a selection of nodes from `RenderContext#find(selector)`.
 Has numeric indexed properties and length like an array.
@@ -141,15 +144,59 @@ Returns the value of the `name` attribute on the jsx node.
 Requires a single node selection to work.
 Returns a copy of the attributes passed to the jsx node.
 
+### `FindWrapper#component()`
+Requires a single node, which is a class based component.
+Returns the **Spied** component. preact-render-spy creates a subclass of your components that enable us to spy things, you'll get a `class Spy extends YourComponent` instance.
+
+Example:
+```jsx
+const context = shallow(<MyComponent />);
+expect(context.component()).toBeInstanceOf(MyComponent);
+```
+
 ### `FindWrapper#contains(vdom)`
 Searches for any children matching the vdom or text passed.
 
-### `FindWrapper#text()`
-Returns the flattened string of any text children of any child component.
+### `FindWrapper#filter(selector)`
+Returns a new `FindWrapper` with a subset of the previously selected elements given the selector argument.
+
+Uses the same possible selectors as [`RenderContext#find(selector)`](#rendercontextfindselector).
+
+### `FindWrapper#find(selector)`
+Selects descendents of the elements previously selected. Returns a new `FindWrapper` with the newly selected elements.
+
+Uses the same possible selectors as [`RenderContext#find(selector)`](#rendercontextfindselector).
+
+
+### `FindWrapper#setState(newState)`
+Requires a single node, which is a class based component.
+Allows you to set the state of a rendered component.  Automatically `rerender()`s the view.
+
+Example:
+```jsx
+const context = shallow(<ClickCounter />);
+context.setState({ count: 2 });
+expect(context.text()).toEqual('2');
+```
 
 ### `FindWrapper#simulate(event, ...args)`
 Looks for an attribute properly named `onEvent` or `onEventCapture` and calls it, passing the arguments.
 
+### `FindWrapper#state(key)`
+Requires a single node, which is a class based component.
+Reads the current state from the component. When passed `key`, this is essentially shorthand `state(key) === state()[key]`.
+
+Example:
+```jsx
+const context = shallow(<ClickCounter />);
+expect(context.state()).toEqual({ count: 0 });
+context.find('[onClick]').simulate('click');
+expect(context.state('count')).toEqual(1);
+```
+
+### `FindWrapper#text()`
+Returns the flattened string of any text children of any child component.
+
 ### `FindWrapper#output()`
 Requires a single Component or functional node. Returns the vdom output of the given component.
 Any Component or functional nodes will be "recursive" up to the depth you specified.  I.E.:
@@ -166,23 +213,13 @@ expect(deep(<First />).output()).toEqual(<div>second first</div>);
 expect(shallow(<First />).output()).toEqual(<Second>first</Second>);
 ```
 
-### `FindWrapper#filter(selector)`
-Returns a new `FindWrapper` with a subset of the previously selected elements given the selector argument.
-
-Uses the same possible selectors as [`RenderContext#find(selector)`](#rendercontextfindselector).
-
-### `FindWrapper#find(selector)`
-Selects descendents of the elements previously selected. Returns a new `FindWrapper` with the newly selected elements.
-
-Uses the same possible selectors as [`RenderContext#find(selector)`](#rendercontextfindselector).
-
 ## Examples
 
 There are many examples in the source files.  Some [tests specific to shallow](https://github.com/mzgoddard/preact-render-spy/blob/master/src/shallow-render.test.js), [tests specific to deep](https://github.com/mzgoddard/preact-render-spy/blob/master/src/deep-render.test.js), and many more [tests against both](https://github.com/mzgoddard/preact-render-spy/blob/master/src/shared-render.test.js).
 
 ### Simulate Clicks:
 ```jsx
-class Node extends Component {
+class ClickCount extends Component {
   constructor(...args) {
     super(...args);
 
@@ -198,7 +235,7 @@ class Node extends Component {
     return <div onClick={this.onClick}>{count}</div>;
   }
 }
-const context = shallow(<Node/>);
+const context = shallow(<ClickCount/>);
 expect(context.find('div').contains('0')).toBeTruthy();
 context.find('[onClick]').simulate('click');
 expect(context.find('div').contains('1')).toBeTruthy();
diff --git a/src/preact-render-spy.js b/src/preact-render-spy.js
index 928754b..5bdb89b 100644
--- a/src/preact-render-spy.js
+++ b/src/preact-render-spy.js
@@ -40,6 +40,7 @@ const popSpyKey = _props => {
 
 const setVDom = (context, spyKey, vdom) => {
   context.vdomMap.set(spyKey, vdom);
+  context.vdomRevision++;
   return vdom;
 };
 
@@ -89,6 +90,7 @@ const createClassSpy = (context, Component) => {
     render(...args) {
       const spyKey = context.keyMap.get(this);
       const spyDepth = context.depthMap.get(this);
+      context.componentMap.set(spyKey, this);
       return spyWalk(context, setVDom(context, spyKey, super.render(...args)), spyDepth + 1);
     }
   }
@@ -156,11 +158,18 @@ const vdomFilter = (pred, vdomMap, vdom) => {
   return Array.from(skip(1, vdomIter(vdomMap, vdom))).filter(pred);
 };
 
+const verifyFoundNodes = wrapper => {
+  if (wrapper.vdomRevision !== wrapper.context.vdomRevision) {
+    console.warn('preact-render-spy: Warning! Performing operation on stale find() result.');
+  }
+};
+
 class FindWrapper {
   constructor(context, _iter, selector) {
     // Set a non-enumerable property for context. In case a user does an deep
     // equal comparison this removes the chance for recursive comparisons.
-    Object.defineProperty(this, 'context', {configurable: true, enumerable: false, value: context});
+    setHiddenProp(this, 'context', context || this);
+    setHiddenProp(this, 'vdomRevision', this.context.vdomRevision);
     this.length = 0;
     let iter = _iter;
     if (selector) {
@@ -180,6 +189,7 @@ class FindWrapper {
     if (index >= this.length) {
       throw new Error(`preact-render-spy: Must have enough results for .at(${index}).`);
     }
+    verifyFoundNodes(this);
 
     return new FindWrapper(this.context, [this[index]]);
   }
@@ -188,6 +198,7 @@ class FindWrapper {
     if (this.length > 1 || this.length === 0) {
       throw new Error(`preact-render-spy: Must have only 1 result for .attr(${name})`);
     }
+    verifyFoundNodes(this);
 
     const item = this[0];
     if (
@@ -206,6 +217,7 @@ class FindWrapper {
     if (this.length > 1 || this.length === 0) {
       throw new Error('preact-render-spy: Must have only 1 result for .attrs().');
     }
+    verifyFoundNodes(this);
 
     return Object.assign({}, this[0].attributes);
   }
@@ -214,6 +226,7 @@ class FindWrapper {
    * Return the text of all nested children concatenated together.
    */
   text() {
+    verifyFoundNodes(this);
     return Array.from(vdomWalk(this.context.vdomMap, Array.from(this)))
       // Filter for strings (text nodes)
       .filter(value => typeof value === 'string')
@@ -222,12 +235,14 @@ class FindWrapper {
   }
 
   contains(vdom) {
+    verifyFoundNodes(this);
     return Array.from(vdomWalk(this.context.vdomMap, Array.from(this)))
       .filter(value => isEqual(vdom, value))
       .length > 0;
   }
 
   simulate(event, ...args) {
+    verifyFoundNodes(this);
     for (let i = 0; i < this.length; i++) {
       const vdom = this[i];
       const eventlc = event.toLowerCase();
@@ -244,16 +259,47 @@ class FindWrapper {
   }
 
   find(selector) {
+    verifyFoundNodes(this);
     return new FindWrapper(this.context, Array.from(this), selector);
   }
 
   filter(selector) {
+    verifyFoundNodes(this);
     return new FindWrapper(
       this.context,
       Array.from(this).filter(isWhere(selToWhere(selector)))
     );
   }
 
+  component() {
+    if (this.length !== 1) {
+      throw new Error('preact-render-spy: component method can only be used on a single node');
+    }
+    verifyFoundNodes(this);
+
+    const ref = this.context.componentMap.get(this[0]);
+
+    if (!ref) {
+      throw new Error('preact-render-spy: not a component node, no instance to return');
+    }
+
+    return ref;
+  }
+
+  setState(...args) {
+    const val = this.component().setState(...args);
+    rerender();
+    return val;
+  }
+
+  state(key) {
+    const state = this.component().state;
+    if (key) {
+      return state[key];
+    }
+    return state;
+  }
+
   output() {
     if (this.length > 1 || this.length === 0) {
       throw new Error('preact-render-spy: Must have only 1 result for .output().');
@@ -262,6 +308,7 @@ class FindWrapper {
     if (!this[0] || typeof this[0].nodeName !== 'function') {
       throw new Error('preact-render-spy: Must have a result of a preact class or function component for .output()');
     }
+    verifyFoundNodes(this);
 
     const getOutput = node => {
       if (!node || typeof node !== 'object') {
@@ -289,6 +336,7 @@ class FindWrapper {
   }
 
   toString() {
+    verifyFoundNodes(this);
     const render = (jsx, index) => {
       if (typeof jsx.nodeName === 'function') {
         jsx = this.at(index).output();
@@ -312,6 +360,7 @@ const setHiddenProp = (object, prop, value) => {
   Object.defineProperty(object, prop, {
     enumerable: false,
     configurable: true,
+    writable: true,
     value,
   });
   return value;
@@ -343,7 +392,9 @@ class RenderContext extends FindWrapper {
 
     setHiddenProp(this, 'context', this);
     setHiddenProp(this, 'renderedDepth', (depth || Infinity) - 1);
+
     setHiddenProp(this, 'fragment', config.createFragment());
+    setHiddenProp(this, 'vdomRevision', 0);
 
     // Create our Maps
     ['keyMap', 'depthMap', 'componentMap', 'componentNoopMap', 'vdomMap'].forEach(prop => {
@@ -369,6 +420,10 @@ class RenderContext extends FindWrapper {
 
     return this;
   }
+
+  rerender() {
+    rerender();
+  }
 }
 
 const deep = (vdom, {depth = Infinity} = {}) => new RenderContext({depth}).render(vdom);
@@ -380,6 +435,7 @@ exports.config = config;
 exports.deep = deep;
 exports.default = deep;
 exports.render = deep;
+exports.rerender = rerender;
 exports.shallow = shallow;
 exports.RenderContext = RenderContext;
 exports.FindWrapper = FindWrapper;


Test patch:
diff --git a/src/shared-render.test.js b/src/shared-render.test.js
index c4994ef..eb32bfe 100644
--- a/src/shared-render.test.js
+++ b/src/shared-render.test.js
@@ -223,6 +223,72 @@ const sharedTests = (name, func) => {
     const Deeper = () => <div><Text /></div>;
     expect(func(<Deeper />)).toMatchSnapshot();
   });
+
+  it(`${name}: can retrieve component instance`, () => {
+    const context = func(<ClickCount />);
+
+    expect(context.component()).toBeInstanceOf(ClickCount);
+  });
+
+  it(`${name}: can retrieve deeper component instances after renders`, () => {
+    const context = func(<div><ClickCount /></div>);
+
+    const component = context.find('ClickCount').component();
+    expect(component).toBeInstanceOf(ClickCount);
+
+    context.render(<div><ClickCount /></div>);
+    // This test ensures that even though this <ClickCount /> is not the same JSX node used
+    // in the initial context render, find('ClickCount').component()
+    // will still retrieve the same component
+    expect(context.find('ClickCount').component()).toEqual(component);
+
+  });
+
+  it(`${name}: can retrieve and set component state`, () => {
+    const context = func(<ClickCount />);
+
+    expect(context.state()).toEqual({ count: 0 });
+    expect(context.state('count')).toEqual(0);
+
+    context.setState({ count: 2 });
+
+    expect(context.text()).toEqual('2');
+  });
+
+  describe('warnings', () => {
+    const warn = console.warn;
+    let spy;
+    let context;
+    let found;
+
+    beforeEach(() => {
+      spy = jest.fn();
+      console.warn = spy;
+      context = func(<div><ClickCount /></div>);
+      found = context.find('ClickCount');
+      context.render(<div><ClickCount /></div>);
+    });
+
+    afterEach(() => {
+      console.warn = warn;
+    });
+
+    it(`${name}: warns when performing at on stale finds`, () => {
+      found.at(0);
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it(`${name}: warns when performing component on stale finds`, () => {
+      found.component();
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it(`${name}: warns when performing attrs on stale finds`, () => {
+      found.attrs();
+      expect(spy).toHaveBeenCalled();
+    });
+
+  });
 };
 
 sharedTests('deep', deep);