From 47618cd626fad9a56d783aa67a9f4399cd77b01d Mon Sep 17 00:00:00 2001 From: Ben Alpert Date: Wed, 17 Aug 2016 15:48:49 -0700 Subject: [PATCH] Test renderer traversal If you do ``` x = ReactTestRenderer.create(); x.update(); x.getType(); ``` then you probably expect to get `'b'` back. But if you were to do ``` div = ReactTestRenderer.create(
); x = div.getChildren()[0]; div.update(
); x.getType(); ``` then since `x` points directly to the original `
` child, there's no possible way we could return `'b'`. So rather than have a confusing inconsistency between the top-level node and all the lower ones, we outlaw changing type and key so that you always have the same instance. I'm not actually sure I like this. It might make more sense to distinguish between the top-level instance and the children in the API -- since it also makes sense to call .update and .unmount at the top but nowhere else. --- src/renderers/testing/ReactTestMount.js | 77 ++++++++++++++++++- src/renderers/testing/ReactTestRenderer.js | 16 +++- .../__tests__/ReactTestRenderer-test.js | 26 ++++++- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/renderers/testing/ReactTestMount.js b/src/renderers/testing/ReactTestMount.js index a58acb15af332..4d51d08cecccc 100644 --- a/src/renderers/testing/ReactTestMount.js +++ b/src/renderers/testing/ReactTestMount.js @@ -19,6 +19,8 @@ var emptyObject = require('emptyObject'); var getHostComponentFromComposite = require('getHostComponentFromComposite'); var instantiateReactComponent = require('instantiateReactComponent'); var invariant = require('invariant'); +var shouldUpdateReactComponent = require('shouldUpdateReactComponent'); +var warning = require('warning'); /** * Temporary (?) hack so that we can store all top-level pending updates on @@ -77,17 +79,82 @@ function batchedMountComponentIntoNode( return image; } -var ReactTestInstance = function(component) { +var ReactTestInstance = function(isTopLevel, component) { + this._isTopLevel = isTopLevel; this._component = component; + this._typeChanged = false; +}; +ReactTestInstance.prototype._getInternalInstance = function() { + invariant( + !this._typeChanged, + 'ReactTestRenderer: Can\'t inspect or traverse after changing component ' + + 'type or key. Fix the earlier warning and try again.' + ); + var component = this._component; + if (this._isTopLevel) { + component = component._renderedComponent; + } + invariant( + // _unmounted is present on test (host) components, not on composites + component && !component._unmounted && component._renderedComponent !== null, + 'ReactTestRenderer: Can\'t inspect or traverse unmounted components.' + ); + return component; +}; +ReactTestInstance.prototype.isText = function() { + var el = this._getInternalInstance()._currentElement; + return typeof el === 'string' || typeof el === 'number'; }; ReactTestInstance.prototype.getInstance = function() { - return this._component._renderedComponent.getPublicInstance(); + return this._getInternalInstance().getPublicInstance(); +}; +ReactTestInstance.prototype.getType = function() { + return this._getInternalInstance()._currentElement.type || null; +}; +ReactTestInstance.prototype.getProps = function() { + return this._getInternalInstance()._currentElement.props || null; +}; +ReactTestInstance.prototype.getChildren = function() { + var instance = this._getInternalInstance(); + var el = instance._currentElement; + if (React.isValidElement(el)) { + var children; + if (typeof el.type === 'function') { + children = [instance._renderedComponent]; + } else { + children = Object.keys(this._renderedChildren) + .map((childKey) => this._renderedChildren[childKey]); + } + return children + .filter((child) => + child._currentElement !== null && child._currentElement !== false + ) + .map((child) => new ReactTestInstance(false, child)); + } else if (typeof el === 'string' || typeof el === 'number') { + return []; + } else { + invariant(false, 'Unrecognized React node %s', el); + } }; ReactTestInstance.prototype.update = function(nextElement) { + invariant( + this._isTopLevel, + 'ReactTestRenderer: .update() can only be called at the top level.' + ); invariant( this._component, "ReactTestRenderer: .update() can't be called after unmount." ); + var prevElement = this._component._currentElement.props.child; + // TODO: Change to invariant in React 16 + if (!shouldUpdateReactComponent(prevElement, nextElement)) { + warning( + false, + 'ReactTestRenderer: Component type and key must be preserved when ' + + 'updating. If necessary, call ReactTestRenderer.create again instead.' + ); + this._typeChanged = true; + } var nextWrappedElement = React.createElement( TopLevelWrapper, { child: nextElement } @@ -107,6 +174,10 @@ ReactTestInstance.prototype.update = function(nextElement) { }); }; ReactTestInstance.prototype.unmount = function(nextElement) { + invariant( + this._isTopLevel, + 'ReactTestRenderer: .unmount() can only be called at the top level.' + ); var component = this._component; ReactUpdates.batchedUpdates(function() { var transaction = ReactUpdates.ReactReconcileTransaction.getPooled(true); @@ -149,7 +220,7 @@ var ReactTestMount = { batchedMountComponentIntoNode, instance ); - return new ReactTestInstance(instance); + return new ReactTestInstance(true, instance); }, }; diff --git a/src/renderers/testing/ReactTestRenderer.js b/src/renderers/testing/ReactTestRenderer.js index 50716e55f1bb4..1dea6b9e9b5ff 100644 --- a/src/renderers/testing/ReactTestRenderer.js +++ b/src/renderers/testing/ReactTestRenderer.js @@ -39,6 +39,7 @@ function getRenderedHostOrTextFromComponent(component) { // ============================================================================= var ReactTestComponent = function(element) { + this._unmounted = false; this._currentElement = element; this._renderedChildren = null; this._topLevelWrapper = null; @@ -66,7 +67,10 @@ ReactTestComponent.prototype.getPublicInstance = function() { // Maybe we'll revise later if someone has a good use case. return null; }; -ReactTestComponent.prototype.unmountComponent = function() {}; +ReactTestComponent.prototype.unmountComponent = function() { + this._unmounted = true; + this.unmountChildren(/* safely */ false); +}; ReactTestComponent.prototype.toJSON = function() { var {children, ...props} = this._currentElement.props; var childrenJSON = []; @@ -93,6 +97,7 @@ Object.assign(ReactTestComponent.prototype, ReactMultiChild.Mixin); // ============================================================================= var ReactTestTextComponent = function(element) { + this._unmounted = false; this._currentElement = element; }; ReactTestTextComponent.prototype.mountComponent = function() {}; @@ -100,7 +105,9 @@ ReactTestTextComponent.prototype.receiveComponent = function(nextElement) { this._currentElement = nextElement; }; ReactTestTextComponent.prototype.getHostNode = function() {}; -ReactTestTextComponent.prototype.unmountComponent = function() {}; +ReactTestTextComponent.prototype.unmountComponent = function() { + this._unmounted = true; +}; ReactTestTextComponent.prototype.toJSON = function() { return this._currentElement; }; @@ -108,12 +115,15 @@ ReactTestTextComponent.prototype.toJSON = function() { // ============================================================================= var ReactTestEmptyComponent = function(element) { + this._unmounted = false; this._currentElement = null; }; ReactTestEmptyComponent.prototype.mountComponent = function() {}; ReactTestEmptyComponent.prototype.receiveComponent = function() {}; ReactTestEmptyComponent.prototype.getHostNode = function() {}; -ReactTestEmptyComponent.prototype.unmountComponent = function() {}; +ReactTestEmptyComponent.prototype.unmountComponent = function() { + this._unmounted = true; +}; ReactTestEmptyComponent.prototype.toJSON = function() {}; // ============================================================================= diff --git a/src/renderers/testing/__tests__/ReactTestRenderer-test.js b/src/renderers/testing/__tests__/ReactTestRenderer-test.js index a20d442686d3b..e09f7806ac05d 100644 --- a/src/renderers/testing/__tests__/ReactTestRenderer-test.js +++ b/src/renderers/testing/__tests__/ReactTestRenderer-test.js @@ -111,7 +111,9 @@ describe('ReactTestRenderer', function() { }); }); - it('updates types', function() { + it('updates types with a warning', function() { + spyOn(console, 'error'); + var renderer = ReactTestRenderer.create(
mouse
); expect(renderer.toJSON()).toEqual({ type: 'div', @@ -125,6 +127,18 @@ describe('ReactTestRenderer', function() { props: {}, children: ['mice'], }); + + expect(console.error.calls.count()).toBe(1); + expect(console.error.calls.argsFor(0)[0]).toEqual( + 'Warning: ReactTestRenderer: Component type and key must be preserved ' + + 'when updating. If necessary, call ReactTestRenderer.create again ' + + 'instead.' + ); + + expect(() => renderer.getType()).toThrow(new Error( + 'ReactTestRenderer: Can\'t inspect or traverse after changing ' + + 'component type or key. Fix the earlier warning and try again.' + )); }); it('updates children', function() { @@ -178,16 +192,20 @@ describe('ReactTestRenderer', function() { } } - var renderer = ReactTestRenderer.create(); - renderer.update(); + var renderer = ReactTestRenderer.create( +
+ ); + renderer.update(
); renderer.unmount(); expect(log).toEqual([ 'render Foo', 'mount Foo', - 'unmount Foo', + 'render Bar', + 'unmount Foo', 'mount Bar', + 'unmount Bar', ]); });