├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── EventEmitter.js ├── NativeSafeComponent.js ├── NativeSafeModule.js ├── SafeComponent.android.js ├── SafeComponent.ios.js ├── SafeComponent.js ├── SafeComponent.windows.js ├── SafeModule.android.js ├── SafeModule.ios.js ├── SafeModule.js ├── SafeModule.windows.js └── index.js ├── test ├── .eslintrc ├── SafeModule-test.js └── _setup.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-native"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "node": true 5 | }, 6 | "ecmaFeatures": { 7 | "arrowFunctions": true, 8 | "blockBindings": true, 9 | "classes": true, 10 | "defaultParams": true, 11 | "destructuring": true, 12 | "forOf": true, 13 | "generators": false, 14 | "modules": true, 15 | "experimentalObjectRestSpread": true, 16 | "objectLiteralComputedProperties": true, 17 | "objectLiteralDuplicateProperties": false, 18 | "objectLiteralShorthandMethods": true, 19 | "objectLiteralShorthandProperties": true, 20 | "restParams": true, 21 | "spread": true, 22 | "superInFunctions": true, 23 | "templateStrings": true, 24 | "jsx": true 25 | }, 26 | "rules": { 27 | // Allow console methods, which get removed during build process 28 | "no-console": 0, 29 | "no-use-before-define": 0, 30 | "arrow-body-style": 0, 31 | "new-cap": 0, 32 | // triple equals is required except for when comparing with null 33 | "eqeqeq": [1, "allow-null"] 34 | }, 35 | "globals": { 36 | "__DEV__": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | lib/ 40 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .ides 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "6" 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Leland Richardson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-safe-modules 2 | 3 | A safe way to consume React Native NativeModules 4 | 5 | 6 | [![npm Version](https://img.shields.io/npm/v/react-native-safe-modules.svg)](https://www.npmjs.com/package/react-native-safe-modules) [![License](https://img.shields.io/npm/l/react-native-safe-modules.svg)](https://www.npmjs.com/package/react-native-safe-modules) 7 | 8 | 9 | ## Motivation 10 | 11 | React Native enables a new aspect of mobile development: "Code Push". 12 | Code Push provides developers a way to push updates to their JS code base 13 | to mobile clients without going through the app store. 14 | 15 | Since you can code push to older versions of the native client app, this 16 | type of deployment creates a new point of failure though: JavaScript 17 | code that is incompatible with the native version of the app it's running 18 | on. 19 | 20 | React Native JS interacts with the Native code entirely through "Native Modules", 21 | which are injected at run-time onto the `ReactNative.NativeModules` 22 | namespace. As a result, having code that interacts with these modules 23 | directly can result in run-time errors. This library allows you to more 24 | safely interact with native modules, and provide version-specific overrides 25 | for the module, as well as mocks to use in the case that the method or module 26 | is entirely absent. The result is more robust code that can be code pushed 27 | to more users, as well as code that can be tested in an environment without 28 | a host app (e.g, Node). 29 | 30 | 31 | 32 | ## Installation 33 | 34 | ```bash 35 | npm i --save react-native-safe-modules 36 | ``` 37 | 38 | ## Usage 39 | 40 | Importing `SafeModule` is as simple as: 41 | 42 | ```js 43 | import SafeModule from 'react-native-safe-modules'; 44 | ``` 45 | 46 | ### Basic Usage 47 | 48 | If you were using a Native Module before, such as `NativeModules.FooModule` 49 | like this: 50 | 51 | ```js 52 | import { NativeModules } from 'react-native'; 53 | const { FooModule } = NativeModules; 54 | 55 | // ... 56 | 57 | FooModule.doSomething().then(...) 58 | 59 | ``` 60 | 61 | You can instead do: 62 | 63 | ```js 64 | import SafeModule from 'react-native-safe-modules'; 65 | const FooModule = SafeModule.create({ 66 | moduleName: 'FooModule', 67 | mock: { 68 | doSomething: () => Promise.resolve(...), 69 | }, 70 | }); 71 | 72 | // ... 73 | 74 | FooModule.doSomething().then(...) 75 | ``` 76 | 77 | 78 | ### Version-Specific Overrides 79 | 80 | By default, `SafeModule` assumes that you are exporting a constant `VERSION` 81 | with each Native Module that can be used to identify which version of the 82 | native module it is. If you would like to specify the version a different 83 | way, you are able to add a `getVersion` option to the SafeModule configuration 84 | which is a function expected to return the correct version of the module. 85 | 86 | Often times you may need to make a breaking change to the API of your Native Module, 87 | but it can be made backwards compatible with SafeModule very easily. 88 | 89 | For example, imagine we have a `Scrolling` module with a `scrollTo(...)` 90 | method. 91 | 92 | In version "7" of the module, the method signature of `scrollTo` 93 | looked something like `scrollTo(x: number, y: number, animated: true)`. 94 | 95 | In the latest version of the module, we have changed the method signature 96 | to look something like: `scrollTo(options: {x: number, y: number, animated: true})`. 97 | 98 | This is a breaking change, but we can make it backwards compatible with SafeModule: 99 | 100 | ```js 101 | // Scrolling.js 102 | import SafeModule from 'react-native-safe-modules'; 103 | 104 | module.exports = SafeModule.create({ 105 | moduleName: 'MyCustomScrollingModule', 106 | mock: { 107 | scrollTo: () => { /* do nothing */}, 108 | }, 109 | overrides: { 110 | 7: { 111 | // overrides are defined as higher-order functions which are first 112 | // called with the real module's method, and are expected to return 113 | // a new function with the current API. 114 | scrollTo: oldScrollTo => options => { 115 | return oldScrollTo(options.x, options.y, !!options.animated); 116 | }, 117 | }, 118 | }, 119 | }); 120 | ``` 121 | 122 | 123 | ### Module Name Changes 124 | 125 | Sometimes we want to change the name of a Native Module. In this case, 126 | we need to support both versions of the name. SafeModule allows you to 127 | specify `moduleName` as an array of names. It will use the first name 128 | it finds. 129 | 130 | For example, consider the case where we have a module named `FooExperimentalModule`, 131 | and we want to change the name of it to be just `FooModule`. 132 | 133 | ```js 134 | // FooModule.js 135 | import SafeModule from 'react-native-safe-modules'; 136 | 137 | module.exports = SafeModule.create({ 138 | moduleName: ['FooModule', 'FooExperimentalModule'], 139 | mock: { 140 | ... 141 | }, 142 | }); 143 | ``` 144 | 145 | In this case, `SafeModule` will look for `FooModule` first, and then 146 | `FooExperimentalModule` if it is not found. Finally, it will fall back 147 | to the `mock` implementation if none is found. 148 | 149 | 150 | 151 | 152 | ## API 153 | 154 | ### `SafeModule.create(options)` 155 | 156 | *Parameters:* 157 | 158 | - `options.moduleName`: (**required**, `string | Array`) the name, 159 | or array of names, to look for the module at on the `NativeModules` namespace. 160 | - `options.mock`: (**required**, `mixed`) The mock implementation of the native module. 161 | - `options.getVersion`: (`(module) => string|number`) Optional. A function that returns 162 | the version of the native module. Only needed if you are specifying overrides and not 163 | exporting a `VERSION` property on your native module. Defaults to `x => x.VERSION`. 164 | - `options.overrides`: (`{[version: string]: mixed`) Optional. A map of version numbers to 165 | overridden implementations of the corresponding property/method. If an overridden property 166 | or method is a function, it will be called during `SafeModule.create(...)` with two arguments, 167 | the original value of that property on the original module, and the original module itself. The 168 | return value of this function will be put on the return value of `SafeModule.create(...)`. 169 | - `options.isEventEmitter`: (`bool`) Optional. A flag indicating that the native module 170 | is expected to be an `EventEmitter`. Puts the `EventEmitter` instance on the `emitter` 171 | property of the resulting module. Defaults to `false`. 172 | 173 | 174 | 175 | 176 | ## TODO 177 | 178 | - [ ] Implement `onInit` lifecycle method 179 | - [ ] Implement `onNoModuleFound` lifecycle method 180 | - [ ] Implement `onVersionFound` lifecycle method 181 | - [ ] Implement `onOverrideUsed` lifecycle method 182 | - [ ] Implement `onOverrideCalled` lifecycle method 183 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-safe-modules", 3 | "version": "1.0.0", 4 | "description": "A safe way to consume React Native NativeModules (forked from react-native-safe-module by @lelandrichardson)", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "prepublish": "npm run --silent build", 8 | "build": "rm -rf lib && babel -d lib src", 9 | "lint": "eslint ./src ./test", 10 | "test": "npm run --silent lint && npm run --silent test:all", 11 | "test:all": "npm run --silent test:base -- --recursive test/", 12 | "test:watch": "npm run --silent test:base -- --recursive test/ --watch", 13 | "test:base": "mocha --require ./test/_setup.js --require react-native-mock/mock.js --compilers js:babel-core/register" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/emilioicai/react-native-safe-modules.git" 18 | }, 19 | "keywords": [ 20 | "react-native", 21 | "react", 22 | "native-modules", 23 | "code-push" 24 | ], 25 | "author": "Emilio Rodriguez ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/emilioicai/react-native-safe-modules/issues" 29 | }, 30 | "homepage": "https://github.com/emilioicai/react-native-safe-modules#readme", 31 | "peerDependencies": { 32 | "react-native": "*" 33 | }, 34 | "dependencies": { 35 | "dedent": "^0.6.0" 36 | }, 37 | "devDependencies": { 38 | "babel-cli": "^6.14.0", 39 | "babel-core": "^6.14.0", 40 | "babel-preset-react-native": "^1.9.0", 41 | "chai": "^3.5.0", 42 | "eslint": "^4.18.2", 43 | "eslint-config-airbnb": "^11.0.0", 44 | "eslint-plugin-import": "^1.14.0", 45 | "eslint-plugin-jsx-a11y": "^2.2.1", 46 | "eslint-plugin-react": "^6.2.0", 47 | "mocha": "^3.0.2", 48 | "react": "^15.2.0", 49 | "react-native": "^0.30.0", 50 | "react-native-mock": "^0.2.6", 51 | "sinon": "^1.17.5", 52 | "sinon-chai": "^2.8.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/EventEmitter.js: -------------------------------------------------------------------------------- 1 | // A basic, minimalist EventEmitter 2 | 3 | // Subscription { remove() {} } 4 | 5 | export default class EventEmitter { 6 | constructor() { 7 | this.registry = {}; 8 | } 9 | 10 | addListener(eventType, listener) { 11 | if (!this.registry[eventType]) { 12 | this.registry[eventType] = []; 13 | } 14 | this.registry[eventType].push(listener); 15 | return { remove: () => this.removeListener(eventType, listener) }; 16 | } 17 | 18 | once(eventType, listener, context) { 19 | const h = (...args) => { 20 | result.remove(); 21 | listener.apply(context, args); 22 | }; 23 | const result = this.addListener(eventType, h); 24 | return result; 25 | } 26 | 27 | removeAllListeners(eventType) { 28 | this.registry[eventType] = []; 29 | } 30 | 31 | // eslint-disable-next-line class-methods-use-this 32 | removeSubscription(subscription) { 33 | subscription.remove(); 34 | } 35 | 36 | listeners(eventType) { 37 | return this.registry[eventType]; 38 | } 39 | 40 | emit(eventType, ...args) { 41 | const events = this.registry[eventType]; 42 | if (!events) return; 43 | events.forEach(handler => handler(...args)); 44 | } 45 | 46 | removeListener(eventType, listener) { 47 | const events = this.registry[eventType]; 48 | if (!events) return; 49 | const index = events.indexOf(listener); 50 | if (index === -1) return; 51 | events.splice(index, 1); 52 | if (events.length === 0) { 53 | delete this.registry[eventType]; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/NativeSafeComponent.js: -------------------------------------------------------------------------------- 1 | import { 2 | requireNativeComponent, 3 | UIManager, 4 | findNodeHandle, 5 | Platform, 6 | } from 'react-native'; 7 | import dedent from 'dedent'; 8 | import SafeModule from './SafeModule'; 9 | 10 | const first = (array, fn) => { 11 | let result; 12 | let i = 0; 13 | /* eslint no-plusplus: 0 */ 14 | for (; i < array.length; i++) { 15 | result = fn(array[i]); 16 | if (result) return result; 17 | } 18 | return null; 19 | }; 20 | 21 | const getViewManagerConfig = (moduleName) => { 22 | if (UIManager.getViewManagerConfig) { 23 | // RN >= 0.58 24 | return UIManager.getViewManagerConfig(moduleName); 25 | } 26 | 27 | // RN < 0.58 28 | return UIManager[moduleName]; 29 | }; 30 | 31 | const moduleWithName = (nameOrArray) => { 32 | if (!nameOrArray) return null; 33 | if (Array.isArray(nameOrArray)) return first(nameOrArray, moduleWithName); 34 | return getViewManagerConfig(nameOrArray); 35 | }; 36 | 37 | const findFirstResolver = namespace => function findFirstOnNamespace(nameOrArray) { 38 | if (!nameOrArray) return null; 39 | if (Array.isArray(nameOrArray)) return first(nameOrArray, findFirstOnNamespace); 40 | return nameOrArray in namespace ? nameOrArray : null; 41 | }; 42 | 43 | const findFirstViewName = findFirstResolver(UIManager); 44 | 45 | const getPrimaryName = (nameOrArray) => { 46 | return Array.isArray(nameOrArray) ? getPrimaryName(nameOrArray[0]) : nameOrArray; 47 | }; 48 | 49 | const defaultGetVersion = module => module.VERSION; 50 | 51 | function SafeComponentCreate(options) { 52 | if (!options) { 53 | throw new Error(dedent` 54 | SafeModule.create(...) was invoked without any options parameter. 55 | `); 56 | } 57 | const { 58 | viewName, 59 | propOverrides, 60 | componentOverrides, 61 | mockComponent, 62 | mock, 63 | } = options; 64 | let { 65 | getVersion, 66 | } = options; 67 | 68 | if (!getVersion) { 69 | getVersion = defaultGetVersion; 70 | } 71 | 72 | if (!viewName) { 73 | throw new Error(` 74 | SafeModule.component(...) requires a viewName property to be specified. 75 | `); 76 | } 77 | 78 | if (!mockComponent) { 79 | throw new Error(` 80 | SafeModule.component(...) requires a mockComponent property to be specified. 81 | `); 82 | } 83 | 84 | const PRIMARY_VIEW_NAME = getPrimaryName(viewName); 85 | 86 | const realViewName = findFirstViewName(viewName); 87 | const realViewConfig = getViewManagerConfig(realViewName); 88 | 89 | if (!realViewName || !realViewConfig) { 90 | return mockComponent; 91 | } 92 | 93 | const moduleOptions = Object.assign({}, options, { 94 | mock: mock || {}, 95 | moduleName: `${realViewName}Manager`, 96 | }); 97 | 98 | const nativeModule = SafeModule(moduleOptions); 99 | 100 | const version = getVersion(realViewConfig.Constants || {}); 101 | 102 | if (propOverrides) { 103 | const overrides = propOverrides[version]; 104 | let boundOverrides = {}; 105 | if (overrides) { 106 | if (typeof overrides === 'function') { 107 | boundOverrides = overrides(realViewConfig.NativeProps, realViewConfig, nativeModule); 108 | } else { 109 | boundOverrides = Object.assign({}, overrides); 110 | } 111 | } 112 | Object.assign(realViewConfig.NativeProps, boundOverrides); 113 | } 114 | 115 | const nativeComponent = requireNativeComponent(realViewName); 116 | 117 | let result = nativeComponent; 118 | 119 | result.runCommand = (instance, name, ...args) => { 120 | const native = () => UIManager.dispatchViewManagerCommand( 121 | findNodeHandle(instance), 122 | getViewManagerConfig(realViewName).Commands[name], 123 | args 124 | ); 125 | return Platform.select({ 126 | android: native, 127 | ios: () => nativeModule[name](findNodeHandle(instance), ...args), 128 | windows: native, 129 | default: () => {}, 130 | })(); 131 | }; 132 | 133 | result.updateView = (instance, props) => { 134 | const native = () => UIManager.updateView(findNodeHandle(instance), realViewName, props); 135 | Platform.select({ 136 | ios: native, 137 | android: native, 138 | windows: native, 139 | default: () => {}, 140 | })(); 141 | }; 142 | 143 | if (componentOverrides) { 144 | const overrides = componentOverrides[version]; 145 | if (overrides) { 146 | if (__DEV__) { 147 | if (typeof overrides !== 'function') { 148 | console.error(dedent` 149 | When attempting to resolve the native component ${PRIMARY_VIEW_NAME}, 150 | componentOverrides.${version} is expected to be a function, but found 151 | ${typeof overrides} instead. 152 | `); 153 | } 154 | } 155 | 156 | result = overrides(nativeComponent, nativeModule); 157 | 158 | if (__DEV__) { 159 | if (typeof result !== 'function') { 160 | console.error(dedent` 161 | When attempting to resolve the native component ${PRIMARY_VIEW_NAME}, 162 | componentOverrides.${version} is expected to be a function that returns a React 163 | component. Instead, ${typeof result} was found. 164 | `); 165 | } 166 | } 167 | } 168 | } 169 | 170 | return result; 171 | } 172 | 173 | module.exports = SafeComponentCreate; 174 | -------------------------------------------------------------------------------- /src/NativeSafeModule.js: -------------------------------------------------------------------------------- 1 | import { NativeModules, NativeEventEmitter } from 'react-native'; 2 | import dedent from 'dedent'; 3 | 4 | // const AccountModule = SafeModule.create({ 5 | // moduleName: ['SomeNativeModule', 'SomeOldNameOfThatModule'], 6 | // isEventEmitter: true, 7 | // getVersion: module => module.VERSION, 8 | // onInit: (module, version) => {}, 9 | // onNoModuleFound: () => {}, 10 | // onVersionFound: (version) => {}, 11 | // onOverrideUsed: (version, overrideName) => {}, 12 | // onOverrideCalled: (version, overrideName) => {}, 13 | // mock: { 14 | // push: () => Promise.resolve(), 15 | // pushNative: () => Promise.resolve(), 16 | // setTitle: noop, 17 | // }, 18 | // overrides: { 19 | // 7: { 20 | // push: old => (id, props, options) => { 21 | // return old(id, props, !!options.animated); 22 | // }, 23 | // }, 24 | // }, 25 | // }); 26 | 27 | const hasOwnProperty = Object.prototype.hasOwnProperty; 28 | 29 | const UNMOCKED_PROPERTY_WHITELIST = { 30 | VERSION: true, 31 | addListener: true, 32 | removeListeners: true, 33 | }; 34 | 35 | const eventEmitterMock = { 36 | addListener() {}, 37 | removeListeners() {}, 38 | }; 39 | 40 | const first = (array, fn) => { 41 | let result; 42 | let i = 0; 43 | /* eslint no-plusplus: 0 */ 44 | for (; i < array.length; i++) { 45 | result = fn(array[i]); 46 | if (result) return result; 47 | } 48 | return null; 49 | }; 50 | 51 | const moduleWithName = (nameOrArray) => { 52 | if (!nameOrArray) return null; 53 | if (Array.isArray(nameOrArray)) return first(nameOrArray, moduleWithName); 54 | return NativeModules[nameOrArray]; 55 | }; 56 | 57 | const getPrimaryName = (nameOrArray) => { 58 | return Array.isArray(nameOrArray) ? getPrimaryName(nameOrArray[0]) : nameOrArray; 59 | }; 60 | 61 | const getModule = (moduleNameOrNames, mock, isEventEmitter) => { 62 | const module = moduleWithName(moduleNameOrNames); 63 | // TODO: in __DEV__, we should console.warn if anything but the first module got used. 64 | if (module) return module; 65 | // For Platform.OS === 'ios', we must ensure that `module` contains event 66 | // emitter methods expected by `NativeEventEmitter`, even in the case of a 67 | // mock. Otherwise, calling the emitter will throw an error. 68 | if (isEventEmitter) return Object.assign({}, mock, eventEmitterMock); 69 | return mock; 70 | }; 71 | 72 | const defaultGetVersion = module => module.VERSION; 73 | 74 | 75 | const create = function SafeModuleCreate(options) { 76 | if (!options) { 77 | throw new Error(dedent` 78 | SafeModule.module(...) was invoked without any options parameter. 79 | `); 80 | } 81 | const { 82 | moduleName, 83 | mock, 84 | isEventEmitter, 85 | versionOverrides, 86 | } = options; 87 | let { 88 | getVersion, 89 | } = options; 90 | 91 | if (!getVersion) { 92 | getVersion = defaultGetVersion; 93 | } 94 | 95 | if (!moduleName) { 96 | throw new Error(dedent` 97 | SafeModule.module(...) requires a moduleName property to be specified. 98 | `); 99 | } 100 | const MODULE_NAME = getPrimaryName(moduleName); 101 | 102 | if (!mock) { 103 | throw new Error(dedent` 104 | Missing a "mock" parameter. 105 | `); 106 | } 107 | 108 | const result = {}; 109 | 110 | const module = getModule(moduleName, mock, isEventEmitter); 111 | const version = getVersion(module); 112 | 113 | if (__DEV__) { 114 | Object.keys(module).forEach((key) => { 115 | if (!hasOwnProperty.call(mock, key) && !UNMOCKED_PROPERTY_WHITELIST[key]) { 116 | console.warn(dedent` 117 | ReactNative.NativeModules.${MODULE_NAME}.${key} did not have a corresponding prop defined 118 | in the mock provided to SafeModule. 119 | `); 120 | } 121 | }); 122 | } 123 | 124 | if (isEventEmitter) { 125 | // TODO(lmr): should this be put inside of a try/catch? 126 | result.emitter = new NativeEventEmitter(module); 127 | } 128 | 129 | let overrides; 130 | let boundOverrides; 131 | if (versionOverrides) { 132 | overrides = versionOverrides[version]; 133 | boundOverrides = {}; 134 | if (overrides) { 135 | Object.keys(overrides).forEach((key) => { 136 | if (typeof overrides[key] === 'function') { 137 | boundOverrides[key] = overrides[key](module[key], module); 138 | } else { 139 | boundOverrides[key] = overrides[key]; 140 | } 141 | }); 142 | } 143 | } 144 | 145 | Object.assign( 146 | result, 147 | mock, 148 | module, 149 | boundOverrides 150 | ); 151 | 152 | return result; 153 | }; 154 | 155 | module.exports = create; 156 | -------------------------------------------------------------------------------- /src/SafeComponent.android.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./NativeSafeComponent'); 2 | -------------------------------------------------------------------------------- /src/SafeComponent.ios.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./NativeSafeComponent'); 2 | -------------------------------------------------------------------------------- /src/SafeComponent.js: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | 3 | function SafeComponentCreate(options) { 4 | if (!options) { 5 | throw new Error(dedent` 6 | SafeModule.create(...) was invoked without any options parameter. 7 | `); 8 | } 9 | const { 10 | viewName, 11 | mockComponent, 12 | } = options; 13 | 14 | if (!viewName) { 15 | throw new Error(` 16 | SafeModule.component(...) requires a viewName property to be specified. 17 | `); 18 | } 19 | 20 | if (!mockComponent) { 21 | throw new Error(` 22 | SafeModule.component(...) requires a mockComponent property to be specified. 23 | `); 24 | } 25 | return mockComponent; 26 | } 27 | 28 | module.exports = SafeComponentCreate; 29 | -------------------------------------------------------------------------------- /src/SafeComponent.windows.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./NativeSafeComponent'); 2 | -------------------------------------------------------------------------------- /src/SafeModule.android.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./NativeSafeModule'); 2 | -------------------------------------------------------------------------------- /src/SafeModule.ios.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./NativeSafeModule'); 2 | -------------------------------------------------------------------------------- /src/SafeModule.js: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import EventEmitter from './EventEmitter'; 3 | 4 | const create = function SafeModuleCreate(options) { 5 | if (!options) { 6 | throw new Error(dedent` 7 | SafeModule.module(...) was invoked without any options parameter. 8 | `); 9 | } 10 | const { 11 | moduleName, 12 | mock, 13 | isEventEmitter, 14 | } = options; 15 | 16 | if (!moduleName) { 17 | throw new Error(dedent` 18 | SafeModule.module(...) requires a moduleName property to be specified. 19 | `); 20 | } 21 | 22 | if (!mock) { 23 | throw new Error(dedent` 24 | Missing a "mock" parameter. 25 | `); 26 | } 27 | 28 | const result = {}; 29 | 30 | if (isEventEmitter) { 31 | // TODO(lmr): should this be put inside of a try/catch? 32 | result.emitter = new EventEmitter(); 33 | } 34 | 35 | Object.assign(result, mock); 36 | 37 | return result; 38 | }; 39 | 40 | module.exports = create; 41 | -------------------------------------------------------------------------------- /src/SafeModule.windows.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./NativeSafeModule'); 2 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require: 0 */ 2 | module.exports = { 3 | create: require('./SafeModule'), 4 | module: require('./SafeModule'), 5 | component: require('./SafeComponent'), 6 | }; 7 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc", 3 | "env": { 4 | "node": true, 5 | "mocha": true, 6 | }, 7 | "rules": { 8 | "import/no-extraneous-dependencies": 0 9 | }, 10 | "globals": { 11 | "__DEV__": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/SafeModule-test.js: -------------------------------------------------------------------------------- 1 | import { NativeModules, NativeEventEmitter } from 'react-native'; 2 | import { expect } from 'chai'; 3 | import sinon from 'sinon'; 4 | import NativeSafeModule from '../src/NativeSafeModule'; 5 | import NativeSafeComponent from '../src/NativeSafeComponent'; 6 | 7 | const SafeModule = { 8 | create: NativeSafeModule, 9 | module: NativeSafeModule, 10 | component: NativeSafeComponent, 11 | }; 12 | 13 | let i = 1; 14 | const uniqueModuleName = () => { 15 | i += 1; 16 | return `ExampleModule${i}`; 17 | }; 18 | 19 | describe('SafeModule', () => { 20 | it('throws if no definition is passed in', () => { 21 | expect(() => SafeModule.create()).to.throw(); 22 | }); 23 | 24 | it('throws if no module name is passed in', () => { 25 | expect(() => SafeModule.create({ 26 | mock: {}, 27 | })).to.throw(); 28 | }); 29 | 30 | it('throws if no mock is passed in', () => { 31 | expect(() => SafeModule.create({ 32 | moduleName: uniqueModuleName(), 33 | })).to.throw(); 34 | }); 35 | 36 | it('uses the mock if module is not found', () => { 37 | const moduleName = uniqueModuleName(); 38 | const mock = { 39 | foo: sinon.spy(), 40 | }; 41 | const result = SafeModule.create({ 42 | moduleName, 43 | mock, 44 | }); 45 | 46 | result.foo(); 47 | 48 | expect(mock.foo).callCount(1); 49 | }); 50 | 51 | 52 | it('uses module in preference to mock', () => { 53 | const moduleName = uniqueModuleName(); 54 | const module = { 55 | foo: sinon.spy(), 56 | }; 57 | const mock = { 58 | foo: sinon.spy(), 59 | }; 60 | NativeModules[moduleName] = module; 61 | const result = SafeModule.create({ 62 | moduleName, 63 | mock, 64 | }); 65 | 66 | result.foo(); 67 | 68 | expect(module.foo).callCount(1); 69 | expect(mock.foo).callCount(0); 70 | }); 71 | 72 | it('uses overrides for specific version', () => { 73 | const moduleName = uniqueModuleName(); 74 | const module = { 75 | VERSION: 5, 76 | foo: sinon.spy(), 77 | }; 78 | const mock = { 79 | foo: sinon.spy(), 80 | bar: 'mock', 81 | }; 82 | const v5spy = sinon.spy(); 83 | const v5 = { 84 | foo: () => v5spy, 85 | bar: 'v5', 86 | }; 87 | sinon.spy(v5, 'foo'); 88 | const v6spy = sinon.spy(); 89 | const v6 = { 90 | foo: () => v6spy, 91 | bar: 'v6', 92 | }; 93 | sinon.spy(v6, 'foo'); 94 | NativeModules[moduleName] = module; 95 | const result = SafeModule.create({ 96 | moduleName, 97 | mock, 98 | versionOverrides: { 99 | 6: v6, 100 | 5: v5, 101 | }, 102 | }); 103 | 104 | expect(v6.foo).callCount(0); 105 | expect(v6spy).callCount(0); 106 | expect(v5spy).callCount(0); 107 | expect(mock.foo).callCount(0); 108 | expect(module.foo).callCount(0); 109 | 110 | expect(v5.foo).callCount(1); 111 | expect(v5.foo).calledWith(module.foo, module); 112 | 113 | result.foo('a', 'b', 'c'); 114 | 115 | expect(v6.foo).callCount(0); 116 | expect(v6spy).callCount(0); 117 | 118 | expect(v5.foo).callCount(1); 119 | expect(v5spy).callCount(1); 120 | expect(v5spy).calledWith('a', 'b', 'c'); 121 | 122 | expect(result.bar).to.equal('v5'); 123 | }); 124 | 125 | it('allow getVersion to get custom version', () => { 126 | const moduleName = uniqueModuleName(); 127 | const module = { 128 | VERSION: 'BAD', 129 | foo: sinon.spy(), 130 | }; 131 | const mock = { 132 | foo: sinon.spy(), 133 | }; 134 | const GOOD = { foo: sinon.spy(() => 'PROP') }; 135 | const BAD = { foo: sinon.spy() }; 136 | const getVersion = sinon.spy(() => 'GOOD'); 137 | NativeModules[moduleName] = module; 138 | const result = SafeModule.create({ 139 | moduleName, 140 | mock, 141 | getVersion, 142 | versionOverrides: { 143 | GOOD, 144 | BAD, 145 | }, 146 | }); 147 | 148 | expect(getVersion).callCount(1); 149 | expect(getVersion).calledWith(module); 150 | expect(GOOD.foo).callCount(1); 151 | expect(GOOD.foo).calledWith(module.foo, module); 152 | expect(BAD.foo).callCount(0); 153 | expect(result.foo).to.equal('PROP'); 154 | }); 155 | 156 | it('isEventEmitter option creates an EventEmitter', () => { 157 | const moduleName = uniqueModuleName(); 158 | const module = { 159 | foo: sinon.spy(), 160 | }; 161 | const mock = { 162 | foo: sinon.spy(), 163 | }; 164 | NativeModules[moduleName] = module; 165 | const result = SafeModule.create({ 166 | moduleName, 167 | mock, 168 | isEventEmitter: true, 169 | }); 170 | expect(result.emitter).instanceOf(NativeEventEmitter); 171 | }); 172 | 173 | it('mock has EventEmitter methods when isEventEmitter=true', () => { 174 | const moduleName = uniqueModuleName(); 175 | const mock = { 176 | foo: sinon.spy(), 177 | }; 178 | const result = SafeModule.create({ 179 | moduleName, 180 | mock, 181 | isEventEmitter: true, 182 | }); 183 | expect(result.emitter).instanceOf(NativeEventEmitter); 184 | expect(result.addListener).to.be.a('function'); 185 | expect(result.removeListeners).to.be.a('function'); 186 | }); 187 | 188 | it('falls back to older module name if newer name isnt present', () => { 189 | const moduleName1 = uniqueModuleName(); 190 | const moduleName2 = uniqueModuleName(); 191 | const module = { 192 | foo: sinon.spy(), 193 | }; 194 | const mock = { 195 | foo: sinon.spy(), 196 | }; 197 | NativeModules[moduleName2] = module; 198 | const result = SafeModule.create({ 199 | moduleName: [moduleName1, moduleName2], 200 | mock, 201 | }); 202 | 203 | result.foo(); 204 | 205 | expect(module.foo).callCount(1); 206 | expect(mock.foo).callCount(0); 207 | }); 208 | 209 | it('prefers the first module name', () => { 210 | const moduleName1 = uniqueModuleName(); 211 | const moduleName2 = uniqueModuleName(); 212 | const module1 = { 213 | foo: sinon.spy(), 214 | }; 215 | const module2 = { 216 | foo: sinon.spy(), 217 | }; 218 | const mock = { 219 | foo: sinon.spy(), 220 | }; 221 | NativeModules[moduleName1] = module1; 222 | NativeModules[moduleName2] = module2; 223 | const result = SafeModule.create({ 224 | moduleName: [moduleName1, moduleName2], 225 | mock, 226 | }); 227 | 228 | result.foo(); 229 | 230 | expect(module1.foo).callCount(1); 231 | expect(module2.foo).callCount(0); 232 | expect(mock.foo).callCount(0); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /test/_setup.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require:0 */ 2 | const chai = require('chai'); 3 | chai.use(require('sinon-chai')); 4 | 5 | __DEV__ = false; 6 | --------------------------------------------------------------------------------