Always Trigger The $destroy Event Before Removing Elements In AngularJS Directives

If you're building custom AngularJS directives, you will inevitably find yourself in a situation where you have to clone elements, create new scopes, remove elements, and destroy old scopes. When you do this, the order of operations is very important. At first, it may not seem like an issue; but, over time, if the order of operations is incorrect, it can lead to unexpected behaviors and memory leaks.

Run this demo in my JavaScript Demos project on GitHub.

Ultimately, the order of operations in your AngularJS directive is important because of the way jQuery implements the .remove() method. When you remove an element from the DOM using .remove() or .empty(), jQuery will clear out the event bindings and the data associated with the element so as to avoid memory leaks. This means that if you remove the element before you trigger the "$destroy" event, the element will be in a "sanitized state" by the time your $destroy event handler is executed.

To see this in action, I have created a demo that defines two "if" directives, modeled on the old ui-if directive in Angular-UI. The two directives differ only in the order in which they remove the transcluded element and destroy the associated child scope. For each transcluded element, I am also using a directive which sets "data" and then attempts to read that data in the "$destroy" event handler.

<!doctype html> <html ng-app="Demo"> <head> <meta charset="utf-8" /> <title> Always Trigger The $destroy Event Before Removing Elements In AngularJS Directives </title> <style type="text/css"> a[ ng-click ] { cursor: pointer ; text-decoration: underline ; } </style> </head> <body ng-controller="AppController"> <h1> Always Trigger The $destroy Event Before Removing Elements In AngularJS Directives </h1> <p> <a ng-click="toggleContainer()">Toggle Container</a> </p> <p bn-bad-if="isShowingContainer"> <span bn-data-test="Bad">This is a bad directive test</span> </p> <p bn-good-if="isShowingContainer"> <span bn-data-test="Good">This is a good directive test</span> </p> <!-- Load scripts. --> <script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script> <script type="text/javascript" src="../../vendor/angularjs/angular-1.2.22.min.js"></script> <script type="text/javascript"> // Create an application module for our demo. var app = angular.module( "Demo", [] ); // -------------------------------------------------- // // -------------------------------------------------- // // I control the root of the application. app.controller( "AppController", function( $scope ) { $scope.isShowingContainer = false; // --- // PUBLIC METHODS. // --- // I show or hide the container, depending on its current state. $scope.toggleContainer = function() { $scope.isShowingContainer = ! $scope.isShowingContainer; }; } ); // -------------------------------------------------- // // -------------------------------------------------- // // I define jQuery data and then test to see if it's available in the $destroy // event of the scope. app.directive( "bnDataTest", function() { // Bind the JavaScript behaviors to the local scope. function link( scope, element, attributes ) { element.data( "test", "jQuery data is available." ); // When the destroy event is triggered, check to see if the above // data is still available. scope.$on( "$destroy", function handleDestroyEvent() { console.log( attributes.bnDataTest, ":", element.data( "test" ) ); } ); } // Return the directive configuration. return({ link: link, restrict: "A" }); } ); // -------------------------------------------------- // // -------------------------------------------------- // // I define a "bad" version of ng-if / ui-if. // -- // NOTE: This is, more or less, a copy of the old ui-if directive. app.directive( "bnBadIf", function() { // Bind the JavaScript behaviors to the local scope. function link( scope, element, attributes, _, transclude ) { // I keep track of the currently-injected element and its defined // scope. We need the injected element to have its own scope so we // can destroy it when we remove the element. var cloneElement = null; var cloneScope = null; // When the model changes, adjust the element existence. scope.$watch( attributes.bnBadIf, function handleWatchChange( newValue, oldValue ) { // If we have an existing item, remove it. if ( cloneElement ) { // *************************************************** // NOTE: We are removing the element BEFORE we are // destroying the scope associated with the element. // *************************************************** cloneElement.remove(); cloneElement = null; cloneScope.$destroy(); cloneScope = null; } // If the new value is truthy, inject the element. if ( newValue ) { cloneScope = scope.$new(); cloneElement = transclude( cloneScope, function injectClonedElement( clone ) { element.after( clone ); } ); } } ); } // Return the directive configuration. return({ link: link, priority: 1000, restrict: "A", terminal: true, transclude: "element" }); } ); // -------------------------------------------------- // // -------------------------------------------------- // // I define a "good" version of ng-if / ui-if. // -- // NOTE: This is, more or less, a copy of the old ui-if directive. app.directive( "bnGoodIf", function() { // Bind the JavaScript behaviors to the local scope. function link( scope, element, attributes, _, transclude ) { // I keep track of the currently-injected element and its defined // scope. We need the injected element to have its own scope so we // can destroy it when we remove the element. var cloneElement = null; var cloneScope = null; // When the model changes, adjust the element existence. scope.$watch( attributes.bnGoodIf, function handleWatchChange( newValue, oldValue ) { // If we have an existing item, remove it. if ( cloneElement ) { // *************************************************** // NOTE: We are removing the element AFTER we are // destroying the scope associated with the element. // *************************************************** cloneScope.$destroy(); cloneScope = null; cloneElement.remove(); cloneElement = null; } // If the new value is truthy, inject the element. if ( newValue ) { cloneScope = scope.$new(); cloneElement = transclude( cloneScope, function injectClonedElement( clone ) { element.after( clone ); } ); } } ); } // Return the directive configuration. return({ link: link, priority: 1000, restrict: "A", terminal: true, transclude: "element" }); } ); </script> </body> </html>

As you can see, the bnBadIf directive removes the element and then destroys the scope. The bnGoodIf directive, on the other hand, destroys the scope and then removes the element. When we run this code and toggle the container, we get the following console output:

Bad : undefined

Good : jQuery data is available.

As you can see, the bnBadIf loses access to the jQuery data in the context of the $destroy event handler.

Most of the time, you probably won't even notice that the order of operations matters. But, if you have a directive that applies a jQuery plugin to the DOM (Document Object Model), that's where things get interesting. Many jQuery plugins rely on the .data() being available. And, if you try to teardown your jQuery plugins in the $destroy event, the order of operations will either allow that to happen; or, it will lead to a slow and steady memory leak. As such, always make sure that you trigger the $destroy event before you actually remove the associated element from the DOM.

Tweet This Great article by @BenNadel - Always Trigger The $destroy Event Before Removing Elements In AngularJS Directives Woot woot — you rock the party that rocks the body!







