react-native/Libraries/Components/Subscribable.js

334 lines
11 KiB
JavaScript

/**
* Copyright 2004-present Facebook. All Rights Reserved.
*
* @providesModule Subscribable
*/
'use strict';
/**
* 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 (
* <Text>
* {this.state.isConnected ? 'Network Connected' : 'No network'}
* </Text>
* <Text onPress={() => this._reachSubscription.remove()}>
* End reachability subscription
* </Text>
* );
* }
* });
* ```
*/
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);
this._upstreamSubscription = 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;
}
/**
* Unsubscribe from the upstream EventEmitter
*/
cleanup() {
this._upstreamSubscription && this._upstreamSubscription.remove();
}
/**
* 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
);
},
/**
* Gets a Subscribable store, scoped to the component, that can be passed to
* children. The component will automatically clean up the subscribable's
* subscription to the eventEmitter when unmounting.
*
* `provideSubscribable` will always return the same Subscribable for any
* particular emitter/eventName combo, so it can be called directly from
* render, and it will never create duplicate Subscribables.
*
* @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.
*/
provideSubscribable: function(eventEmitter, eventName, eventMapping, getInitData) {
this._localSubscribables = this._localSubscribables || {};
this._localSubscribables[eventEmitter] =
this._localSubscribables[eventEmitter] || {};
if (!this._localSubscribables[eventEmitter][eventName]) {
this._localSubscribables[eventEmitter][eventName] =
new Subscribable(eventEmitter, eventName, eventMapping, getInitData);
}
return this._localSubscribables[eventEmitter][eventName];
},
/**
* Removes any local Subscribables created with `provideSubscribable`, so the
* component can unmount without leaving any dangling listeners on
* eventEmitters
*/
_cleanupLocalSubscribables: function() {
if (!this._localSubscribables) {
return;
}
var emitterSubscribables;
Object.keys(this._localSubscribables).forEach((eventEmitter) => {
emitterSubscribables = this._localSubscribables[eventEmitter];
Object.keys(emitterSubscribables).forEach((eventName) => {
emitterSubscribables[eventName].cleanup();
});
});
this._localSubscribables = null;
},
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();
this._cleanupLocalSubscribables();
// 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)
);
}
};
module.exports = Subscribable;