Skip to main content
Ben Nadel at NCDevCon 2011 (Raleigh, NC) with: Jaana Gilbert and Brenda Priest and Jim Priest
Ben Nadel at NCDevCon 2011 (Raleigh, NC) with: Jaana Gilbert Brenda Priest Jim Priest

Forced Repaints In Directive Can Cause Accidental Scrolling In AngularJS

By
Published in Comments (5)

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.

Want to use code from this post? Check out the license.

Reader Comments

1 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.

15,902 Comments

@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.

1 Comments

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.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel