diff --git a/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js b/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js index dfc09ba7c..4702e246d 100644 --- a/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js +++ b/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js @@ -19,9 +19,17 @@ var slice = Array.prototype.slice; var MethodTypes = keyMirror({ remote: null, + remoteAsync: null, local: null, }); +type ErrorData = { + message: string; + domain: string; + code: number; + nativeStackIOS?: string; +}; + /** * Creates remotely invokable modules. */ @@ -36,21 +44,40 @@ var BatchedBridgeFactory = { */ _createBridgedModule: function(messageQueue, moduleConfig, moduleName) { var remoteModule = mapObject(moduleConfig.methods, function(methodConfig, memberName) { - return methodConfig.type === MethodTypes.local ? null : function() { - var lastArg = arguments.length > 0 ? arguments[arguments.length - 1] : null; - var secondLastArg = arguments.length > 1 ? arguments[arguments.length - 2] : null; - var hasSuccCB = typeof lastArg === 'function'; - var hasErrorCB = typeof secondLastArg === 'function'; - hasErrorCB && invariant( - hasSuccCB, - 'Cannot have a non-function arg after a function arg.' - ); - var numCBs = (hasSuccCB ? 1 : 0) + (hasErrorCB ? 1 : 0); - var args = slice.call(arguments, 0, arguments.length - numCBs); - var onSucc = hasSuccCB ? lastArg : null; - var onFail = hasErrorCB ? secondLastArg : null; - return messageQueue.call(moduleName, memberName, args, onFail, onSucc); - }; + switch (methodConfig.type) { + case MethodTypes.remote: + return function() { + var lastArg = arguments.length > 0 ? arguments[arguments.length - 1] : null; + var secondLastArg = arguments.length > 1 ? arguments[arguments.length - 2] : null; + var hasErrorCB = typeof lastArg === 'function'; + var hasSuccCB = typeof secondLastArg === 'function'; + hasSuccCB && invariant( + hasErrorCB, + 'Cannot have a non-function arg after a function arg.' + ); + var numCBs = (hasSuccCB ? 1 : 0) + (hasErrorCB ? 1 : 0); + var args = slice.call(arguments, 0, arguments.length - numCBs); + var onSucc = hasSuccCB ? secondLastArg : null; + var onFail = hasErrorCB ? lastArg : null; + messageQueue.call(moduleName, memberName, args, onSucc, onFail); + }; + + case MethodTypes.remoteAsync: + return function(...args) { + return new Promise((resolve, reject) => { + messageQueue.call(moduleName, memberName, args, resolve, (errorData) => { + var error = _createErrorFromErrorData(errorData); + reject(error); + }); + }); + }; + + case MethodTypes.local: + return null; + + default: + throw new Error('Unknown bridge method type: ' + methodConfig.type); + } }); for (var constName in moduleConfig.constants) { warning(!remoteModule[constName], 'saw constant and method named %s', constName); @@ -59,7 +86,6 @@ var BatchedBridgeFactory = { return remoteModule; }, - create: function(MessageQueue, modulesConfig, localModulesConfig) { var messageQueue = new MessageQueue(modulesConfig, localModulesConfig); return { @@ -80,4 +106,14 @@ var BatchedBridgeFactory = { } }; +function _createErrorFromErrorData(errorData: ErrorData): Error { + var { + message, + ...extraErrorInfo, + } = errorData; + var error = new Error(message); + error.framesToPop = 1; + return Object.assign(error, extraErrorInfo); +} + module.exports = BatchedBridgeFactory; diff --git a/Libraries/Utilities/MessageQueue.js b/Libraries/Utilities/MessageQueue.js index a09bd4f4a..8a819a939 100644 --- a/Libraries/Utilities/MessageQueue.js +++ b/Libraries/Utilities/MessageQueue.js @@ -441,14 +441,14 @@ var MessageQueueMixin = { }, /** - * @param {Function} onFail Function to store in current thread for later - * lookup, when request fails. * @param {Function} onSucc Function to store in current thread for later * lookup, when request succeeds. + * @param {Function} onFail Function to store in current thread for later + * lookup, when request fails. * @param {Object?=} scope Scope to invoke `cb` with. * @param {Object?=} res Resulting callback ids. Use `this._POOLED_CBIDS`. */ - _storeCallbacksInCurrentThread: function(onFail, onSucc, scope) { + _storeCallbacksInCurrentThread: function(onSucc, onFail, scope) { invariant(onFail || onSucc, INTERNAL_ERROR); this._bookkeeping.allocateCallbackIDs(this._POOLED_CBIDS); var succCBID = this._POOLED_CBIDS.successCallbackID; @@ -506,7 +506,7 @@ var MessageQueueMixin = { return ret; }, - call: function(moduleName, methodName, params, onFail, onSucc, scope) { + call: function(moduleName, methodName, params, onSucc, onFail, scope) { invariant( (!onFail || typeof onFail === 'function') && (!onSucc || typeof onSucc === 'function'), @@ -514,10 +514,10 @@ var MessageQueueMixin = { ); // Store callback _before_ sending the request, just in case the MailBox // returns the response in a blocking manner. - if (onSucc) { - this._storeCallbacksInCurrentThread(onFail, onSucc, scope, this._POOLED_CBIDS); + if (onSucc || onFail) { + this._storeCallbacksInCurrentThread(onSucc, onFail, scope, this._POOLED_CBIDS); + onSucc && params.push(this._POOLED_CBIDS.successCallbackID); onFail && params.push(this._POOLED_CBIDS.errorCallbackID); - params.push(this._POOLED_CBIDS.successCallbackID); } var moduleID = this._remoteModuleNameToModuleID[moduleName]; if (moduleID === undefined || moduleID === null) { diff --git a/React/Base/RCTBridge.m b/React/Base/RCTBridge.m index fc741208f..8a479eade 100644 --- a/React/Base/RCTBridge.m +++ b/React/Base/RCTBridge.m @@ -47,6 +47,11 @@ typedef NS_ENUM(NSUInteger, RCTBridgeFields) { RCTBridgeFieldFlushDateMillis }; +typedef NS_ENUM(NSUInteger, RCTJavaScriptFunctionKind) { + RCTJavaScriptFunctionKindNormal, + RCTJavaScriptFunctionKindAsync, +}; + #ifdef __LP64__ typedef struct mach_header_64 *RCTHeaderValue; typedef struct section_64 RCTHeaderSection; @@ -204,6 +209,27 @@ static NSArray *RCTBridgeModuleClassesByModuleID(void) return RCTModuleClassesByID; } +// TODO: Can we just replace RCTMakeError with this function instead? +static NSDictionary *RCTJSErrorFromNSError(NSError *error) +{ + NSString *errorMessage; + NSArray *stackTrace = [NSThread callStackSymbols]; + NSMutableDictionary *errorInfo = + [NSMutableDictionary dictionaryWithObject:stackTrace forKey:@"nativeStackIOS"]; + + if (error) { + errorMessage = error.localizedDescription ?: @"Unknown error from a native module"; + errorInfo[@"domain"] = error.domain ?: RCTErrorDomain; + errorInfo[@"code"] = @(error.code); + } else { + errorMessage = @"Unknown error from a native module"; + errorInfo[@"domain"] = RCTErrorDomain; + errorInfo[@"code"] = @-1; + } + + return RCTMakeError(errorMessage, nil, errorInfo); +} + @class RCTBatchedBridge; @interface RCTBridge () @@ -239,6 +265,7 @@ static NSArray *RCTBridgeModuleClassesByModuleID(void) @property (nonatomic, copy, readonly) NSString *moduleClassName; @property (nonatomic, copy, readonly) NSString *JSMethodName; @property (nonatomic, assign, readonly) SEL selector; +@property (nonatomic, assign, readonly) RCTJavaScriptFunctionKind functionKind; @end @@ -390,22 +417,64 @@ case _value: { \ [invocation setArgument:returnValue atIndex:index]; - free(returnValue); - }]; - break; + free(returnValue); + }]; + break; + } + + default: + defaultCase(argumentType); + } + } else if ([argumentName isEqualToString:@"RCTResponseSenderBlock"]) { + addBlockArgument(); + } else if ([argumentName isEqualToString:@"RCTPromiseResolveBlock"]) { + RCTAssert(i == numberOfArguments - 2, + @"The RCTPromiseResolveBlock must be the second to last parameter in -[%@ %@]", + _moduleClassName, objCMethodName); + RCT_ARG_BLOCK( + if (RCT_DEBUG && ![json isKindOfClass:[NSNumber class]]) { + RCTLogError(@"Argument %tu (%@) of %@.%@ must be a promise resolver ID", index, + json, RCTBridgeModuleNameForClass(_moduleClass), _JSMethodName); + return; } - default: - defaultCase(argumentType); - } - } else if ([argumentName isEqualToString:@"RCTResponseSenderBlock"]) { - addBlockArgument(); - } else { + // Marked as autoreleasing, because NSInvocation doesn't retain arguments + __autoreleasing RCTPromiseResolveBlock value = (^(id result) { + NSArray *arguments = result ? @[result] : @[]; + [bridge _invokeAndProcessModule:@"BatchedBridge" + method:@"invokeCallbackAndReturnFlushedQueue" + arguments:@[json, arguments] + context:context]; + }); + ) + _functionKind = RCTJavaScriptFunctionKindAsync; + } else if ([argumentName isEqualToString:@"RCTPromiseRejectBlock"]) { + RCTAssert(i == numberOfArguments - 1, + @"The RCTPromiseRejectBlock must be the last parameter in -[%@ %@]", + _moduleClassName, objCMethodName); + RCT_ARG_BLOCK( + if (RCT_DEBUG && ![json isKindOfClass:[NSNumber class]]) { + RCTLogError(@"Argument %tu (%@) of %@.%@ must be a promise rejecter ID", index, + json, RCTBridgeModuleNameForClass(_moduleClass), _JSMethodName); + return; + } - // Unknown argument type - RCTLogError(@"Unknown argument type '%@' in method %@. Extend RCTConvert" - " to support this type.", argumentName, [self methodName]); - } + // Marked as autoreleasing, because NSInvocation doesn't retain arguments + __autoreleasing RCTPromiseRejectBlock value = (^(NSError *error) { + NSDictionary *errorJSON = RCTJSErrorFromNSError(error); + [bridge _invokeAndProcessModule:@"BatchedBridge" + method:@"invokeCallbackAndReturnFlushedQueue" + arguments:@[json, @[errorJSON]] + context:context]; + }); + ) + _functionKind = RCTJavaScriptFunctionKindAsync; + } else { + + // Unknown argument type + RCTLogError(@"Unknown argument type '%@' in method %@. Extend RCTConvert" + " to support this type.", argumentName, [self methodName]); + } } _argumentBlocks = [argumentBlocks copy]; @@ -427,9 +496,18 @@ case _value: { \ // Safety check if (arguments.count != _argumentBlocks.count) { + NSInteger actualCount = arguments.count; + NSInteger expectedCount = _argumentBlocks.count; + + // Subtract the implicit Promise resolver and rejecter functions for implementations of async functions + if (_functionKind == RCTJavaScriptFunctionKindAsync) { + actualCount -= 2; + expectedCount -= 2; + } + RCTLogError(@"%@.%@ was called with %zd arguments, but expects %zd", RCTBridgeModuleNameForClass(_moduleClass), _JSMethodName, - arguments.count, _argumentBlocks.count); + actualCount, expectedCount); return; } } @@ -535,7 +613,7 @@ static RCTSparseArray *RCTExportedMethodsByModuleID(void) * }, * "methodName2": { * "methodID": 1, - * "type": "remote" + * "type": "remoteAsync" * }, * etc... * }, @@ -559,7 +637,7 @@ static NSDictionary *RCTRemoteModulesConfig(NSDictionary *modulesByName) [methods enumerateObjectsUsingBlock:^(RCTModuleMethod *method, NSUInteger methodID, BOOL *_stop) { methodsByName[method.JSMethodName] = @{ @"methodID": @(methodID), - @"type": @"remote", + @"type": method.functionKind == RCTJavaScriptFunctionKindAsync ? @"remoteAsync" : @"remote", }; }]; diff --git a/React/Base/RCTBridgeModule.h b/React/Base/RCTBridgeModule.h index f3a9a5a3e..d64e81693 100644 --- a/React/Base/RCTBridgeModule.h +++ b/React/Base/RCTBridgeModule.h @@ -17,6 +17,20 @@ */ typedef void (^RCTResponseSenderBlock)(NSArray *response); +/** + * Block that bridge modules use to resolve the JS promise waiting for a result. + * Nil results are supported and are converted to JS's undefined value. + */ +typedef void (^RCTPromiseResolveBlock)(id result); + +/** + * Block that bridge modules use to reject the JS promise waiting for a result. + * The error may be nil but it is preferable to pass an NSError object for more + * precise error messages. + */ +typedef void (^RCTPromiseRejectBlock)(NSError *error); + + /** * This constant can be returned from +methodQueue to force module * methods to be called on the JavaScript thread. This can have serious @@ -37,7 +51,7 @@ extern const dispatch_queue_t RCTJSThread; * A reference to the RCTBridge. Useful for modules that require access * to bridge features, such as sending events or making JS calls. This * will be set automatically by the bridge when it initializes the module. -* To implement this in your module, just add @synthesize bridge = _bridge; + * To implement this in your module, just add @synthesize bridge = _bridge; */ @property (nonatomic, weak) RCTBridge *bridge; @@ -70,6 +84,26 @@ extern const dispatch_queue_t RCTJSThread; * { ... } * * and is exposed to JavaScript as `NativeModules.ModuleName.doSomething`. + * + * ## Promises + * + * Bridge modules can also define methods that are exported to JavaScript as + * methods that return a Promise, and are compatible with JS async functions. + * + * Declare the last two parameters of your native method to be a resolver block + * and a rejecter block. The resolver block must precede the rejecter block. + * + * For example: + * + * RCT_EXPORT_METHOD(doSomethingAsync:(NSString *)aString + * resolver:(RCTPromiseResolveBlock)resolve + * rejecter:(RCTPromiseRejectBlock)reject + * { ... } + * + * Calling `NativeModules.ModuleName.doSomethingAsync(aString)` from + * JavaScript will return a promise that is resolved or rejected when your + * native method implementation calls the respective block. + * */ #define RCT_EXPORT_METHOD(method) \ RCT_REMAP_METHOD(, method) @@ -118,7 +152,7 @@ extern const dispatch_queue_t RCTJSThread; RCT_EXTERN_REMAP_MODULE(, objc_name, objc_supername) /** - * Similar to RCT_EXTERN_MODULE but allows setting a custom JavaScript name + * Like RCT_EXTERN_MODULE, but allows setting a custom JavaScript name. */ #define RCT_EXTERN_REMAP_MODULE(js_name, objc_name, objc_supername) \ objc_name : objc_supername \ @@ -136,7 +170,7 @@ extern const dispatch_queue_t RCTJSThread; RCT_EXTERN_REMAP_METHOD(, method) /** - * Similar to RCT_EXTERN_REMAP_METHOD but allows setting a custom JavaScript name + * Like RCT_EXTERN_REMAP_METHOD, but allows setting a custom JavaScript name. */ #define RCT_EXTERN_REMAP_METHOD(js_name, method) \ - (void)__rct_export__##method { \