From 803bbb5b9f7f401116d13e4b559c134c0d852898 Mon Sep 17 00:00:00 2001 From: James Ide Date: Sat, 9 May 2015 18:15:24 -0700 Subject: [PATCH] [Bridge] Add support for JS async functions to RCT_EXPORT_METHOD Adds support for JS async methods and helps guide people writing native modules w.r.t. the callbacks. With this diff, on the native side you write: ```objc RCT_EXPORT_METHOD(getValueAsync:(NSString *)key resolver:(RCTPromiseResolver)resolve rejecter:(RCTPromiseRejecter)reject) { NSError *error = nil; id value = [_nativeDataStore valueForKey:key error:&error]; // "resolve" and "reject" are automatically defined blocks that take // any object (nil is OK) and an NSError, respectively if (!error) { resolve(value); } else { reject(error); } } ``` On the JS side, you can write: ```js var {DemoDataStore} = require('react-native').NativeModules; DemoDataStore.getValueAsync('sample-key').then((value) => { console.log('Got:', value); }, (error) => { console.error(error); // "error" is an Error object whose message is the NSError's description. // The NSError's code and domain are also set, and the native trace is // available under nativeStackIOS }); ``` And if you take a time machine or use Babel w/stage 1, you can write: ```js try { var value = await DemoDataStore.getValueAsync('sample-key'); console.log('Got:', value); } catch (error) { console.error(error); } ``` Test Plan: Defined a sample async method: ```objc RCT_EXPORT_METHOD(testAsync:(RCTPromiseResolver)resolve rejecter:(RCTPromiseRejecter)reject) { static BOOL toggle = NO; toggle = !toggle; if (toggle) { resolve(@"result"); } else { reject(nil); } } ``` Called it from JS to verify that the JS method returns a promise and is correctly resolved or rejected. In the rejection case, we get a JS Error object with the message passed from native (a default error message in this case since the NSError was nil), code, domain, and native stack trace. Tested parameter verification by removing the rejecter parameter. The assertion in the bridge failed and printed a helpful error message with the name of the offending native class and selector. --- .../BatchedBridgeFactory.js | 68 +++++++++++---- Libraries/Utilities/MessageQueue.js | 14 +-- React/Base/RCTBridge.m | 86 ++++++++++++++++++- React/Base/RCTBridgeModule.h | 36 +++++++- 4 files changed, 177 insertions(+), 27 deletions(-) diff --git a/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js b/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js index dfc09ba7cbc219..4702e246de9eed 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 df34dde0629f31..5b1989c287f501 100644 --- a/Libraries/Utilities/MessageQueue.js +++ b/Libraries/Utilities/MessageQueue.js @@ -431,14 +431,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; @@ -494,7 +494,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'), @@ -502,10 +502,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 47c7f59420c91d..fe13586b5f317f 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 uint64_t RCTHeaderValue; typedef struct section_64 RCTHeaderSection; @@ -239,6 +244,7 @@ @interface RCTModuleMethod : NSObject @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 @@ -420,6 +426,51 @@ - (instancetype)initWithReactMethodName:(NSString *)reactMethodName } else if ([argumentName isEqualToString:@"RCTResponseSenderBlock"]) { addBlockArgument(); useFallback = NO; + } 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; + } + + // 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]; + }); + ) + useFallback = NO; + _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; + } + + // Marked as autoreleasing, because NSInvocation doesn't retain arguments + __autoreleasing RCTPromiseRejectBlock value = (^(NSError *error) { + NSDictionary *errorData = [RCTModuleMethod dictionaryFromError:error + stackTrace:[NSThread callStackSymbols]]; + [bridge _invokeAndProcessModule:@"BatchedBridge" + method:@"invokeCallbackAndReturnFlushedQueue" + arguments:@[json, @[errorData]] + context:context]; + }); + ) + useFallback = NO; + _functionKind = RCTJavaScriptFunctionKindAsync; } } @@ -498,9 +549,18 @@ - (void)invokeWithBridge:(RCTBridge *)bridge // 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; } } @@ -528,6 +588,26 @@ - (NSString *)description return [NSString stringWithFormat:@"<%@: %p; exports %@ as %@;>", NSStringFromClass(self.class), self, _methodName, _JSMethodName]; } ++ (NSDictionary *)dictionaryFromError:(NSError *)error stackTrace:(NSArray *)stackTrace +{ + NSString *errorMessage; + NSMutableDictionary *errorInfo = [NSMutableDictionary dictionaryWithDictionary:@{ + @"nativeStackIOS": stackTrace, + }]; + + 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); +} + @end /** @@ -606,7 +686,7 @@ - (NSString *)description * }, * "methodName2": { * "methodID": 1, - * "type": "remote" + * "type": "remoteAsync" * }, * etc... * }, @@ -630,7 +710,7 @@ - (NSString *)description [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 34b861ff3f8f10..302e4642f42649 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 promises 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)