Skip to content
This repository was archived by the owner on Sep 5, 2024. It is now read-only.

Commit dc6b10c

Browse files
crisbetokara
authored andcommitted
perf(tooltip): reduce amount of event listeners on the window (#9514)
Until now, each tooltip used to register 3 events on the window (scroll, resize and blur), even if it isn't being displayed. These can really add up on a page with a lot of tooltips. This change introduces a service that keeps track of the event handlers and only registers 1 global handler on the window for each event. Referencing #9506.
1 parent ad82012 commit dc6b10c

File tree

2 files changed

+161
-25
lines changed

2 files changed

+161
-25
lines changed

src/components/tooltip/tooltip.js

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
* @name material.components.tooltip
44
*/
55
angular
6-
.module('material.components.tooltip', [ 'material.core' ])
7-
.directive('mdTooltip', MdTooltipDirective);
6+
.module('material.components.tooltip', [ 'material.core' ])
7+
.directive('mdTooltip', MdTooltipDirective)
8+
.service('$$mdTooltipRegistry', MdTooltipRegistry);
89

910
/**
1011
* @ngdoc directive
@@ -33,8 +34,8 @@ angular
3334
* @param {boolean=} md-autohide If present or provided with a boolean value, the tooltip will hide on mouse leave, regardless of focus
3435
* @param {string=} md-direction Which direction would you like the tooltip to go? Supports left, right, top, and bottom. Defaults to bottom.
3536
*/
36-
function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdTheming, $rootElement,
37-
$animate, $q, $interpolate) {
37+
function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdTheming, $animate,
38+
$interpolate, $$mdTooltipRegistry) {
3839

3940
var ENTER_EVENTS = 'focus touchstart mouseenter';
4041
var LEAVE_EVENTS = 'blur touchcancel mouseleave';
@@ -143,7 +144,7 @@ function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdThe
143144
// - In these cases the scope might not have been destroyed, which is why we
144145
// destroy it manually. An example of this can be having `md-visible="false"` and
145146
// adding tooltips while they're invisible. If `md-visible` becomes true, at some
146-
// point, you'd usually get a lot of inputs.
147+
// point, you'd usually get a lot of tooltips.
147148
// - We use `.one`, not `.on`, because this only needs to fire once. If we were
148149
// using `.on`, it would get thrown into an infinite loop.
149150
// - This kicks off the scope's `$destroy` event which finishes the cleanup.
@@ -202,21 +203,21 @@ function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdThe
202203
var windowBlurHandler = function() {
203204
elementFocusedOnWindowBlur = document.activeElement === parent[0];
204205
};
206+
205207
var elementFocusedOnWindowBlur = false;
206208

207209
function windowScrollHandler() {
208210
setVisible(false);
209211
}
210212

211-
angular.element($window)
212-
.on('blur', windowBlurHandler)
213-
.on('resize', debouncedOnResize);
213+
$$mdTooltipRegistry.register('scroll', windowScrollHandler, true);
214+
$$mdTooltipRegistry.register('blur', windowBlurHandler);
215+
$$mdTooltipRegistry.register('resize', debouncedOnResize);
214216

215-
document.addEventListener('scroll', windowScrollHandler, true);
216217
scope.$on('$destroy', function() {
217-
angular.element($window)
218-
.off('blur', windowBlurHandler)
219-
.off('resize', debouncedOnResize);
218+
$$mdTooltipRegistry.deregister('scroll', windowScrollHandler, true);
219+
$$mdTooltipRegistry.deregister('blur', windowBlurHandler);
220+
$$mdTooltipRegistry.deregister('resize', debouncedOnResize);
220221

221222
parent
222223
.off(ENTER_EVENTS, enterHandler)
@@ -225,7 +226,6 @@ function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdThe
225226

226227
// Trigger the handler in case any the tooltip was still visible.
227228
leaveHandler();
228-
document.removeEventListener('scroll', windowScrollHandler, true);
229229
attributeObserver && attributeObserver.disconnect();
230230
});
231231

@@ -248,6 +248,7 @@ function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdThe
248248
}
249249
}
250250
};
251+
251252
var leaveHandler = function () {
252253
var autohide = scope.hasOwnProperty('autohide') ? scope.autohide : attr.hasOwnProperty('mdAutohide');
253254

@@ -266,6 +267,7 @@ function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdThe
266267
}
267268
mouseActive = false;
268269
};
270+
269271
var mousedownHandler = function() {
270272
mouseActive = true;
271273
};
@@ -386,3 +388,78 @@ function MdTooltipDirective($timeout, $window, $$rAF, $document, $mdUtil, $mdThe
386388
}
387389

