Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
I am the chief technical officer at InVision App, Inc - a prototyping and collaboration platform for designers, built by designers. I also rock out in JavaScript and ColdFusion 24x7.
Meanwhile on Twitter
Loading latest tweet...
Ben Nadel at RIA Unleashed (Nov. 2009) with:

Forced Repaints In Directive Can Cause Accidental Scrolling In AngularJS

By Ben Nadel on

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.




Reader Comments

console.log( "Pane Width:", element.width() );

TypeError: Object [object Object] has no method 'width'

Did i miss something?
Are You using jQuery in addition?

Cheers.

Reply to this Comment

@Daniel,

That's really odd! I am using jQuery, but it's being loaded from the remote CDN (right before the main Script tag). Perhaps the CDN was down when you ran the code? It should definitely be supported.

Reply to this Comment

Hey, thanks for the post. Lead me to finding a very similar bug. Strangely, mine was in the CSS of a package I had included. They were using a "bugfix" that used

  • from {
  • -webkit-transform: translate3d(0, 0, 0); }
  •  
  • to {
  • -webkit-transform: translate3d(0, 0, 0); } }

and this made the page jump up. I imagine it was causing a repaint. I changed it to padding: 0 to padding: 0 as suggested here: http://css-tricks.com/webkit-sibling-bug/ and my scrolling bug was fixed. Bizzare.

Reply to this Comment

Bingo! I had the exact same problem with a directive causing issues with re-painting.

Thanks for the writeup and fix.

Reply to this Comment

I had a similar issue with repainting in a directive, and your post led me to the fix. Thanks for writing!

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.