Unsubscribe automatically $onDestroy
nico1510 opened this issue · 2 comments
Just a quick question/RFC:
If you look at the react-redux source code you see that they unsubscribe from the store when the component is unmounted (https://github.com/reactjs/react-redux/blob/master/src/components/connectAdvanced.js#L168). The user doesn't have to remember to always include the unsubscribe when the component is destroyed. Would that be possible for angularjs too ? Like adding something like the following:
target.$onDestory = (() => {
unsubscribe();
target.$onDestory();
})();
to https://github.com/angular-redux/ng-redux/blob/master/src/components/connector.js#L58 ?
Automatic unsubscribe isn't possible but AngularJS does have a component/scope lifecycle that you can hook into via $scope.$on('$destroy', () => {})
if you're using AngularJS components, you can add a method called $onDestroy
which will run when component is unmounted.
So why can't this be done automatically? because a user doesn't have to connect to the actual component!
You can bind to $scope
(which has the $on
method), you can bind to the component (via this
), or you can bind to a random object if you want to.
If you are targeting browsers that support FinalizationRegistry
, you can decorate $ngRedux
like so:
import ngRedux from 'angular-redux';
angular.module(ngRedux)
.decorator('$ngRedux', ['$delegate', $ngRedux => {
const registry = new FinalizationRegistry(unsubscribe => {
unsubscribe();
});
const connect = $ngRedux.connect;
$ngRedux.connect = function connect (...connectArgs) {
const connector = connect.apply(this, connectArgs);
return (...connectorArgs) => {
const unsubscribe = connector(...connectorArgs);
const [target] = connectorArgs;
registry.register(target, unsubscribe);
return unsubscribe;
};
};
return $ngRedux;
}]);
if not, you can try this eldritch horror (requires Map
from es6, or a polyfill):
angular.module('ngRedux')
.decorator(
'$ngRedux',
[
'$delegate',
'$rootScope',
'$log',
function ($ngRedux, $rootScope, $log) {
var targets = new Map()
, Scope = $rootScope.constructor
, connect = $ngRedux.connect;
function unsubscribeAll (target) {
var targetInfo = targets.get(target)
, subscriptions = targetInfo.subscriptions
, destructorDescriptor = targetInfo.destructorDescriptor
, i
, unsubscribe;
for (i = 0; i < subscriptions.length; i++) {
unsubscribe = subscriptions[i];
unsubscribe();
}
// attempt to leave the target in pristine condition, in case
// the target's lifetime is not actually managed by AngularJS.
if (destructorDescriptor) {
Object.defineProperty(target, '$onDestroy', destructorDescriptor);
} else {
delete target.$onDestroy;
}
targets.delete(target);
}
$ngRedux.connect = function connect () {
var connector = connect.apply(this, arguments);
return function unsubscribe () {
var target = arguments[0]
, unsubscribe = connector.apply(undefined, arguments)
, destructorDescriptor
, superDestructor;
if (target instanceof Scope) {
// This is the simple case. If the target is a Scope, we can
// individually register each subscription with the Scope's
// own lifecycle management machinery.
target.$on('$destroy', unsubscribe);
} else if ('$on' in target) {
throw new Error(
'Passed an AngularJS scope to ngRedux that did not ' +
'belong to the same AngularJS injector that owns ngRedux. ' +
'You are probably in deep trouble.'
);
} else {
// Assume `target` is an AngularJS viewmodel instance.
// Unfortunately, there is no inheritance chain we can check
// to be sure one way or the other. We'll define an
// $onDestroy() hook that AngularJS will see, if it's
// looking, and warn during application teardown if anything
// leaks through.
if (!target.constructor || target.constructor === Object) {
$log.warn(
'Passed a plain object to ngRedux. ngRedux technically ' +
'supports passing any object as a target. Cannot automatically, ' +
'unlisten. Please remember to unsubscribe at the end of the ' +
"target object's lifetime."
);
}
if (targets.has(target)) {
// we've already seen this target, so we don't need to
// redo our monkeypatch. It's sufficient to register the
// additional listener and move on.
targets.get(target).subscriptions.push(unsubscribe);
} else {
// cache the ownProperty descriptor of the '$onDestroy'
// property we are about to monkeypatch (if it exists). If
// the target is not actually an AngularJS viewmodel
// instance, it will outlive the rootScope, and we will
// use this cached descriptor to restore the target to its
// original state.
//
// It would be pretty weird for an object which is not an
// AngularJS viewmodel to have an '$onDestroy' ownProperty,
// and a developer who passed such an object as a target to
// `ngRedux.connect(...)(target)` would certainly be asking
// for trouble, but the possibility is well within the
// realm of imagination, and we must be as defensive as
// possible when mutating objects of unknown provenance.
destructorDescriptor = Object.getOwnPropertyDescriptor(
target,
'$onDestroy'
);
if (destructorDescriptor && !destructorDescriptor.configurable) {
throw new Error(
'Passed a target to ngRedux with a non-configurable ' +
'`$onDestroy` property. This is a pretty strange thing to ' +
'do. If you are procedurally setting $onDestroy on an ' +
'AngularJS viewmodel instance, you should use a normal ' +
'assignment (`=`) operator. If you need to use ' +
'`Object.defineProperty()` for metaprogramming, set ' +
'`configurable: true` on your property descriptor object.'
);
}
targets.set(target, {
subscriptions: [unsubscribe],
destructorDescriptor: destructorDescriptor
});
// retrieve from the prototype chain
superDestructor = target.$onDestroy
Object.defineProperty(target, '$onDestroy', {
// we may need to delete this later in `unsubscribeAll()`
configurable: true,
// compose the original destructor, if one
// exists, and unsubscribe.
get() {
return function $onDestroy () {
var targetInfo = targets.get(this)
, destructorDescriptor = targetInfo.destructorDescriptor
, destructor = destructorDescriptor
? Object.prototype.hasOwnProperty.call(destructorDescriptor, 'value')
? destructorDescriptor.value
: destructorDescriptor.get.call(this)
: superDestructor;
// Allow an ownProperty to override the method we
// retrieved from the prototype chain, if both exist.
if (destructor) {
destructor.call(this);
}
unsubscribeAll(this);
}
},
// It is very important that we implement this as a
// getter/setter pair, so that we can handle the case
// where `$onDestroy()` is procedurally defined after
// a call to `ngRedux.connect()` in the body of an
// old-school constructor function style controller
// definition, e.g.:
//
// function FooController($ngRedux) {
// $ngRedux.connect(...)(this);
// // don't want to accidentally overwrite the secret
// // $onDestory() handler that the monkeypatched
// // ngRedux.connect() applied, want to compose it!
// this.$onDestroy = function () { ... };
// }
//
// We handle this by writing the incoming $onDestroy()
// method into the destructorDescription, where it can
// be ready out in the getter and composed with our
// unsubscribe logic (see above).
set (value) {
var targetInfo = targets.get(target)
, destructorDescriptor = targetInfo.destructorDescriptor;
if (destructorDescriptor) {
// Why would $onDestroy() ever be a setter?
// Well, we made it one here, and we might need
// to compose with someone else who has the same
// bright idea. Pedantry is of the essence.
if (destructorDescriptor.set) {
destructorDescriptor.set.call(this, value);
} else {
destructorDescriptor.value = value;
}
} else {
// Build a novel destructorDescriptor,
// imitating normal `=` assignment
targetInfo.destructorDescriptor = {
value: value,
writable: true,
enumerable: true,
configurable: true,
};
}
},
})
}
}
return unsubscribe;
}
};
// It's virtually certain that nobody would intentionally use
// $ngRedux and intend for it to outlive the AngularJS injector in
// which $ngRedux is itself hosted. Even if a developer using
// $ngRedux inexplicably evades all earlier attempts to detect and
// avert misuse, we can still clean up leaked subscriptions at the
// end of the injector's lifecycle.
$rootScope.$on('$destroy', function () {
var allTargets = targets.keys()
, i
, target;
if (targets.size > 0) {
$log.error('ngRedux: automatic subscription cleanup failed.');
$log.info(
'Hint: did you manually instantiate an AngularJS controller, ' +
'but forget to call $onDestroy() when you were done with ' +
'it? Did you remember to close all modals and await ' +
'their animations?'
);
for (i = 0; i < allTargets.length; i++) {
target = allTargets[0];
unsubscribeAll(target);
}
}
});
return $ngRedux;
}
]
);