388390
}
391+
392+
/**
393+
* Service that is used to reduce the amount of listeners that are being
394+
* registered on the `window` by the tooltip component. Works by collecting
395+
* the individual event handlers and dispatching them from a global handler.
396+
*
397+
* @ngInject
398+
*/
399+
function MdTooltipRegistry() {
400+
var listeners = {};
401+
var ngWindow = angular.element(window);
402+
403+
return {
404+
register: register,
405+
deregister: deregister
406+
};
407+
408+
/**
409+
* Global event handler that dispatches the registered
410+
* handlers in the service.
411+
* @param {Event} event Event object passed in by the browser.
412+
*/
413+
function globalEventHandler(event) {
414+
if (listeners[event.type]) {
415+
listeners[event.type].forEach(function(currentHandler) {
416+
currentHandler.call(this, event);
417+
}, this);
418+
}
419+
}
420+
421+
/**
422+
* Registers a new handler with the service.
423+
* @param {String} type Type of event to be registered.
424+
* @param {Function} handler Event handler
425+
* @param {Boolean} useCapture Whether to use event capturing.
426+
*/
427+
function register(type, handler, useCapture) {
428+
var array = listeners[type] = listeners[type] || [];
429+
430+
if (!array.length) {
431+
if (useCapture) {
432+
window.addEventListener(type, globalEventHandler, true);
433+
} else {
434+
ngWindow.on(type, globalEventHandler);
435+
}
436+
}
437+
438+
if (array.indexOf(handler) === -1) {
439+
array.push(handler);
440+
}
441+
}
442+
443+
/**
444+
* Removes an event handler from the service.
445+
* @param {String} type Type of event handler.
446+
* @param {Function} handler The event handler itself.
447+
* @param {Boolean} useCapture Whether the event handler used event capturing.
448+
*/
449+
function deregister(type, handler, useCapture) {
450+
var array = listeners[type];
451+
var index = array ? array.indexOf(handler) : -1;
452+
453+
if (index > -1) {
454+
array.splice(index, 1);
455+
456+
if (array.length === 0) {
457+
if (useCapture) {
458+
window.removeEventListener(type, globalEventHandler, true);
459+
} else {
460+
ngWindow.off(type, globalEventHandler);
461+
}
462+
}
463+
}
464+
}
465+
}

