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',
+ }));
+ }
+}