diff --git a/Libraries/BatchedBridge/BatchedBridge.js b/Libraries/BatchedBridge/BatchedBridge.js index 82ca82de1..5b582be40 100644 --- a/Libraries/BatchedBridge/BatchedBridge.js +++ b/Libraries/BatchedBridge/BatchedBridge.js @@ -26,8 +26,7 @@ BatchedBridge.registerCallableModule('Systrace', Systrace); BatchedBridge.registerCallableModule('JSTimersExecution', JSTimersExecution); if (__DEV__) { - const HMRClient = require('HMRClient'); - BatchedBridge.registerCallableModule('HMRClient', HMRClient); + BatchedBridge.registerCallableModule('HMRClient', require('HMRClient')); } // Wire up the batched bridge on the global object so that we can call into it. diff --git a/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js b/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js index cb6ba3275..e1dcea29d 100644 --- a/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js +++ b/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js @@ -216,3 +216,8 @@ if (__DEV__) { } require('RCTDeviceEventEmitter'); require('PerformanceLogger'); + +if (__DEV__) { + // include this transform and it's dependencies on the bundle on dev mode + require('react-transform-hmr'); +} diff --git a/Libraries/Utilities/HMRClient.js b/Libraries/Utilities/HMRClient.js index 1671b1850..7ce2f24b6 100644 --- a/Libraries/Utilities/HMRClient.js +++ b/Libraries/Utilities/HMRClient.js @@ -10,30 +10,26 @@ */ 'use strict'; -let _activeWS; - /** * HMR Client that receives from the server HMR updates and propagates them * runtime to reflects those changes. */ const HMRClient = { - setEnabled(enabled) { - if (_activeWS && _activeWS) { - _activeWS.close(); - _activeWS = null; - } + enable() { + // need to require WebSocket inside of `enable` function because the + // this module is defined as a `polyfillGlobal`. + // See `InitializeJavascriptAppEngine.js` + const WebSocket = require('WebSocket'); - if (enabled) { - // TODO(martinb): parametrize the url and receive entryFile to minimize - // the number of updates we want to receive from the server. - _activeWS = new WebSocket('ws://localhost:8081/hot'); - _activeWS.onerror = (e) => { - console.error('[Hot Module Replacement] Unexpected error', e); - }; - _activeWS.onmessage = (m) => { - // TODO(martinb): inject HMR update - }; - } + // TODO(martinb): parametrize the url and receive entryFile to minimize + // the number of updates we want to receive from the server. + const activeWS = new WebSocket('ws://localhost:8081/hot'); + activeWS.onerror = (e) => { + console.error('[Hot Module Replacement] Unexpected error', e); + }; + activeWS.onmessage = (m) => { + eval(m.data); // eslint-disable-line no-eval + }; }, }; diff --git a/React/Base/RCTBridge.m b/React/Base/RCTBridge.m index 6ba6b5c36..65e36ee25 100644 --- a/React/Base/RCTBridge.m +++ b/React/Base/RCTBridge.m @@ -17,6 +17,7 @@ #import "RCTLog.h" #import "RCTPerformanceLogger.h" #import "RCTUtils.h" +#import "RCTBundleURLProcessor.h" NSString *const RCTReloadNotification = @"RCTReloadNotification"; NSString *const RCTJavaScriptWillStartLoadingNotification = @"RCTJavaScriptWillStartLoadingNotification"; @@ -257,6 +258,7 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init) RCTAssertMainThread(); _bundleURL = [self.delegate sourceURLForBridge:self] ?: _bundleURL; + _bundleURL = [[RCTBundleURLProcessor sharedProcessor] process: _bundleURL]; // Sanitize the bundle URL _bundleURL = [RCTConvert NSURL:_bundleURL.absoluteString]; diff --git a/React/Base/RCTBundleURLProcessor.h b/React/Base/RCTBundleURLProcessor.h new file mode 100644 index 000000000..89587aa17 --- /dev/null +++ b/React/Base/RCTBundleURLProcessor.h @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +@interface RCTBundleURLProcessor : NSObject + ++ (id)sharedProcessor; + +- (NSString *)getQueryStringValue:(NSString *)attribute; +- (void)setQueryStringValue:(NSString *)value forAttribute:(NSString *)attribute; +- (NSURL *)process:(NSURL *)url; + +@end diff --git a/React/Base/RCTBundleURLProcessor.m b/React/Base/RCTBundleURLProcessor.m new file mode 100644 index 000000000..35c49a9d5 --- /dev/null +++ b/React/Base/RCTBundleURLProcessor.m @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +#import + +#import "RCTBundleURLProcessor.h" + +@implementation RCTBundleURLProcessor + +NSDictionary *_qsAttributes; + ++ (id)sharedProcessor +{ + static RCTBundleURLProcessor *sharedProcessor = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedProcessor = [self new]; + }); + return sharedProcessor; +} + +- (instancetype)init +{ + // dictionary with additional query string attributes that will get appended + // to the bundle URL + _qsAttributes = [NSMutableDictionary new]; + return self; +} + +- (NSString *)getQueryStringValue:(NSString *)attribute +{ + return [_qsAttributes valueForKey:attribute]; +} + +- (void)setQueryStringValue:(NSString *)value forAttribute:(NSString *)attribute +{ + [_qsAttributes setValue:value forKey:attribute]; +} + +- (NSURL *)process:(NSURL *)url +{ + if (url.isFileURL || [_qsAttributes count] == 0) { + return url; + } + + // append either `?` or `&` depending on whether there are query string + // attibutes or not. + NSString *urlString = url.absoluteString; + if ([urlString rangeOfString:@"?"].location == NSNotFound) { + urlString = [urlString stringByAppendingString:@"?"]; + } else { + urlString = [urlString stringByAppendingString:@"&"]; + } + + // array with new query string attributes + NSMutableArray *parts = [NSMutableArray new]; + for (id attribute in _qsAttributes) { + if ([urlString rangeOfString:[NSString stringWithFormat:@"%@=", attribute]].location != NSNotFound) { + [NSException raise:@"Cannot override attribute" format:@"Attribute %@ is already present in url: %@", attribute, url.absoluteString]; + } + [parts addObject:[NSString stringWithFormat:@"%@=%@", attribute, _qsAttributes[attribute]]]; + } + + return [NSURL URLWithString:[NSString stringWithFormat:@"%@%@", urlString, [parts componentsJoinedByString:@"&"]]]; +} + +@end diff --git a/React/Executors/RCTJSCExecutor.m b/React/Executors/RCTJSCExecutor.m index d71b1a351..b46886133 100644 --- a/React/Executors/RCTJSCExecutor.m +++ b/React/Executors/RCTJSCExecutor.m @@ -23,6 +23,7 @@ #import "RCTPerformanceLogger.h" #import "RCTUtils.h" #import "RCTJSCProfiler.h" +#import "RCTBundleURLProcessor.h" static NSString *const RCTJSCProfilerEnabledDefaultsKey = @"RCTJSCProfilerEnabled"; static NSString *const RCTHotLoadingEnabledDefaultsKey = @"RCTHotLoadingEnabled"; @@ -146,9 +147,20 @@ static void RCTInstallJSCProfiler(RCTBridge *bridge, JSContextRef context) static void RCTInstallHotLoading(RCTBridge *bridge, RCTJSCExecutor *executor) { - [bridge.devMenu addItem:[RCTDevMenuItem toggleItemWithKey:RCTHotLoadingEnabledDefaultsKey title:@"Enable Hot Loading" selectedTitle:@"Disable Hot Loading" handler:^(BOOL enabled) { + [bridge.devMenu addItem:[RCTDevMenuItem toggleItemWithKey:RCTHotLoadingEnabledDefaultsKey title:@"Enable Hot Loading" selectedTitle:@"Disable Hot Loading" handler:^(BOOL enabledOnCurrentBundle) { [executor executeBlockOnJavaScriptQueue:^{ - [bridge enqueueJSCall:@"HMRClient.setEnabled" args:@[enabled ? @YES : @NO]]; + NSString *enabledQS = [[RCTBundleURLProcessor sharedProcessor] getQueryStringValue:@"hot"]; + BOOL enabledOnConfig = (enabledQS != nil && [enabledQS isEqualToString:@"true"]) ? YES : NO; + + // reload bundle when user change Hot Loading setting + if (enabledOnConfig != enabledOnCurrentBundle) { + [[RCTBundleURLProcessor sharedProcessor] setQueryStringValue:enabledOnCurrentBundle ? @"true" : @"false" forAttribute:@"hot"]; + [bridge reload]; + } + + if (enabledOnCurrentBundle) { + [bridge enqueueJSCall:@"HMRClient.enable" args:@[@YES]]; + } }]; }]]; } diff --git a/local-cli/server/util/attachHMRServer.js b/local-cli/server/util/attachHMRServer.js index 9554f0ff0..bca1fe41b 100644 --- a/local-cli/server/util/attachHMRServer.js +++ b/local-cli/server/util/attachHMRServer.js @@ -14,6 +14,11 @@ */ function attachHMRServer({httpServer, path, packagerServer}) { let activeWS; + + function disconnect() { + activeWS = null; + } + packagerServer.addFileChangeListener(filename => { if (!activeWS) { return; @@ -40,12 +45,10 @@ function attachHMRServer({httpServer, path, packagerServer}) { ws.on('error', e => { console.error('[Hot Module Replacement] Unexpected error', e); + disconnect(); }); - ws.on('close', () => { - console.log('[Hot Module Replacement] Client disconnected'); - activeWS = null; - }); + ws.on('close', () => disconnect()); }); } diff --git a/packager/react-packager/src/Bundler/index.js b/packager/react-packager/src/Bundler/index.js index 87e97d061..a2fd93be4 100644 --- a/packager/react-packager/src/Bundler/index.js +++ b/packager/react-packager/src/Bundler/index.js @@ -137,7 +137,7 @@ class Bundler { this._assetServer = opts.assetServer; if (opts.getTransformOptionsModulePath) { - this._getTransformOptions = require(opts.getTransformOptionsModulePath); + this._getTransformOptionsModule = require(opts.getTransformOptionsModulePath); } } @@ -158,6 +158,7 @@ class Bundler { dev: isDev, platform, unbundle: isUnbundle, + hot: hot, }) { // Const cannot have the same name as the method (babel/babel#2834) const bbundle = new Bundle(sourceMapUrl); @@ -194,7 +195,8 @@ class Bundler { bbundle, response, module, - platform + platform, + hot, ).then(transformed => { if (bar) { bar.tick(); @@ -286,12 +288,16 @@ class Bundler { return Promise.all([ module.getName(), - this._transformer.loadFileAndTransform(path.resolve(entryFile)), + this._transformer.loadFileAndTransform( + path.resolve(entryFile), + // TODO(martinb): pass non null main (t9527509) + this._getTransformOptions({main: null}, {hot: true}), + ), ]).then(([moduleName, transformedSource]) => { return (` __accept( - '${moduleName}', - function(global, require, module, exports) { + '${moduleName}', + function(global, require, module, exports) { ${transformedSource.code} } ); @@ -340,7 +346,7 @@ class Bundler { ); } - _transformModule(bundle, response, module, platform = null) { + _transformModule(bundle, response, module, platform = null, hot = false) { if (module.isAsset_DEPRECATED()) { return this.generateAssetModule_DEPRECATED(bundle, module); } else if (module.isAsset()) { @@ -350,8 +356,10 @@ class Bundler { } else { return this._transformer.loadFileAndTransform( path.resolve(module.path), - this._getTransformOptions ? - this._getTransformOptions({bundle, module, platform}) : {} + this._getTransformOptions( + {bundleEntry: bundle.getMainModuleId(), modulePath: module.path}, + {hot: hot}, + ), ); } } @@ -445,6 +453,14 @@ class Bundler { }); }); } + + _getTransformOptions(config, options) { + const transformerOptions = this._getTransformOptionsModule + ? this._getTransformOptionsModule(config) + : null; + + return {...options, ...transformerOptions}; + } } function generateJSONModule(module) { diff --git a/packager/react-packager/src/Resolver/index.js b/packager/react-packager/src/Resolver/index.js index 8e8dd315c..be7e8a5eb 100644 --- a/packager/react-packager/src/Resolver/index.js +++ b/packager/react-packager/src/Resolver/index.js @@ -89,6 +89,7 @@ class Resolver { // should work after this release and we can // remove it from here. 'parse', + 'react-transform-hmr', ], platforms: ['ios', 'android'], fileWatcher: opts.fileWatcher, diff --git a/packager/react-packager/src/Resolver/polyfills/require.js b/packager/react-packager/src/Resolver/polyfills/require.js index 73e54e596..022a4831a 100644 --- a/packager/react-packager/src/Resolver/polyfills/require.js +++ b/packager/react-packager/src/Resolver/polyfills/require.js @@ -16,7 +16,7 @@ hot: { acceptCallback: null, accept: function(callback) { - this.acceptCallback = callback; + modules[id].module.hot.acceptCallback = callback; } } }); @@ -97,12 +97,28 @@ if (__DEV__) { // HMR function accept(id, factory) { var mod = modules[id]; + + if (!mod) { + console.error( + 'Cannot accept unknown module `' + id + '`. Make sure you\'re not ' + + 'trying to modify something else other than a module ' + + '(i.e.: a polyfill).' + ); + } + + if (!mod.module.hot) { + console.error( + 'Cannot accept module because Hot Module Replacement ' + + 'API was not installed.' + ); + } + if (mod.module.hot.acceptCallback) { mod.factory = factory; mod.isInitialized = false; require(id); - mod.hot.acceptCallback(); + mod.module.hot.acceptCallback(); } else { console.log( '[HMR] Module `' + id + '` cannot be accepted. ' + diff --git a/packager/react-packager/src/Server/index.js b/packager/react-packager/src/Server/index.js index 668d38782..915203958 100644 --- a/packager/react-packager/src/Server/index.js +++ b/packager/react-packager/src/Server/index.js @@ -109,7 +109,11 @@ const bundleOpts = declareOpts({ unbundle: { type: 'boolean', default: false, - } + }, + hot: { + type: 'boolean', + default: false, + }, }); const hmrBundleOpts = declareOpts({ @@ -501,6 +505,7 @@ class Server { entryFile: entryFile, dev: this._getBoolOptionFromQuery(urlObj.query, 'dev', true), minify: this._getBoolOptionFromQuery(urlObj.query, 'minify'), + hot: this._getBoolOptionFromQuery(urlObj.query, 'hot', false), runModule: this._getBoolOptionFromQuery(urlObj.query, 'runModule', true), inlineSourceMap: this._getBoolOptionFromQuery( urlObj.query,