src/components/tooltip/tooltip.spec.js

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
describe('<md-tooltip> directive', function() {
2-
var $compile, $rootScope, $material, $timeout;
2+
var $compile, $rootScope, $material, $timeout, $$mdTooltipRegistry;
33
var element;
44

55
beforeEach(module('material.components.tooltip'));
66
beforeEach(module('material.components.button'));
7-
beforeEach(inject(function(_$compile_, _$rootScope_, _$material_, _$timeout_){
7+
beforeEach(inject(function(_$compile_, _$rootScope_, _$material_, _$timeout_, _$$mdTooltipRegistry_){
88
$compile = _$compile_;
99
$rootScope = _$rootScope_;
1010
$material = _$material_;
1111
$timeout = _$timeout_;
12+
$$mdTooltipRegistry = _$$mdTooltipRegistry_;
1213
}));
1314
afterEach(function() {
1415
// Make sure to remove/cleanup after each test
@@ -18,20 +19,14 @@ describe('<md-tooltip> directive', function() {
1819
});
1920

2021
it('should support dynamic directions', function() {
21-
var error;
22-
23-
try {
22+
expect(function() {
2423
buildTooltip(
2524
'<md-button>' +
26-
'Hello' +
27-
'<md-tooltip md-direction="{{direction}}">Tooltip</md-tooltip>' +
25+
'Hello' +
26+
'<md-tooltip md-direction="{{direction}}">Tooltip</md-tooltip>' +
2827
'</md-button>'
2928
);
30-
} catch(e) {
31-
error = e;
32-
}
33-
34-
expect(error).toBe(undefined);
29+
}).not.toThrow();
3530
});
3631

3732
it('should set the position to "bottom", if it is undefined', function() {
@@ -140,6 +135,18 @@ describe('<md-tooltip> directive', function() {
140135

141136
});
142137

138+
it('should register itself with the $$mdTooltipRegistry', function() {
139+
spyOn($$mdTooltipRegistry, 'register');
140+
141+
buildTooltip(
142+
'<md-button>' +
143+
'<md-tooltip>Tooltip</md-tooltip>' +
144+
'</md-button>'
145+
);
146+
147+
expect($$mdTooltipRegistry.register).toHaveBeenCalled();
148+
});
149+
143150
describe('show and hide', function() {
144151

145152
it('should show and hide when visible is set', function() {
@@ -370,6 +377,18 @@ describe('<md-tooltip> directive', function() {
370377
expect(findTooltip().length).toBe(0);
371378
});
372379

380+
it('should remove itself from the $$mdTooltipRegistry when it is destroyed', function() {
381+
buildTooltip(
382+
'<md-button>' +
383+
'<md-tooltip md-visible="true">Tooltip</md-tooltip>' +
384+
'</md-button>'
385+
);
386+
387+
spyOn($$mdTooltipRegistry, 'deregister');
388+
findTooltip().scope().$destroy();
389+
expect($$mdTooltipRegistry.deregister).toHaveBeenCalled();
390+
});
391+
373392
it('should not re-appear if it was outside the DOM when the parent was removed', function() {
374393
buildTooltip(
375394
'<md-button>' +
@@ -436,3 +455,43 @@ describe('<md-tooltip> directive', function() {
436455
}
437456

438457
});
458+
459+
describe('$$mdTooltipRegistry service', function() {
460+
var tooltipRegistry, ngWindow;
461+
462+
beforeEach(function() {
463+
module('material.components.tooltip');
464+
465+
inject(function($$mdTooltipRegistry, $window) {
466+
tooltipRegistry = $$mdTooltipRegistry;
467+
ngWindow = angular.element($window);
468+
});
469+
});
470+
471+
it('should allow for registering event handlers on the window', function() {
472+
var obj = { callback: function() {} };
473+
474+
spyOn(obj, 'callback');
475+
tooltipRegistry.register('resize', obj.callback);
476+
ngWindow.triggerHandler('resize');
477+
478+
// check that the callback was triggered
479+
expect(obj.callback).toHaveBeenCalled();
480+
481+
// check that the event object was passed
482+
expect(obj.callback.calls.mostRecent().args[0]).toBeTruthy();
483+
});
484+
485+
it('should allow deregistering of the callbacks', function() {
486+
var obj = { callback: function() {} };
487+
488+
spyOn(obj, 'callback');
489+
tooltipRegistry.register('resize', obj.callback);
490+
ngWindow.triggerHandler('resize');
491+
expect(obj.callback).toHaveBeenCalledTimes(1);
492+
493+
tooltipRegistry.deregister('resize', obj.callback);
494+
ngWindow.triggerHandler('resize');
495+
expect(obj.callback).toHaveBeenCalledTimes(1);
496+
});
497+
});

0 commit comments

Comments
 (0)