angular-redux / ng-redux

Angular bindings for Redux
MIT License
1.16k stars 177 forks source link

Unsubscribe automatically $onDestroy #194

Open nico1510 opened 6 years ago

nico1510 commented 6 years ago

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 ?

AntJanus commented 6 years ago

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.

AprilArcus commented 2 years ago

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;
      }
    ]
  );