Forced Repaints In Directive Can Cause Accidental Scrolling In AngularJS

The other day, I started getting an odd behavior in an AngularJS application that had a tabbed interface. Normally, with a tabbed interface, when the user switches from tab to tab, the scroll offset of the browser should remain the same. And, this is how our application was working. But then suddenly, we started seeing a "scroll-to-top" behavior when the active tab pane was switched. After an hour of ripping code out of the app, I finally figured out what it was - a newly-added directive was forcing a browser repaint which was accidentally causing the browser to scroll up.

If you remember from my blog post on CSS class transisions and timing, the browser optimizes rendering by grouping multiple UI (User Interface) changes into a single repaint (when possible). However, if you ask for UI deminsions in the middle of a series UI mutations, the browser is forced to repaint in order to query the most accurate dimensions from the DOM (Document Object Model).

This is basically what was happening in our AngularJS application. A newly-added directive was querying for DOM dimensions when a tab pane was activated. This forced the browser to repaint before AngularJS had a chance to render the content of the activated tab pane. And, since the tab pane had no content, it reduced the height of the document body, which caused the window to scroll up to the new content height.

Of course, the tab pane was rendered in the next event loop, leaving the change in content height too fast to be noticed by the naked eye. That's what made debugging this so irksome.

Anyway, to see this in action take a look at the video above which demonstrates the code below. In this demo, we have a directive that queries the tab pane width during the linking phase. This executes before the nested ngRepeat directive has a chance to respond to the model.

NOTE: I believe this happens because the $watch() expression used to monitor the ngRepeat collection is invoked asynchronously (as are all $watch() expressions).

<!doctype html> <html ng-app="Demo" ng-controller="DemoController"> <head> <meta charset="utf-8" /> <title> Forced Repaints In Directive Can Cause Accidental Scrolling In AngularJS </title> </head> <body> <h1> Forced Repaints In Directive Can Cause Accidental Scrolling In AngularJS </h1> <!-- BEGIN: Tabbed Interface. --> <div class="tabbed"> <div class="tabs"> <a ng-click="showTab( 'A' )">Show Tab A</a> <a ng-click="showTab( 'B' )">Show Tab B</a> </div> <hr /> <!-- BEGIN: Tab Panes. --> <div class="panes" ng-switch="activePane"> <!-- Tab with "helper" directive. --> <div ng-switch-when="A" bn-tab-helper> <h3> Pane A </h3> <ol> <li ng-repeat="friend in friends"> {{ friend }} </li> </ol> </div> <!-- Tab with "helper" directive. --> <div ng-switch-when="B" bn-tab-helper> <h3> Pane B </h3> <ul> <li ng-repeat="friend in friends"> {{ friend }} </li> </ul> </div> </div> <!-- END: Tab Panes. --> </div> <!-- END: Tabbed Interface. --> <!-- Load jQuery and AngularJS from the CDN. --> <script type="text/javascript" src="//code.jquery.com/jquery-2.0.0.min.js"> </script> <script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js"> </script> <script type="text/javascript"> // Create an application module for our demo. var Demo = angular.module( "Demo", [] ); // -------------------------------------------------- // // -------------------------------------------------- // // I am the controller for Demo. Demo.controller( "DemoController", function( $scope ) { // I define the active tab pane. $scope.showTab = function( whichTab ) { $scope.activePane = whichTab; }; // I hold the active tab pane. $scope.activePane = "A"; // I am the list of friends to render. $scope.friends = [ "Sarah", "Joanna", "Kim", "Lisa", "Tricia", "Anna", "Francis", "Rebbecca", "Nicole", "Kit", "Pam", "Christina", "Sonia", "Alex" ]; } ); // -------------------------------------------------- // // -------------------------------------------------- // // I force a repaint in the link phase (accidentally). Demo.directive( "bnTabHelper", function( $timeout ) { // I bind the DOM events to the scope. function link( $scope, element, attributes ) { // Getting the width of the element forces the // browser to repaint the UI; this, accidentally // causes the window to scroll to the top because // the repaint happens BEFORE the nested list has // a chance to react to the changing model in // the ngRepeat directive. console.log( "Pane Width:", element.width() ); var list = element.find( "ol, ul" ); // Show the currently-rendered friends. console.log( "Friends:", list.children() ); // Show the list on next tick. $timeout( function() { console.log( "Timeout:", list.children() ); } ); } // Return the directive configuration. return({ link: link, restrict: "A" }); } ); </script> </body> </html>

To get around this, we ended up putting the DOM-querying method behind a $timeout(). This allowed the tab pane content to render before its dimensions were calculated. This kept the content height consistent which allowed the scroll offset of the window to remain the same.

This isn't a bug in AngularJS; it's just an interesting interaction of browser optimizations, the DOM, directives, and the AngularJS digest lifecycle. You just need to know why it's happening so you can debug it when it happens to you.

Tweet This Titillating read by @BenNadel - Forced Repaints In Directive Can Cause Accidental Scrolling In AngularJS Woot woot — you rock the party that rocks the body!







