From b91396be8eac3dc57ab1dbd2d3bb27c791d88169 Mon Sep 17 00:00:00 2001 From: Marshall Roch Date: Mon, 18 Nov 2013 10:41:42 -0800 Subject: [PATCH] Contexts Summary: adds `this.context` which you can think of as implicit props, which are passed automatically down the //ownership// hierarchy. Contexts should be used sparingly, since they essentially allow components to communicate with descendants (in the ownership sense, not parenthood sense), which is not usually a good idea. You probably would only use contexts in places where you'd normally use a global, but contexts allow you to override them for certain view subtrees which you can't do with globals. The context starts out `null`: var RootComponent = React.createClass({ render: function() { // this.context === null } }); You should **never** mutate the context directly, just like props and state. You can change the context of your children (the ones you own, not `this.props.children` or via other props) using the new `withContext` method on `React`: var RootComponent = React.createClass({ render: function() { // this.context === null var children = React.withContext({foo: 'a', bar: 'b'}, () => ( // In ChildComponent#render, this.context === {foo: 'a', bar: 'b'} )); // this.context === null } }); Contexts are merged, so a component can override its owner's context **for its children**: var ChildComponent = React.createClass({ render: function() { // this.context === {foo: 'a', bar: 'b'} (for the caller above) var children = React.withContext({foo: 'c'},() => ( // In GrandchildComponent#render, // this.context === {foo: 'c', bar: 'b'} )); // this.context === {foo: 'a', bar: 'b'} } }); --- .../transitions/ReactTransitionableChild.js | 2 +- src/core/React.js | 2 + src/core/ReactCompositeComponent.js | 174 ++++++++++++++++-- src/core/ReactContext.js | 70 +++++++ src/core/ReactPropTypeLocations.js | 29 +++ src/core/ReactPropTypes.js | 36 +++- .../__tests__/ReactCompositeComponent-test.js | 143 ++++++++++++++ src/core/__tests__/ReactPropTypes-test.js | 9 +- src/dom/components/ReactDOMInput.js | 2 +- src/dom/components/ReactDOMTextarea.js | 2 +- src/test/reactComponentExpect.js | 16 ++ 11 files changed, 455 insertions(+), 30 deletions(-) create mode 100644 src/core/ReactContext.js create mode 100644 src/core/ReactPropTypeLocations.js diff --git a/src/addons/transitions/ReactTransitionableChild.js b/src/addons/transitions/ReactTransitionableChild.js index c15d4b263a..55a7191d32 100644 --- a/src/addons/transitions/ReactTransitionableChild.js +++ b/src/addons/transitions/ReactTransitionableChild.js @@ -138,7 +138,7 @@ var ReactTransitionableChild = React.createClass({ } }, - componentDidUpdate: function(prevProps, prevState, node) { + componentDidUpdate: function(prevProps, prevState, prevContext, node) { if (prevProps.children && !this.props.children) { this.transition('leave', true, this.props.onDoneLeaving); } diff --git a/src/core/React.js b/src/core/React.js index b664ef410b..40b3321535 100644 --- a/src/core/React.js +++ b/src/core/React.js @@ -20,6 +20,7 @@ var ReactComponent = require('ReactComponent'); var ReactCompositeComponent = require('ReactCompositeComponent'); +var ReactContext = require('ReactContext'); var ReactCurrentOwner = require('ReactCurrentOwner'); var ReactDOM = require('ReactDOM'); var ReactDOMComponent = require('ReactDOMComponent'); @@ -53,6 +54,7 @@ var React = { unmountAndReleaseReactRootNode: ReactMount.unmountAndReleaseReactRootNode, isValidClass: ReactCompositeComponent.isValidClass, isValidComponent: ReactComponent.isValidComponent, + withContext: ReactContext.withContext, __internals: { Component: ReactComponent, CurrentOwner: ReactCurrentOwner, diff --git a/src/core/ReactCompositeComponent.js b/src/core/ReactCompositeComponent.js index 2168128c77..6c61396d47 100644 --- a/src/core/ReactCompositeComponent.js +++ b/src/core/ReactCompositeComponent.js @@ -19,11 +19,13 @@ "use strict"; var ReactComponent = require('ReactComponent'); +var ReactContext = require('ReactContext'); var ReactCurrentOwner = require('ReactCurrentOwner'); var ReactErrorUtils = require('ReactErrorUtils'); var ReactOwner = require('ReactOwner'); var ReactPerf = require('ReactPerf'); var ReactPropTransferer = require('ReactPropTransferer'); +var ReactPropTypeLocations = require('ReactPropTypeLocations'); var ReactUpdates = require('ReactUpdates'); var invariant = require('invariant'); @@ -98,6 +100,21 @@ var ReactCompositeComponentInterface = { */ propTypes: SpecPolicy.DEFINE_MANY_MERGED, + /** + * Definition of context types for this component. + * + * @type {object} + * @optional + */ + contextTypes: SpecPolicy.DEFINE_MANY_MERGED, + + /** + * Definition of context types this component sets for its children. + * + * @type {object} + * @optional + */ + childContextTypes: SpecPolicy.DEFINE_MANY_MERGED, // ==== Definition methods ==== @@ -130,6 +147,12 @@ var ReactCompositeComponentInterface = { */ getInitialState: SpecPolicy.DEFINE_MANY_MERGED, + /** + * @return {object} + * @optional + */ + getChildContext: SpecPolicy.DEFINE_MANY_MERGED, + /** * Uses props from `this.props` and state from `this.state` to render the * structure of the component. @@ -214,7 +237,8 @@ var ReactCompositeComponentInterface = { /** * Invoked when the component is about to update due to a transition from - * `this.props` and `this.state` to `nextProps` and `nextState`. + * `this.props`, `this.state` and `this.context` to `nextProps`, `nextState` + * and `nextContext`. * * Use this as an opportunity to perform preparation before an update occurs. * @@ -222,6 +246,7 @@ var ReactCompositeComponentInterface = { * * @param {object} nextProps * @param {?object} nextState + * @param {?object} nextContext * @param {ReactReconcileTransaction} transaction * @optional */ @@ -235,6 +260,7 @@ var ReactCompositeComponentInterface = { * * @param {object} prevProps * @param {?object} prevState + * @param {?object} prevContext * @param {DOMElement} rootNode DOM element representing the component. * @optional */ @@ -288,6 +314,12 @@ var RESERVED_SPEC_KEYS = { } } }, + childContextTypes: function(Constructor, childContextTypes) { + Constructor.childContextTypes = childContextTypes; + }, + contextTypes: function(Constructor, contextTypes) { + Constructor.contextTypes = contextTypes; + }, propTypes: function(Constructor, propTypes) { Constructor.propTypes = propTypes; } @@ -512,8 +544,14 @@ var ReactCompositeComponentMixin = { construct: function(initialProps, children) { // Children can be either an array or more than one argument ReactComponent.Mixin.construct.apply(this, arguments); + this.state = null; this._pendingState = null; + + this.context = this._processContext(ReactContext.current); + this._currentContext = ReactContext.current; + this._pendingContext = null; + this._compositeLifeCycleState = null; }, @@ -659,6 +697,64 @@ var ReactCompositeComponentMixin = { ReactUpdates.enqueueUpdate(this, callback); }, + /** + * Filters the context object to only contain keys specified in + * `contextTypes`, and asserts that they are valid. + * + * @param {object} context + * @return {?object} + * @private + */ + _processContext: function(context) { + var maskedContext = null; + var contextTypes = this.constructor.contextTypes; + if (contextTypes) { + maskedContext = {}; + for (var contextName in contextTypes) { + maskedContext[contextName] = context[contextName]; + } + this._checkPropTypes( + contextTypes, + maskedContext, + ReactPropTypeLocations.context + ); + } + return maskedContext; + }, + + /** + * @param {object} currentContext + * @return {object} + * @private + */ + _processChildContext: function(currentContext) { + var childContext = this.getChildContext && this.getChildContext(); + var displayName = this.constructor.displayName || 'ReactCompositeComponent'; + if (childContext) { + invariant( + typeof this.constructor.childContextTypes === 'object', + '%s.getChildContext(): childContextTypes must be defined in order to ' + + 'use getChildContext().', + displayName + ); + this._checkPropTypes( + this.constructor.childContextTypes, + childContext, + ReactPropTypeLocations.childContext + ); + for (var name in childContext) { + invariant( + name in this.constructor.childContextTypes, + '%s.getChildContext(): key "%s" is not defined in childContextTypes.', + displayName, + name + ); + } + return merge(currentContext, childContext); + } + return currentContext; + }, + /** * Processes props by setting default values for unspecified props and * asserting that the props are valid. @@ -667,21 +763,32 @@ var ReactCompositeComponentMixin = { * @private */ _processProps: function(props) { - var propName; var defaultProps = this._defaultProps; - for (propName in defaultProps) { + for (var propName in defaultProps) { if (typeof props[propName] === 'undefined') { props[propName] = defaultProps[propName]; } } var propTypes = this.constructor.propTypes; if (propTypes) { - var componentName = this.constructor.displayName; - for (propName in propTypes) { - var checkProp = propTypes[propName]; - if (checkProp) { - checkProp(props, propName, componentName); - } + this._checkPropTypes(propTypes, props, ReactPropTypeLocations.prop); + } + }, + + /** + * Assert that the props are valid + * + * @param {object} propTypes Map of prop name to a ReactPropType + * @param {object} props + * @param {string} location e.g. "prop", "context", "child context" + * @private + */ + _checkPropTypes: function(propTypes, props, location) { + var componentName = this.constructor.displayName; + for (var propName in propTypes) { + var checkProp = propTypes[propName]; + if (checkProp) { + checkProp(props, propName, componentName, location); } } }, @@ -707,6 +814,7 @@ var ReactCompositeComponentMixin = { _performUpdateIfNecessary: function(transaction) { if (this._pendingProps == null && this._pendingState == null && + this._pendingContext == null && !this._pendingForceUpdate) { return; } @@ -728,17 +836,26 @@ var ReactCompositeComponentMixin = { var nextState = this._pendingState || this.state; this._pendingState = null; + var nextContext = this._pendingContext || this._currentContext; + this._pendingContext = null; + if (this._pendingForceUpdate || !this.shouldComponentUpdate || - this.shouldComponentUpdate(nextProps, nextState)) { + this.shouldComponentUpdate(nextProps, nextState, nextContext)) { this._pendingForceUpdate = false; - // Will set `this.props` and `this.state`. - this._performComponentUpdate(nextProps, nextState, transaction); + // Will set `this.props`, `this.state` and `this.context`. + this._performComponentUpdate( + nextProps, + nextState, + nextContext, + transaction + ); } else { // If it's determined that a component should not update, we still want // to set props and state. this.props = nextProps; this.state = nextState; + this.context = nextContext; } this._compositeLifeCycleState = null; @@ -750,30 +867,49 @@ var ReactCompositeComponentMixin = { * * @param {object} nextProps Next object to set as properties. * @param {?object} nextState Next object to set as state. + * @param {?object} nextContext Next object to set as context. * @param {ReactReconcileTransaction} transaction * @private */ - _performComponentUpdate: function(nextProps, nextState, transaction) { + _performComponentUpdate: function( + nextProps, + nextState, + nextContext, + transaction + ) { var prevProps = this.props; var prevState = this.state; + var prevContext = this.context; if (this.componentWillUpdate) { - this.componentWillUpdate(nextProps, nextState); + this.componentWillUpdate(nextProps, nextState, nextContext); } this.props = nextProps; this.state = nextState; - this.updateComponent(transaction, prevProps, prevState); + this._currentContext = nextContext; + this.context = this._processContext(nextContext); + + this.updateComponent(transaction, prevProps, prevState, prevContext); if (this.componentDidUpdate) { transaction.getReactMountReady().enqueue( this, - this.componentDidUpdate.bind(this, prevProps, prevState) + this.componentDidUpdate.bind(this, prevProps, prevState, prevContext) ); } }, + receiveComponent: function(nextComponent, transaction) { + this._pendingContext = nextComponent._currentContext; + ReactComponent.Mixin.receiveComponent.call( + this, + nextComponent, + transaction + ); + }, + /** * Updates the component's currently mounted DOM representation. * @@ -783,13 +919,14 @@ var ReactCompositeComponentMixin = { * @param {ReactReconcileTransaction} transaction * @param {object} prevProps * @param {?object} prevState + * @param {?object} prevContext * @internal * @overridable */ updateComponent: ReactPerf.measure( 'ReactCompositeComponent', 'updateComponent', - function(transaction, prevProps, prevState) { + function(transaction, prevProps, prevState, prevContext) { ReactComponent.Mixin.updateComponent.call(this, transaction, prevProps); var prevComponent = this._renderedComponent; var nextComponent = this._renderValidatedComponent(); @@ -851,6 +988,8 @@ var ReactCompositeComponentMixin = { */ _renderValidatedComponent: function() { var renderedComponent; + var previousContext = ReactContext.current; + ReactContext.current = this._processChildContext(this._currentContext); ReactCurrentOwner.current = this; try { renderedComponent = this.render(); @@ -858,6 +997,7 @@ var ReactCompositeComponentMixin = { // IE8 requires `catch` in order to use `finally`. throw error; } finally { + ReactContext.current = previousContext; ReactCurrentOwner.current = null; } invariant( diff --git a/src/core/ReactContext.js b/src/core/ReactContext.js new file mode 100644 index 0000000000..44ff1a23c6 --- /dev/null +++ b/src/core/ReactContext.js @@ -0,0 +1,70 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule ReactContext + */ + +"use strict"; + +var merge = require('merge'); + +/** + * Keeps track of the current context. + * + * The context is automatically passed down the component ownership hierarchy + * and is accessible via `this.context` on ReactCompositeComponents. + */ +var ReactContext = { + + /** + * @internal + * @type {object} + */ + current: {}, + + /** + * Temporarily extends the current context while executing scopedCallback. + * + * A typical use case might look like + * + * render: function() { + * var children = ReactContext.withContext({foo: 'foo'} () => ( + * + * )); + * return
{children}
; + * } + * + * @param {object} newContext New context to merge into the existing context + * @param {function} scopedCallback Callback to run with the new context + * @return {ReactComponent|array} + */ + withContext: function(newContext, scopedCallback) { + var result; + var previousContext = ReactContext.current; + ReactContext.current = merge(previousContext, newContext); + try { + result = scopedCallback(); + } catch (error) { + // IE8 requires `catch` in order to use `finally`. + throw error; + } finally { + ReactContext.current = previousContext; + } + return result; + } + +}; + +module.exports = ReactContext; diff --git a/src/core/ReactPropTypeLocations.js b/src/core/ReactPropTypeLocations.js new file mode 100644 index 0000000000..d0868d9993 --- /dev/null +++ b/src/core/ReactPropTypeLocations.js @@ -0,0 +1,29 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule ReactPropTypeLocations + */ + +"use strict"; + +var keyMirror = require('keyMirror'); + +var ReactPropTypeLocations = keyMirror({ + props: null, + context: null, + childContext: null +}); + +module.exports = ReactPropTypeLocations; diff --git a/src/core/ReactPropTypes.js b/src/core/ReactPropTypes.js index 4720892365..ebe261687f 100644 --- a/src/core/ReactPropTypes.js +++ b/src/core/ReactPropTypes.js @@ -18,6 +18,8 @@ "use strict"; +var ReactPropTypeLocations = require('ReactPropTypeLocations'); + var createObjectFrom = require('createObjectFrom'); var invariant = require('invariant'); @@ -87,14 +89,18 @@ var Props = { var ANONYMOUS = '<>'; function createPrimitiveTypeChecker(expectedType) { - function validatePrimitiveType(propValue, propName, componentName) { + function validatePrimitiveType(propValue, propName, componentName, location) { var propType = typeof propValue; if (propType === 'object' && Array.isArray(propValue)) { propType = 'array'; } invariant( propType === expectedType, - 'Invalid prop `%s` of type `%s` supplied to `%s`, expected `%s`.', + 'Invalid %s `%s` of type `%s` supplied to `%s`, expected `%s`.', + (location === ReactPropTypeLocations.prop ? 'prop' : + (location === ReactPropTypeLocations.context ? 'context' : + (location === ReactPropTypeLocations.childContext ? 'child context' : + ''))), propName, propType, componentName, @@ -106,10 +112,14 @@ function createPrimitiveTypeChecker(expectedType) { function createEnumTypeChecker(expectedValues) { var expectedEnum = createObjectFrom(expectedValues); - function validateEnumType(propValue, propName, componentName) { + function validateEnumType(propValue, propName, componentName, location) { invariant( expectedEnum[propValue], - 'Invalid prop `%s` supplied to `%s`, expected one of %s.', + 'Invalid %s `%s` supplied to `%s`, expected one of %s.', + (location === ReactPropTypeLocations.prop ? 'prop' : + (location === ReactPropTypeLocations.context ? 'context' : + (location === ReactPropTypeLocations.childContext ? 'child context' : + ''))), propName, componentName, JSON.stringify(Object.keys(expectedEnum)) @@ -119,10 +129,14 @@ function createEnumTypeChecker(expectedValues) { } function createInstanceTypeChecker(expectedClass) { - function validateInstanceType(propValue, propName, componentName) { + function validateInstanceType(propValue, propName, componentName, location) { invariant( propValue instanceof expectedClass, - 'Invalid prop `%s` supplied to `%s`, expected instance of `%s`.', + 'Invalid %s `%s` supplied to `%s`, expected instance of `%s`.', + (location === ReactPropTypeLocations.prop ? 'prop' : + (location === ReactPropTypeLocations.context ? 'context' : + (location === ReactPropTypeLocations.childContext ? 'child context' : + ''))), propName, componentName, expectedClass.name || ANONYMOUS @@ -133,15 +147,19 @@ function createInstanceTypeChecker(expectedClass) { function createChainableTypeChecker(validate) { function createTypeChecker(isRequired) { - function checkType(props, propName, componentName) { + function checkType(props, propName, componentName, location) { var propValue = props[propName]; if (propValue != null) { // Only validate if there is a value to check. - validate(propValue, propName, componentName || ANONYMOUS); + validate(propValue, propName, componentName || ANONYMOUS, location); } else { invariant( !isRequired, - 'Required prop `%s` was not specified in `%s`.', + 'Required %s `%s` was not specified in `%s`.', + (location === ReactPropTypeLocations.prop ? + 'prop' : (location === ReactPropTypeLocations.context ? + 'context' : (location === ReactPropTypeLocations.childContext ? + 'child context' : ''))), propName, componentName || ANONYMOUS ); diff --git a/src/core/__tests__/ReactCompositeComponent-test.js b/src/core/__tests__/ReactCompositeComponent-test.js index af3f2eb049..3afd659d8d 100644 --- a/src/core/__tests__/ReactCompositeComponent-test.js +++ b/src/core/__tests__/ReactCompositeComponent-test.js @@ -476,4 +476,147 @@ describe('ReactCompositeComponent', function() { console.warn = warn; } }); + + it('should pass context', function() { + var childInstance = null; + var grandchildInstance = null; + + var Parent = React.createClass({ + childContextTypes: { + foo: ReactPropTypes.string, + depth: ReactPropTypes.number + }, + + getChildContext: function() { + return { + foo: 'bar', + depth: 0 + }; + }, + + render: function() { + childInstance = ; + return childInstance; + } + }); + + var Child = React.createClass({ + contextTypes: { + foo: ReactPropTypes.string, + depth: ReactPropTypes.number + }, + + childContextTypes: { + depth: ReactPropTypes.number + }, + + getChildContext: function() { + return { + depth: this.context.depth + 1 + }; + }, + + render: function() { + grandchildInstance = ; + return grandchildInstance; + } + }); + + var Grandchild = React.createClass({ + contextTypes: { + foo: ReactPropTypes.string, + depth: ReactPropTypes.number + }, + + render: function() { + return
; + } + }); + + var instance = ; + ReactTestUtils.renderIntoDocument(instance); + reactComponentExpect(childInstance).scalarContextEqual({foo: 'bar', depth: 0}); + reactComponentExpect(grandchildInstance).scalarContextEqual({foo: 'bar', depth: 1}); + }); + + it('should check context types', function() { + var Component = React.createClass({ + contextTypes: { + foo: ReactPropTypes.string.isRequired + }, + + render: function() { + return
; + } + }); + + expect(function() { + ReactTestUtils.renderIntoDocument(); + }).toThrow( + 'Invariant Violation: Required context `foo` was not specified in ' + + '`Component`.' + ); + + expect(function() { + React.withContext({foo: 'bar'}, function() { + ReactTestUtils.renderIntoDocument(); + }); + }).not.toThrow(); + + expect(function() { + React.withContext({foo: 123}, function() { + ReactTestUtils.renderIntoDocument(); + }); + }).toThrow( + 'Invariant Violation: Invalid context `foo` of type `number` supplied ' + + 'to `Component`, expected `string`.' + ); + }); + + it('should check child context types', function() { + var Component = React.createClass({ + childContextTypes: { + foo: ReactPropTypes.string.isRequired, + bar: ReactPropTypes.number + }, + + getChildContext: function() { + return this.props.testContext; + }, + + render: function() { + return
; + } + }); + + expect(function() { + ReactTestUtils.renderIntoDocument( + + ); + }).toThrow( + 'Invariant Violation: Required child context `foo` was not specified ' + + 'in `Component`.' + ); + + expect(function() { + ReactTestUtils.renderIntoDocument( + + ); + }).toThrow( + 'Invariant Violation: Invalid child context `foo` of type `number` ' + + 'supplied to `Component`, expected `string`.' + ); + + expect(function() { + ReactTestUtils.renderIntoDocument( + + ); + }).not.toThrow(); + + expect(function() { + ReactTestUtils.renderIntoDocument( + + ); + }).not.toThrow(); + }); }); diff --git a/src/core/__tests__/ReactPropTypes-test.js b/src/core/__tests__/ReactPropTypes-test.js index 262711a352..cdea401015 100644 --- a/src/core/__tests__/ReactPropTypes-test.js +++ b/src/core/__tests__/ReactPropTypes-test.js @@ -20,13 +20,20 @@ "use strict"; var Props = require('ReactPropTypes'); +var ReactPropTypeLocations = require('ReactPropTypeLocations'); function typeCheck(declaration, value) { var props = {}; if (arguments.length > 1) { props.testProp = value; } - return declaration.bind(null, props, 'testProp', 'testComponent'); + return declaration.bind( + null, + props, + 'testProp', + 'testComponent', + ReactPropTypeLocations.prop + ); } describe('Primitive Types', function() { diff --git a/src/dom/components/ReactDOMInput.js b/src/dom/components/ReactDOMInput.js index 12521b833c..2bb7adb5eb 100644 --- a/src/dom/components/ReactDOMInput.js +++ b/src/dom/components/ReactDOMInput.js @@ -92,7 +92,7 @@ var ReactDOMInput = ReactCompositeComponent.createClass({ delete instancesByReactID[id]; }, - componentDidUpdate: function(prevProps, prevState, rootNode) { + componentDidUpdate: function(prevProps, prevState, prevContext, rootNode) { if (this.props.checked != null) { DOMPropertyOperations.setValueForProperty( rootNode, diff --git a/src/dom/components/ReactDOMTextarea.js b/src/dom/components/ReactDOMTextarea.js index 9da6fac114..1c6b0167ed 100644 --- a/src/dom/components/ReactDOMTextarea.js +++ b/src/dom/components/ReactDOMTextarea.js @@ -110,7 +110,7 @@ var ReactDOMTextarea = ReactCompositeComponent.createClass({ return textarea(props, this.state.initialValue); }, - componentDidUpdate: function(prevProps, prevState, rootNode) { + componentDidUpdate: function(prevProps, prevState, prevContext, rootNode) { var value = this.getValue(); if (value != null) { // Cast `value` to a string to ensure the value is set correctly. While diff --git a/src/test/reactComponentExpect.js b/src/test/reactComponentExpect.js index dc664075bc..df9f2ea804 100644 --- a/src/test/reactComponentExpect.js +++ b/src/test/reactComponentExpect.js @@ -196,6 +196,22 @@ mergeInto(reactComponentExpect.prototype, { .toEqual(propNameToExpectedValue[propName]); } return this; + }, + + /** + * Check a set of props are equal to a set of expected values - only works + * with scalars. + */ + scalarContextEqual: function(contextNameToExpectedValue) { + expect(this.instance()).toBeTruthy(); + for (var contextName in contextNameToExpectedValue) { + if (!contextNameToExpectedValue.hasOwnProperty(contextName)) { + continue; + } + expect(this.instance().context[contextName]) + .toEqual(contextNameToExpectedValue[contextName]); + } + return this; } });