From 4f613e20d23e391ff2d85526627afd847f785980 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 6 Feb 2015 15:45:28 -0800 Subject: [PATCH] 2015-02-05 updates - [ReactServer] Fix newly introduced bug | Amjad Masad - [ReactServer] Last sync from github | Amjad Masad - [RFC-ReactNative] Subscribable overhaul, clean up AppState/Reachability | Eric Vicenti - [ReactKit] Enable tappable subnodes | Alex Akers --- Examples/UIExplorer/TextExample.ios.js | 18 +- Libraries/Components/Subscribable.js | 288 ++++++++++++++++-- ReactKit/Base/RCTTouchHandler.m | 4 +- ReactKit/Base/RCTViewNodeProtocol.h | 1 + ReactKit/ReactKit.xcodeproj/project.pbxproj | 6 + ReactKit/Views/RCTShadowText.h | 1 - ReactKit/Views/RCTShadowText.m | 38 +-- ReactKit/Views/RCTShadowView.m | 15 + ReactKit/Views/RCTText.h | 13 + ReactKit/Views/RCTText.m | 97 ++++++ ReactKit/Views/RCTTextManager.m | 37 +-- ReactKit/Views/UIView+ReactKit.m | 9 + packager/react-packager/index.js | 109 +------ packager/react-packager/package.json | 8 +- packager/react-packager/src/Activity/index.js | 3 +- .../src/DependencyResolver/haste/index.js | 2 +- .../FileWatcher/__tests__/FileWatcher-test.js | 4 +- .../react-packager/src/FileWatcher/index.js | 10 +- .../react-packager/src/JSTransformer/Cache.js | 22 +- .../src/JSTransformer/__mocks__/worker.js | 5 + .../src/JSTransformer/__tests__/Cache-test.js | 42 --- .../react-packager/src/JSTransformer/index.js | 6 + .../react-packager/src/Packager/Package.js | 8 +- packager/react-packager/src/Packager/index.js | 4 + .../src/Server/__tests__/Server-test.js | 166 ++++++++++ packager/react-packager/src/Server/index.js | 128 ++++++++ 26 files changed, 786 insertions(+), 258 deletions(-) create mode 100644 ReactKit/Views/RCTText.h create mode 100644 ReactKit/Views/RCTText.m create mode 100644 packager/react-packager/src/JSTransformer/__mocks__/worker.js create mode 100644 packager/react-packager/src/Server/__tests__/Server-test.js create mode 100644 packager/react-packager/src/Server/index.js diff --git a/Examples/UIExplorer/TextExample.ios.js b/Examples/UIExplorer/TextExample.ios.js index f7cfba060..a0d5eba33 100644 --- a/Examples/UIExplorer/TextExample.ios.js +++ b/Examples/UIExplorer/TextExample.ios.js @@ -34,22 +34,20 @@ var AttributeToggler = React.createClass({ render: function() { var curStyle = {fontSize: this.state.fontSize}; return ( - + Tap the controls below to change attributes. - - See how it will even work on{' '} - - this nested text - + See how it will even work on{' '} + + this nested text + + + {'>> Increase Size <<'} - - {'>> Increase Size <<'} - - + ); } }); diff --git a/Libraries/Components/Subscribable.js b/Libraries/Components/Subscribable.js index d8264d343..b53d9322e 100644 --- a/Libraries/Components/Subscribable.js +++ b/Libraries/Components/Subscribable.js @@ -5,34 +5,270 @@ */ 'use strict'; -var Subscribable = { - Mixin: { - componentWillMount: function() { - this._subscriptions = []; - }, - componentWillUnmount: function() { - this._subscriptions.forEach((subscription) => subscription.remove()); - this._subscriptions = null; - }, +/** + * Subscribable wraps EventEmitter in a clean interface, and provides a mixin + * so components can easily subscribe to events and not worry about cleanup on + * unmount. + * + * Also acts as a basic store because it records the last data that it emitted, + * and provides a way to populate the initial data. The most recent data can be + * fetched from the Subscribable by calling `get()` + * + * Advantages over EventEmitter + Subscibable.Mixin.addListenerOn: + * - Cleaner usage: no strings to identify the event + * - Lifespan pattern enforces cleanup + * - More logical: Subscribable.Mixin now uses a Subscribable class + * - Subscribable saves the last data and makes it available with `.get()` + * + * Legacy Subscribable.Mixin.addListenerOn allowed automatic subscription to + * EventEmitters. Now we should avoid EventEmitters and wrap with Subscribable + * instead: + * + * ``` + * AppState.networkReachability = new Subscribable( + * RCTDeviceEventEmitter, + * 'reachabilityDidChange', + * (resp) => resp.network_reachability, + * RKReachability.getCurrentReachability + * ); + * + * var myComponent = React.createClass({ + * mixins: [Subscribable.Mixin], + * getInitialState: function() { + * return { + * isConnected: AppState.networkReachability.get() !== 'none' + * }; + * }, + * componentDidMount: function() { + * this._reachSubscription = this.subscribeTo( + * AppState.networkReachability, + * (reachability) => { + * this.setState({ isConnected: reachability !== 'none' }) + * } + * ); + * }, + * render: function() { + * return ( + * + * {this.state.isConnected ? 'Network Connected' : 'No network'} + * + * this._reachSubscription.remove()}> + * End reachability subscription + * + * ); + * } + * }); + * ``` + */ - /** - * Special form of calling `addListener` that *guarantees* that a - * subscription *must* be tied to a component instance, and therefore will - * be cleaned up when the component is unmounted. It is impossible to create - * the subscription and pass it in - this method must be the one to create - * the subscription and therefore can guarantee it is retained in a way that - * will be cleaned up. - * - * @param {EventEmitter} eventEmitter emitter to subscribe to. - * @param {string} eventType Type of event to listen to. - * @param {function} listener Function to invoke when event occurs. - * @param {object} context Object to use as listener context. - */ - addListenerOn: function(eventEmitter, eventType, listener, context) { - this._subscriptions.push( - eventEmitter.addListener(eventType, listener, context) - ); +var EventEmitter = require('EventEmitter'); + +var invariant = require('invariant'); +var logError = require('logError'); + +var SUBSCRIBABLE_INTERNAL_EVENT = 'subscriptionEvent'; + + +class Subscribable { + /** + * Creates a new Subscribable object + * + * @param {EventEmitter} eventEmitter Emitter to trigger subscription events. + * @param {string} eventName Name of emitted event that triggers subscription + * events. + * @param {function} eventMapping (optional) Function to convert the output + * of the eventEmitter to the subscription output. + * @param {function} getInitData (optional) Async function to grab the initial + * data to publish. Signature `function(successCallback, errorCallback)`. + * The resolved data will be transformed with the eventMapping before it + * gets emitted. + */ + constructor(eventEmitter, eventName, eventMapping, getInitData) { + + this._internalEmitter = new EventEmitter(); + this._eventMapping = eventMapping || (data => data); + + eventEmitter.addListener( + eventName, + this._handleEmit, + this + ); + + // Asyncronously get the initial data, if provided + getInitData && getInitData(this._handleInitData.bind(this), logError); + } + + /** + * Returns the last data emitted from the Subscribable, or undefined + */ + get() { + return this._lastData; + } + + /** + * Add a new listener to the subscribable. This should almost never be used + * directly, and instead through Subscribable.Mixin.subscribeTo + * + * @param {object} lifespan Object with `addUnmountCallback` that accepts + * a handler to be called when the component unmounts. This is required and + * desirable because it enforces cleanup. There is no easy way to leave the + * subsciption hanging + * { + * addUnmountCallback: function(newUnmountHanlder) {...}, + * } + * @param {function} callback Handler to call when Subscribable has data + * updates + * @param {object} context Object to bind the handler on, as "this" + * + * @return {object} the subscription object: + * { + * remove: function() {...}, + * } + * Call `remove` to terminate the subscription before unmounting + */ + subscribe(lifespan, callback, context) { + invariant( + typeof lifespan.addUnmountCallback === 'function', + 'Must provide a valid lifespan, which provides a way to add a ' + + 'callback for when subscription can be cleaned up. This is used ' + + 'automatically by Subscribable.Mixin' + ); + invariant( + typeof callback === 'function', + 'Must provide a valid subscription handler.' + ); + + // Add a listener to the internal EventEmitter + var subscription = this._internalEmitter.addListener( + SUBSCRIBABLE_INTERNAL_EVENT, + callback, + context + ); + + // Clean up subscription upon the lifespan unmount callback + lifespan.addUnmountCallback(() => { + subscription.remove(); + }); + + return subscription; + } + + /** + * Callback for the initial data resolution. Currently behaves the same as + * `_handleEmit`, but we may eventually want to keep track of the difference + */ + _handleInitData(dataInput) { + var emitData = this._eventMapping(dataInput); + this._lastData = emitData; + this._internalEmitter.emit(SUBSCRIBABLE_INTERNAL_EVENT, emitData); + } + + /** + * Handle new data emissions. Pass the data through our eventMapping + * transformation, store it for later `get()`ing, and emit it for subscribers + */ + _handleEmit(dataInput) { + var emitData = this._eventMapping(dataInput); + this._lastData = emitData; + this._internalEmitter.emit(SUBSCRIBABLE_INTERNAL_EVENT, emitData); + } +} + + +Subscribable.Mixin = { + + /** + * @return {object} lifespan Object with `addUnmountCallback` that accepts + * a handler to be called when the component unmounts + * { + * addUnmountCallback: function(newUnmountHanlder) {...}, + * } + */ + _getSubscribableLifespan: function() { + if (!this._subscribableLifespan) { + this._subscribableLifespan = { + addUnmountCallback: (cb) => { + this._endSubscribableLifespanCallbacks.push(cb); + }, + }; } + return this._subscribableLifespan; + }, + + _endSubscribableLifespan: function() { + this._endSubscribableLifespanCallbacks.forEach(cb => cb()); + }, + + /** + * Components use `subscribeTo` for listening to Subscribable stores. Cleanup + * is automatic on component unmount. + * + * To stop listening to the subscribable and end the subscription early, + * components should store the returned subscription object and invoke the + * `remove()` function on it + * + * @param {Subscribable} subscription to subscribe to. + * @param {function} listener Function to invoke when event occurs. + * @param {object} context Object to bind the handler on, as "this" + * + * @return {object} the subscription object: + * { + * remove: function() {...}, + * } + * Call `remove` to terminate the subscription before unmounting + */ + subscribeTo: function(subscribable, handler, context) { + invariant( + subscribable instanceof Subscribable, + 'Must provide a Subscribable' + ); + return subscribable.subscribe( + this._getSubscribableLifespan(), + handler, + context + ); + }, + + componentWillMount: function() { + this._endSubscribableLifespanCallbacks = []; + + // DEPRECATED addListenerOn* usage: + this._subscribableSubscriptions = []; + }, + + componentWillUnmount: function() { + // Resolve the lifespan, which will cause Subscribable to clean any + // remaining subscriptions + this._endSubscribableLifespan && this._endSubscribableLifespan(); + + // DEPRECATED addListenerOn* usage uses _subscribableSubscriptions array + // instead of lifespan + this._subscribableSubscriptions.forEach( + (subscription) => subscription.remove() + ); + this._subscribableSubscriptions = null; + }, + + /** + * DEPRECATED - Use `Subscribable` and `Mixin.subscribeTo` instead. + * `addListenerOn` subscribes the component to an `EventEmitter`. + * + * Special form of calling `addListener` that *guarantees* that a + * subscription *must* be tied to a component instance, and therefore will + * be cleaned up when the component is unmounted. It is impossible to create + * the subscription and pass it in - this method must be the one to create + * the subscription and therefore can guarantee it is retained in a way that + * will be cleaned up. + * + * @param {EventEmitter} eventEmitter emitter to subscribe to. + * @param {string} eventType Type of event to listen to. + * @param {function} listener Function to invoke when event occurs. + * @param {object} context Object to use as listener context. + */ + addListenerOn: function(eventEmitter, eventType, listener, context) { + this._subscribableSubscriptions.push( + eventEmitter.addListener(eventType, listener, context) + ); } }; diff --git a/ReactKit/Base/RCTTouchHandler.m b/ReactKit/Base/RCTTouchHandler.m index cc5cdee7c..0bca6680b 100644 --- a/ReactKit/Base/RCTTouchHandler.m +++ b/ReactKit/Base/RCTTouchHandler.m @@ -80,7 +80,7 @@ typedef NS_ENUM(NSInteger, RCTTouchEventType) { RCTAssert(targetView.reactTag && targetView.userInteractionEnabled, @"No react view found for touch - something went wrong."); - + // Get new, unique touch id const NSUInteger RCTMaxTouches = 11; // This is the maximum supported by iDevices NSInteger touchID = ([_reactTouches.lastObject[@"target"] integerValue] + 1) % RCTMaxTouches; @@ -97,7 +97,7 @@ typedef NS_ENUM(NSInteger, RCTTouchEventType) { // Create touch NSMutableDictionary *reactTouch = [[NSMutableDictionary alloc] initWithCapacity:9]; - reactTouch[@"target"] = targetView.reactTag; + reactTouch[@"target"] = [targetView reactTagAtPoint:[touch locationInView:targetView]]; reactTouch[@"identifier"] = @(touchID); reactTouch[@"touches"] = [NSNull null]; // We hijack this touchObj to serve both as an event reactTouch[@"changedTouches"] = [NSNull null]; // and as a Touch object, so making this JIT friendly. diff --git a/ReactKit/Base/RCTViewNodeProtocol.h b/ReactKit/Base/RCTViewNodeProtocol.h index d45806f26..1fa3e252b 100644 --- a/ReactKit/Base/RCTViewNodeProtocol.h +++ b/ReactKit/Base/RCTViewNodeProtocol.h @@ -13,6 +13,7 @@ - (void)insertReactSubview:(id)subview atIndex:(NSInteger)atIndex; - (void)removeReactSubview:(id)subview; - (NSMutableArray *)reactSubviews; +- (NSNumber *)reactTagAtPoint:(CGPoint)point; // View is an RCTRootView - (BOOL)isReactRootView; diff --git a/ReactKit/ReactKit.xcodeproj/project.pbxproj b/ReactKit/ReactKit.xcodeproj/project.pbxproj index a9a5cb9ad..4610f9f75 100644 --- a/ReactKit/ReactKit.xcodeproj/project.pbxproj +++ b/ReactKit/ReactKit.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 13E067591A70F44B002CDEE1 /* UIView+ReactKit.m in Sources */ = {isa = PBXBuildFile; fileRef = 13E067541A70F44B002CDEE1 /* UIView+ReactKit.m */; }; 830A229E1A66C68A008503DA /* RCTRootView.m in Sources */ = {isa = PBXBuildFile; fileRef = 830A229D1A66C68A008503DA /* RCTRootView.m */; }; 832348161A77A5AA00B55238 /* Layout.c in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FC71A68125100A75B9A /* Layout.c */; }; + 835DD1321A7FDFB600D561F7 /* RCTText.m in Sources */ = {isa = PBXBuildFile; fileRef = 835DD1311A7FDFB600D561F7 /* RCTText.m */; }; 83CBBA511A601E3B00E9B192 /* RCTAssert.m in Sources */ = {isa = PBXBuildFile; fileRef = 83CBBA4B1A601E3B00E9B192 /* RCTAssert.m */; }; 83CBBA521A601E3B00E9B192 /* RCTLog.m in Sources */ = {isa = PBXBuildFile; fileRef = 83CBBA4E1A601E3B00E9B192 /* RCTLog.m */; }; 83CBBA531A601E3B00E9B192 /* RCTUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 83CBBA501A601E3B00E9B192 /* RCTUtils.m */; }; @@ -134,6 +135,8 @@ 830213F31A654E0800B993E6 /* RCTBridgeModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTBridgeModule.h; sourceTree = ""; }; 830A229C1A66C68A008503DA /* RCTRootView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTRootView.h; sourceTree = ""; }; 830A229D1A66C68A008503DA /* RCTRootView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRootView.m; sourceTree = ""; }; + 835DD1301A7FDFB600D561F7 /* RCTText.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTText.h; sourceTree = ""; }; + 835DD1311A7FDFB600D561F7 /* RCTText.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTText.m; sourceTree = ""; }; 83BEE46C1A6D19BC00B5863B /* RCTSparseArray.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTSparseArray.h; sourceTree = ""; }; 83BEE46D1A6D19BC00B5863B /* RCTSparseArray.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTSparseArray.m; sourceTree = ""; }; 83CBBA2E1A601D0E00E9B192 /* libReactKit.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libReactKit.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -228,6 +231,8 @@ 137029581A6C197000575408 /* RCTRawTextManager.m */, 13B07FFC1A6947C200A75B9A /* RCTShadowText.h */, 13B07FFD1A6947C200A75B9A /* RCTShadowText.m */, + 835DD1301A7FDFB600D561F7 /* RCTText.h */, + 835DD1311A7FDFB600D561F7 /* RCTText.m */, 13B080021A6947C200A75B9A /* RCTTextManager.h */, 13B080031A6947C200A75B9A /* RCTTextManager.m */, 13B0800C1A69489C00A75B9A /* RCTNavigator.h */, @@ -416,6 +421,7 @@ 13B0801F1A69489C00A75B9A /* RCTTextFieldManager.m in Sources */, 134FCB3D1A6E7F0800051CC8 /* RCTContextExecutor.m in Sources */, 13E067591A70F44B002CDEE1 /* UIView+ReactKit.m in Sources */, + 835DD1321A7FDFB600D561F7 /* RCTText.m in Sources */, 137029531A69923600575408 /* RCTImageDownloader.m in Sources */, 83CBBA981A6020BB00E9B192 /* RCTTouchHandler.m in Sources */, 83CBBA521A601E3B00E9B192 /* RCTLog.m in Sources */, diff --git a/ReactKit/Views/RCTShadowText.h b/ReactKit/Views/RCTShadowText.h index 541d34c26..ddaf2ebe7 100644 --- a/ReactKit/Views/RCTShadowText.h +++ b/ReactKit/Views/RCTShadowText.h @@ -22,6 +22,5 @@ extern NSString *const RCTReactTagAttributeName; @property (nonatomic, assign) NSLineBreakMode truncationMode; - (NSAttributedString *)attributedString; -- (NSAttributedString *)reactTagAttributedString; @end diff --git a/ReactKit/Views/RCTShadowText.m b/ReactKit/Views/RCTShadowText.m index 917c0d864..ce6ff244e 100644 --- a/ReactKit/Views/RCTShadowText.m +++ b/ReactKit/Views/RCTShadowText.m @@ -14,8 +14,7 @@ NSString *const RCTReactTagAttributeName = @"ReactTagAttributeName"; static css_dim_t RCTMeasure(void *context, float width) { RCTShadowText *shadowText = (__bridge RCTShadowText *)context; - if (isnan(width)) width = MAXFLOAT; - CGSize computedSize = [[shadowText attributedString] boundingRectWithSize:(CGSize){width, CGFLOAT_MAX} options:NSStringDrawingUsesLineFragmentOrigin context:nil].size; + CGSize computedSize = [[shadowText attributedString] boundingRectWithSize:(CGSize){isnan(width) ? CGFLOAT_MAX : width, CGFLOAT_MAX} options:NSStringDrawingUsesLineFragmentOrigin context:nil].size; css_dim_t result; result.dimensions[CSS_WIDTH] = RCTCeilPixelValue(computedSize.width); @@ -26,7 +25,6 @@ static css_dim_t RCTMeasure(void *context, float width) @implementation RCTShadowText { NSAttributedString *_cachedAttributedString; - NSAttributedString *_cachedReactTagAttributedString; UIFont *_font; } @@ -39,33 +37,6 @@ static css_dim_t RCTMeasure(void *context, float width) return self; } -- (NSAttributedString *)reactTagAttributedString -{ - if (![self isTextDirty] && _cachedReactTagAttributedString) { - return _cachedReactTagAttributedString; - } - - NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] init]; - for (RCTShadowView *child in [self reactSubviews]) { - if ([child isKindOfClass:[RCTShadowText class]]) { - RCTShadowText *shadowText = (RCTShadowText *)child; - [attributedString appendAttributedString:[shadowText reactTagAttributedString]]; - } else if ([child isKindOfClass:[RCTShadowRawText class]]) { - RCTShadowRawText *shadowRawText = (RCTShadowRawText *)child; - [attributedString appendAttributedString:[[NSAttributedString alloc] initWithString:[shadowRawText text] ?: @""]]; - } else { - RCTLogError(@" can't have any children except or raw strings"); - } - } - - [self _addAttribute:RCTReactTagAttributeName - withValue:self.reactTag - toAttributedString:attributedString]; - - _cachedReactTagAttributedString = attributedString; - return _cachedReactTagAttributedString; -} - - (NSAttributedString *)attributedString { return [self _attributedStringWithFontFamily:nil @@ -81,9 +52,6 @@ static css_dim_t RCTMeasure(void *context, float width) return _cachedAttributedString; } - // while we're updating the attributed string, also update the react tag attributed string - [self reactTagAttributedString]; - if (_fontSize && !isnan(_fontSize)) { fontSize = _fontSize; } @@ -121,7 +89,7 @@ static css_dim_t RCTMeasure(void *context, float width) _font = [RCTConvert UIFont:nil withFamily:fontFamily size:@(fontSize) weight:fontWeight]; [self _addAttribute:NSFontAttributeName withValue:_font toAttributedString:attributedString]; - + [self _addAttribute:RCTReactTagAttributeName withValue:self.reactTag toAttributedString:attributedString]; [self _setParagraphStyleOnAttributedString:attributedString]; // create a non-mutable attributedString for use by the Text system which avoids copies down the line @@ -179,10 +147,10 @@ static css_dim_t RCTMeasure(void *context, float width) // if we found anything, set it :D if (hasParagraphStyle) { NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; + paragraphStyle.alignment = _textAlign; paragraphStyle.baseWritingDirection = _writingDirection; paragraphStyle.minimumLineHeight = _lineHeight; paragraphStyle.maximumLineHeight = _lineHeight; - [paragraphStyle setAlignment:_textAlign]; [attributedString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:(NSRange){0, attributedString.length}]; diff --git a/ReactKit/Views/RCTShadowView.m b/ReactKit/Views/RCTShadowView.m index 9a95e62da..84ce9ab0d 100644 --- a/ReactKit/Views/RCTShadowView.m +++ b/ReactKit/Views/RCTShadowView.m @@ -376,6 +376,21 @@ static void RCTProcessMetaProps(const float metaProps[META_PROP_COUNT], float st return _reactSubviews; } +- (NSNumber *)reactTagAtPoint:(CGPoint)point +{ + for (RCTShadowView *shadowView in _reactSubviews) { + if (CGRectContainsPoint(shadowView.frame, point)) { + CGPoint relativePoint = point; + CGPoint origin = shadowView.frame.origin; + relativePoint.x -= origin.x; + relativePoint.y -= origin.y; + return [shadowView reactTagAtPoint:relativePoint]; + } + } + + return self.reactTag; +} + - (void)updateShadowViewLayout { if (_recomputePadding) { diff --git a/ReactKit/Views/RCTText.h b/ReactKit/Views/RCTText.h new file mode 100644 index 000000000..ddd99cece --- /dev/null +++ b/ReactKit/Views/RCTText.h @@ -0,0 +1,13 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@interface RCTText : UIView + +@property (nonatomic, copy) NSAttributedString *attributedText; +@property (nonatomic, assign) NSLineBreakMode lineBreakMode; +@property (nonatomic, assign) NSInteger numberOfLines; + +- (NSNumber *)reactTagAtPoint:(CGPoint)point; + +@end diff --git a/ReactKit/Views/RCTText.m b/ReactKit/Views/RCTText.m new file mode 100644 index 000000000..5055cd8ac --- /dev/null +++ b/ReactKit/Views/RCTText.m @@ -0,0 +1,97 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTText.h" + +#import "RCTShadowText.h" +#import "RCTUtils.h" +#import "UIView+ReactKit.h" + +@implementation RCTText +{ + NSLayoutManager *_layoutManager; + NSTextStorage *_textStorage; + NSTextContainer *_textContainer; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if ((self = [super initWithFrame:frame])) { + _textContainer = [[NSTextContainer alloc] init]; + _textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + _textContainer.lineFragmentPadding = 0.0; + + _layoutManager = [[NSLayoutManager alloc] init]; + [_layoutManager addTextContainer:_textContainer]; + + _textStorage = [[NSTextStorage alloc] init]; + [_textStorage addLayoutManager:_layoutManager]; + + self.contentMode = UIViewContentModeRedraw; + } + + return self; +} + +- (NSAttributedString *)attributedText +{ + return [_textStorage copy]; +} + +- (void)setAttributedText:(NSAttributedString *)attributedText +{ + [_textStorage setAttributedString:attributedText]; +} + +- (NSInteger)numberOfLines +{ + return _textContainer.maximumNumberOfLines; +} + +- (void)setNumberOfLines:(NSInteger)numberOfLines +{ + _textContainer.maximumNumberOfLines = MAX(0, numberOfLines); +} + +- (NSLineBreakMode)lineBreakMode +{ + return _textContainer.lineBreakMode; +} + +- (void)setLineBreakMode:(NSLineBreakMode)lineBreakMode +{ + _textContainer.lineBreakMode = lineBreakMode; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // The header comment for `size` says that a height of 0.0 should be enough, + // but it isn't. + _textContainer.size = CGSizeMake(self.bounds.size.width, CGFLOAT_MAX); +} + +- (void)drawRect:(CGRect)rect +{ + NSRange glyphRange = [_layoutManager glyphRangeForTextContainer:_textContainer]; + [_layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:CGPointZero]; + [_layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:CGPointZero]; +} + +- (NSNumber *)reactTagAtPoint:(CGPoint)point +{ + CGFloat fraction; + NSUInteger characterIndex = [_layoutManager characterIndexForPoint:point inTextContainer:_textContainer fractionOfDistanceBetweenInsertionPoints:&fraction]; + + NSNumber *reactTag = nil; + + // If the point is not before (fraction == 0.0) the first character and not + // after (fraction == 1.0) the last character, then the attribute is valid. + if (_textStorage.length > 0 && (fraction > 0 || characterIndex > 0) && (fraction < 1 || characterIndex < _textStorage.length - 1)) { + reactTag = [_textStorage attribute:RCTReactTagAttributeName atIndex:characterIndex effectiveRange:NULL]; + } + + return reactTag ?: self.reactTag; +} + +@end diff --git a/ReactKit/Views/RCTTextManager.m b/ReactKit/Views/RCTTextManager.m index 2673e60fe..97944e34e 100644 --- a/ReactKit/Views/RCTTextManager.m +++ b/ReactKit/Views/RCTTextManager.m @@ -5,19 +5,17 @@ #import "RCTAssert.h" #import "RCTConvert.h" #import "RCTLog.h" -#import "RCTSparseArray.h" -#import "UIView+ReactKit.h" - #import "RCTShadowRawText.h" #import "RCTShadowText.h" +#import "RCTSparseArray.h" +#import "RCTText.h" +#import "UIView+ReactKit.h" @implementation RCTTextManager - (UIView *)view { - UILabel *label = [[UILabel alloc] init]; - label.numberOfLines = 0; - return label; + return [[RCTText alloc] init]; } - (RCTShadowView *)shadowView @@ -26,11 +24,17 @@ } RCT_REMAP_VIEW_PROPERTY(containerBackgroundColor, backgroundColor) -RCT_REMAP_VIEW_PROPERTY(textAlign, textAlignment); + +- (void)set_textAlign:(id)json + forShadowView:(RCTShadowText *)shadowView + withDefaultView:(RCTShadowText *)defaultView +{ + shadowView.textAlign = json ? [RCTConvert NSTextAlignment:json] : defaultView.textAlign; +} - (void)set_numberOfLines:(id)json - forView:(UILabel *)view - withDefaultView:(UILabel *)defaultView + forView:(RCTText *)view + withDefaultView:(RCTText *)defaultView { NSLineBreakMode truncationMode = NSLineBreakByClipping; view.numberOfLines = json ? [RCTConvert NSInteger:json] : defaultView.numberOfLines; @@ -80,7 +84,6 @@ RCT_REMAP_VIEW_PROPERTY(textAlign, textAlignment); // TODO: are modules global, or specific to a given rootView? for (RCTShadowView *rootView in shadowViewRegistry.allObjects) { - if (![rootView isReactRootView]) { // This isn't a host view continue; @@ -92,7 +95,7 @@ RCT_REMAP_VIEW_PROPERTY(textAlign, textAlignment); } // TODO: this is a slightly weird way to do this - a recursive approach would be cleaner - RCTSparseArray *attributedStringForTag = [[RCTSparseArray alloc] init]; + RCTSparseArray *reactTaggedAttributedStrings = [[RCTSparseArray alloc] init]; NSMutableArray *queue = [NSMutableArray arrayWithObject:rootView]; for (NSInteger i = 0; i < [queue count]; i++) { RCTShadowView *shadowView = queue[i]; @@ -100,11 +103,9 @@ RCT_REMAP_VIEW_PROPERTY(textAlign, textAlignment); if ([shadowView isKindOfClass:[RCTShadowText class]]) { RCTShadowText *shadowText = (RCTShadowText *)shadowView; - NSNumber *reactTag = shadowText.reactTag; - attributedStringForTag[reactTag] = [shadowText attributedString]; + reactTaggedAttributedStrings[shadowText.reactTag] = [shadowText attributedString]; } else if ([shadowView isKindOfClass:[RCTShadowRawText class]]) { - RCTShadowRawText *shadowRawText = (RCTShadowRawText *)shadowView; - RCTLogError(@"Raw text cannot be used outside of a tag. Not rendering string: '%@'", [shadowRawText text]); + RCTLogError(@"Raw text cannot be used outside of a tag. Not rendering string: '%@'", [(RCTShadowRawText *)shadowView text]); } else { for (RCTShadowView *child in [shadowView reactSubviews]) { if ([child isTextDirty]) { @@ -117,9 +118,9 @@ RCT_REMAP_VIEW_PROPERTY(textAlign, textAlignment); } [shadowBlocks addObject:^(RCTUIManager *viewManager, RCTSparseArray *viewRegistry) { - [attributedStringForTag enumerateObjectsUsingBlock:^(NSAttributedString *attributedString, NSNumber *reactTag, BOOL *stop) { - UILabel *textView = viewRegistry[reactTag]; - [textView setAttributedText:attributedString]; + [reactTaggedAttributedStrings enumerateObjectsUsingBlock:^(NSAttributedString *attributedString, NSNumber *reactTag, BOOL *stop) { + RCTText *text = viewRegistry[reactTag]; + text.attributedText = attributedString; }]; }]; } diff --git a/ReactKit/Views/UIView+ReactKit.m b/ReactKit/Views/UIView+ReactKit.m index ffdf6dd7c..39bca8ec6 100644 --- a/ReactKit/Views/UIView+ReactKit.m +++ b/ReactKit/Views/UIView+ReactKit.m @@ -23,6 +23,15 @@ return NO; } +- (NSNumber *)reactTagAtPoint:(CGPoint)point +{ + UIView *view = [self hitTest:point withEvent:nil]; + while (view && !view.reactTag) { + view = view.superview; + } + return view.reactTag; +} + - (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex { [self insertSubview:subview atIndex:atIndex]; diff --git a/packager/react-packager/index.js b/packager/react-packager/index.js index 52562cfe4..59a22d6e9 100644 --- a/packager/react-packager/index.js +++ b/packager/react-packager/index.js @@ -1,100 +1,19 @@ -var Packager = require('./src/Packager'); +'use strict'; + var Activity = require('./src/Activity'); -var url = require('url'); +var Server = require('./src/Server'); + +exports.middleware = function(options) { + var server = new Server(options); + return server.processRequest.bind(server); +}; exports.buildPackageFromUrl = function(options, reqUrl) { Activity.disable(); - var packager = createPackager(options); - var params = getOptionsFromPath(url.parse(reqUrl).pathname); - return packager.package( - params.main, - params.runModule, - params.sourceMapUrl - ).then(function(p) { - packager.kill(); - return p; - }); + var server = new Server(options); + return server.buildPackageFromUrl(reqUrl) + .then(function(p) { + server.kill(); + return p; + }); }; - -exports.catalystMiddleware = function(options) { - var packager = createPackager(options); - - return function(req, res, next) { - var options; - if (req.url.match(/\.bundle$/)) { - options = getOptionsFromPath(url.parse(req.url).pathname); - packager.package( - options.main, - options.runModule, - options.sourceMapUrl - ).then( - function(package) { - res.end(package.getSource()); - }, - function(error) { - handleError(res, error); - } - ).done(); - } else if (req.url.match(/\.map$/)) { - options = getOptionsFromPath(url.parse(req.url).pathname); - packager.package( - options.main, - options.runModule, - options.sourceMapUrl - ).then( - function(package) { - res.end(JSON.stringify(package.getSourceMap())); - }, - function(error) { - handleError(res, error); - } - ).done(); - } else { - next(); - } - }; -}; - -function getOptionsFromPath(pathname) { - var parts = pathname.split('.'); - // Remove the leading slash. - var main = parts[0].slice(1) + '.js'; - return { - runModule: parts.slice(1).some(function(part) { - return part === 'runModule'; - }), - main: main, - sourceMapUrl: parts.slice(0, -1).join('.') + '.map' - }; -} - -function handleError(res, error) { - res.writeHead(500, { - 'Content-Type': 'application/json; charset=UTF-8', - }); - - if (error.type === 'TransformError') { - res.end(JSON.stringify(error)); - } else { - console.error(error.stack || error); - res.end(JSON.stringify({ - type: 'InternalError', - message: 'React packager has encountered an internal error, ' + - 'please check your terminal error output for more details', - })); - } -} - -function createPackager(options) { - return new Packager({ - projectRoot: options.projectRoot, - blacklistRE: options.blacklistRE, - polyfillModuleNames: options.polyfillModuleNames || [], - runtimeCode: options.runtimeCode, - cacheVersion: options.cacheVersion, - resetCache: options.resetCache, - dev: options.dev, - }); -} - -exports.kill = Packager.kill; diff --git a/packager/react-packager/package.json b/packager/react-packager/package.json index db3e9e147..84230dec8 100644 --- a/packager/react-packager/package.json +++ b/packager/react-packager/package.json @@ -1,6 +1,10 @@ { "name": "react-packager", - "version": "0.0.1", + "version": "0.0.2", "description": "", - "main": "index.js" + "main": "index.js", + "jest": { + "unmockedModulePathPatterns": ["source-map"], + "testPathIgnorePatterns": ["JSAppServer/node_modules"] + } } diff --git a/packager/react-packager/src/Activity/index.js b/packager/react-packager/src/Activity/index.js index 5387cbd45..a60f87b08 100644 --- a/packager/react-packager/src/Activity/index.js +++ b/packager/react-packager/src/Activity/index.js @@ -131,11 +131,12 @@ function _writeAction(action) { case 'endEvent': var startAction = _eventStarts[action.eventId]; + var startData = startAction.data ? ': ' + JSON.stringify(startAction.data) : ''; console.log( '[' + fmtTime + '] ' + ' ' + startAction.eventName + '(' + (action.tstamp - startAction.tstamp) + 'ms)' + - data + startData ); delete _eventStarts[action.eventId]; break; diff --git a/packager/react-packager/src/DependencyResolver/haste/index.js b/packager/react-packager/src/DependencyResolver/haste/index.js index 960e49feb..40e4a1d44 100644 --- a/packager/react-packager/src/DependencyResolver/haste/index.js +++ b/packager/react-packager/src/DependencyResolver/haste/index.js @@ -29,7 +29,7 @@ function HasteDependencyResolver(config) { return filepath.indexOf('__tests__') !== -1 || (config.blacklistRE && config.blacklistRE.test(filepath)); }, - fileWatcher: new FileWatcher(config) + fileWatcher: new FileWatcher(config.projectRoot) }); this._polyfillModuleNames = [ diff --git a/packager/react-packager/src/FileWatcher/__tests__/FileWatcher-test.js b/packager/react-packager/src/FileWatcher/__tests__/FileWatcher-test.js index 844715172..af7a4083c 100644 --- a/packager/react-packager/src/FileWatcher/__tests__/FileWatcher-test.js +++ b/packager/react-packager/src/FileWatcher/__tests__/FileWatcher-test.js @@ -17,14 +17,14 @@ describe('FileWatcher', function() { }); pit('it should get the watcher instance when ready', function() { - var fileWatcher = new FileWatcher({projectRoot: 'rootDir'}); + var fileWatcher = new FileWatcher('rootDir'); return fileWatcher.getWatcher().then(function(watcher) { expect(watcher instanceof Watcher).toBe(true); }); }); pit('it should end the watcher', function() { - var fileWatcher = new FileWatcher({projectRoot: 'rootDir'}); + var fileWatcher = new FileWatcher('rootDir'); Watcher.prototype.close.mockImplementation(function(callback) { callback(); }); diff --git a/packager/react-packager/src/FileWatcher/index.js b/packager/react-packager/src/FileWatcher/index.js index d7e1b82f6..d8b17277e 100644 --- a/packager/react-packager/src/FileWatcher/index.js +++ b/packager/react-packager/src/FileWatcher/index.js @@ -22,15 +22,15 @@ var MAX_WAIT_TIME = 3000; var memoizedInstances = Object.create(null); -function FileWatcher(projectConfig) { - if (memoizedInstances[projectConfig.projectRoot]) { - return memoizedInstances[projectConfig.projectRoot]; +function FileWatcher(projectRoot) { + if (memoizedInstances[projectRoot]) { + return memoizedInstances[projectRoot]; } else { - memoizedInstances[projectConfig.projectRoot] = this; + memoizedInstances[projectRoot] = this; } this._loadingWatcher = detectingWatcherClass.then(function(Watcher) { - var watcher = new Watcher(projectConfig.projectRoot, {glob: '**/*.js'}); + var watcher = new Watcher(projectRoot, {glob: '**/*.js'}); return new Promise(function(resolve, reject) { var rejectTimeout = setTimeout(function() { diff --git a/packager/react-packager/src/JSTransformer/Cache.js b/packager/react-packager/src/JSTransformer/Cache.js index 0c5bb9425..4aefd6eb9 100644 --- a/packager/react-packager/src/JSTransformer/Cache.js +++ b/packager/react-packager/src/JSTransformer/Cache.js @@ -4,7 +4,6 @@ var path = require('path'); var version = require('../../package.json').version; var tmpdir = require('os').tmpDir(); var pathUtils = require('../fb-path-utils'); -var FileWatcher = require('../FileWatcher'); var fs = require('fs'); var _ = require('underscore'); var q = require('q'); @@ -34,16 +33,6 @@ function Cache(projectConfig) { this._persistCache.bind(this), 2000 ); - - this._fileWatcher = new FileWatcher(projectConfig); - this._fileWatcher - .getWatcher() - .done(function(watcher) { - watcher.on('all', function(type, filepath) { - var absPath = path.join(projectConfig.projectRoot, filepath); - delete data[absPath]; - }); - }.bind(this)); } Cache.prototype.get = function(filepath, loaderCb) { @@ -75,11 +64,14 @@ Cache.prototype._set = function(filepath, loaderPromise) { }.bind(this)); }; +Cache.prototype.invalidate = function(filepath){ + if(this._has(filepath)) { + delete this._data[filepath]; + } +} + Cache.prototype.end = function() { - return q.all([ - this._persistCache(), - this._fileWatcher.end() - ]); + return this._persistCache(); }; Cache.prototype._persistCache = function() { diff --git a/packager/react-packager/src/JSTransformer/__mocks__/worker.js b/packager/react-packager/src/JSTransformer/__mocks__/worker.js new file mode 100644 index 000000000..04a24e8db --- /dev/null +++ b/packager/react-packager/src/JSTransformer/__mocks__/worker.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function (data, callback) { + callback(null, {}); +}; diff --git a/packager/react-packager/src/JSTransformer/__tests__/Cache-test.js b/packager/react-packager/src/JSTransformer/__tests__/Cache-test.js index 1ae537f18..85278c25d 100644 --- a/packager/react-packager/src/JSTransformer/__tests__/Cache-test.js +++ b/packager/react-packager/src/JSTransformer/__tests__/Cache-test.js @@ -19,13 +19,6 @@ describe('JSTransformer Cache', function() { }); Cache = require('../Cache'); - - var FileWatcher = require('../../FileWatcher'); - FileWatcher.prototype.getWatcher = function() { - return q({ - on: function() {} - }); - }; }); describe('getting/settig', function() { @@ -76,41 +69,6 @@ describe('JSTransformer Cache', function() { }); }); }); - - pit('it invalidates cache after a file has changed', function() { - require('fs').stat.mockImpl(function(file, callback) { - callback(null, { - mtime: { - getTime: function() {} - } - }); - }); - var FileWatcher = require('../../FileWatcher'); - var triggerChangeFile; - FileWatcher.prototype.getWatcher = function() { - return q({ - on: function(type, callback) { - triggerChangeFile = callback; - } - }); - }; - - var cache = new Cache({projectRoot: '/rootDir'}); - var loaderCb = jest.genMockFn().mockImpl(function() { - return q('lol'); - }); - - return cache.get('/rootDir/someFile', loaderCb).then(function(value) { - expect(value).toBe('lol'); - triggerChangeFile('change', 'someFile'); - var loaderCb2 = jest.genMockFn().mockImpl(function() { - return q('lol2'); - }); - return cache.get('/rootDir/someFile', loaderCb2).then(function(value2) { - expect(value2).toBe('lol2'); - }); - }); - }); }); describe('loading cache from disk', function() { diff --git a/packager/react-packager/src/JSTransformer/index.js b/packager/react-packager/src/JSTransformer/index.js index 95d7948be..b748c32e2 100644 --- a/packager/react-packager/src/JSTransformer/index.js +++ b/packager/react-packager/src/JSTransformer/index.js @@ -25,6 +25,12 @@ Transformer.prototype.kill = function() { return this._cache.end(); }; +Transformer.prototype.invalidateFile = function(filePath) { + this._cache.invalidate(filePath); + //TODO: We can read the file and put it into the cache right here + // This would simplify some caching logic as we can be sure that the cache is up to date +} + Transformer.prototype.loadFileAndTransform = function( transformSets, filePath, diff --git a/packager/react-packager/src/Packager/Package.js b/packager/react-packager/src/Packager/Package.js index f68073970..f3f5d992c 100644 --- a/packager/react-packager/src/Packager/Package.js +++ b/packager/react-packager/src/Packager/Package.js @@ -42,9 +42,11 @@ Package.prototype.finalize = function(options) { }; Package.prototype.getSource = function() { - return _.pluck(this._modules, 'transformedCode').join('\n') + '\n' + - 'RAW_SOURCE_MAP = ' + JSON.stringify(this.getSourceMap({excludeSource: true})) + ';\n' + - '\/\/@ sourceMappingURL=' + this._sourceMapUrl; + return this._source || ( + this._source = _.pluck(this._modules, 'transformedCode').join('\n') + '\n' + + 'RAW_SOURCE_MAP = ' + JSON.stringify(this.getSourceMap({excludeSource: true})) + + ';\n' + '\/\/@ sourceMappingURL=' + this._sourceMapUrl + ); }; Package.prototype.getSourceMap = function(options) { diff --git a/packager/react-packager/src/Packager/index.js b/packager/react-packager/src/Packager/index.js index 0cecca2e7..83af7c228 100644 --- a/packager/react-packager/src/Packager/index.js +++ b/packager/react-packager/src/Packager/index.js @@ -97,6 +97,10 @@ Packager.prototype.package = function(main, runModule, sourceMapUrl) { }); }; +Packager.prototype.invalidateFile = function(filePath){ + this._transformer.invalidateFile(filePath); +} + Packager.prototype._transformModule = function(module) { var resolver = this._resolver; return this._transformer.loadFileAndTransform( diff --git a/packager/react-packager/src/Server/__tests__/Server-test.js b/packager/react-packager/src/Server/__tests__/Server-test.js new file mode 100644 index 000000000..0482d4f29 --- /dev/null +++ b/packager/react-packager/src/Server/__tests__/Server-test.js @@ -0,0 +1,166 @@ + 'use strict'; + +jest.dontMock('worker-farm') + .dontMock('q') + .dontMock('os') + .dontMock('errno/custom') + .dontMock('path') + .dontMock('url') + .dontMock('../'); + + +var server = require('../'); +var q = require('q'); + +describe('processRequest', function(){ + var server; + var Activity; + var Packager; + var FileWatcher; + + var options = { + projectRoot: 'root', + blacklistRE: null, + cacheVersion: null, + polyfillModuleNames: null + }; + + var makeRequest = function(requestHandler, requrl){ + var deferred = q.defer(); + requestHandler({ + url: requrl + },{ + end: function(res){ + deferred.resolve(res); + } + },{ + next: function(){} + } + ); + return deferred.promise; + }; + + var invalidatorFunc = jest.genMockFunction(); + var watcherFunc = jest.genMockFunction(); + var requestHandler; + + beforeEach(function(){ + Activity = require('../../Activity'); + Packager = require('../../Packager'); + FileWatcher = require('../../FileWatcher') + + Packager.prototype.package = function(main, runModule, sourceMapUrl) { + return q({ + getSource: function(){ + return "this is the source" + }, + getSourceMap: function(){ + return "this is the source map" + } + }) + }; + FileWatcher.prototype.getWatcher = function() { + return q({ + on: watcherFunc + }); + }; + + Packager.prototype.invalidateFile = invalidatorFunc; + + var Server = require('../'); + server = new Server(options); + requestHandler = server.processRequest.bind(server); + }); + + pit('returns JS bundle source on request of *.bundle',function(){ + result = makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle'); + return result.then(function(response){ + expect(response).toEqual("this is the source"); + }); + }); + + pit('returns sourcemap on request of *.map', function(){ + result = makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle.map'); + return result.then(function(response){ + expect(response).toEqual('"this is the source map"'); + }); + }); + + pit('watches all files in projectRoot', function(){ + result = makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle'); + return result.then(function(response){ + expect(watcherFunc.mock.calls[0][0]).toEqual('all'); + expect(watcherFunc.mock.calls[0][1]).not.toBe(null); + }) + }); + + + describe('file changes', function() { + var triggerFileChange; + beforeEach(function() { + FileWatcher.prototype.getWatcher = function() { + return q({ + on: function(eventType, callback) { + if (eventType !== 'all') { + throw new Error('Can only handle "all" event in watcher.'); + } + triggerFileChange = callback; + return this; + } + }); + }; + }); + + pit('invalides files in package when file is updated', function() { + result = makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle'); + return result.then(function(response){ + var onFileChange = watcherFunc.mock.calls[0][1]; + onFileChange('all','path/file.js'); + expect(invalidatorFunc.mock.calls[0][0]).toEqual('root/path/file.js'); + }); + }); + + pit('rebuilds the packages that contain a file when that file is changed', function() { + var packageFunc = jest.genMockFunction(); + packageFunc + .mockReturnValueOnce( + q({ + getSource: function(){ + return "this is the first source" + }, + getSourceMap: function(){}, + }) + ) + .mockReturnValue( + q({ + getSource: function(){ + return "this is the rebuilt source" + }, + getSourceMap: function(){}, + }) + ); + + Packager.prototype.package = packageFunc; + + var Server = require('../../Server'); + var server = new Server(options); + + requestHandler = server.processRequest.bind(server); + + + return makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle') + .then(function(response){ + expect(response).toEqual("this is the first source"); + expect(packageFunc.mock.calls.length).toBe(1); + triggerFileChange('all','path/file.js'); + }) + .then(function(){ + expect(packageFunc.mock.calls.length).toBe(2); + return makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle') + .then(function(response){ + expect(response).toEqual("this is the rebuilt source"); + }); + }); + }); + }); +}); diff --git a/packager/react-packager/src/Server/index.js b/packager/react-packager/src/Server/index.js new file mode 100644 index 000000000..877b48ca7 --- /dev/null +++ b/packager/react-packager/src/Server/index.js @@ -0,0 +1,128 @@ +var url = require('url'); +var path = require('path'); +var FileWatcher = require('../FileWatcher') +var Packager = require('../Packager'); +var Activity = require('../Activity'); +var q = require('q'); + +module.exports = Server; + +function Server(options) { + this._projectRoot = options.projectRoot; + this._packages = Object.create(null); + this._packager = new Packager({ + projectRoot: options.projectRoot, + blacklistRE: options.blacklistRE, + polyfillModuleNames: options.polyfillModuleNames || [], + runtimeCode: options.runtimeCode, + cacheVersion: options.cacheVersion, + resetCache: options.resetCache, + dev: options.dev, + }); + + this._fileWatcher = new FileWatcher(options.projectRoot); + + var onFileChange = this._onFileChange.bind(this); + this._fileWatcher.getWatcher().done(function(watcher) { + watcher.on('all', onFileChange); + }); +} + +Server.prototype._onFileChange = function(type, filepath) { + var absPath = path.join(this._projectRoot, filepath); + this._packager.invalidateFile(absPath); + this._rebuildPackages(absPath); +}; + +Server.prototype._rebuildPackages = function(filepath) { + var buildPackage = this._buildPackage.bind(this); + var packages = this._packages; + Object.keys(packages).forEach(function(key) { + var options = getOptionsFromPath(url.parse(key).pathname); + packages[key] = buildPackage(options).then(function(p) { + // Make a throwaway call to getSource to cache the source string. + p.getSource(); + return p; + }); + }); +}; + +Server.prototype.kill = function() { + q.all([ + this._fileWatcher.end(), + this._packager.kill(), + ]); +}; + +Server.prototype._buildPackage = function(options) { + return this._packager.package( + options.main, + options.runModule, + options.sourceMapUrl + ); +}; + +Server.prototype.buildPackageFromUrl = function(reqUrl) { + var options = getOptionsFromPath(url.parse(reqUrl).pathname); + return this._buildPackage(options); +}; + +Server.prototype.processRequest = function(req, res, next) { + var requestType; + if (req.url.match(/\.bundle$/)) { + requestType = 'bundle'; + } else if (req.url.match(/\.map$/)) { + requestType = 'map'; + } else { + return next(); + } + + var startReqEventId = Activity.startEvent('request:' + req.url); + var options = getOptionsFromPath(url.parse(req.url).pathname); + var building = this._packages[req.url] || this._buildPackage(options) + this._packages[req.url] = building; + building.then( + function(p) { + if (requestType === 'bundle') { + res.end(p.getSource()); + Activity.endEvent(startReqEventId); + } else if (requestType === 'map') { + res.end(JSON.stringify(p.getSourceMap())); + Activity.endEvent(startReqEventId); + } + }, + function(error) { + handleError(res, error); + } + ).done(); +}; + +function getOptionsFromPath(pathname) { + var parts = pathname.split('.'); + // Remove the leading slash. + var main = parts[0].slice(1) + '.js'; + return { + runModule: parts.slice(1).some(function(part) { + return part === 'runModule'; + }), + main: main, + sourceMapUrl: parts.slice(0, -1).join('.') + '.map' + }; +} + +function handleError(res, error) { + res.writeHead(500, { + 'Content-Type': 'application/json; charset=UTF-8', + }); + + if (error.type === 'TransformError') { + res.end(JSON.stringify(error)); + } else { + console.error(error.stack || error); + res.end(JSON.stringify({ + type: 'InternalError', + message: 'react-packager has encountered an internal error, ' + + 'please check your terminal error output for more details', + })); + } +}