Skip to content

Commit 313bdce

Browse files
jelbournalxhub
authored andcommitted
feat(platform-browser): allow lazy-loading HammerJS (#23906)
PR Close #23906
1 parent 5cf82f8 commit 313bdce

File tree

2 files changed

+185
-20
lines changed

2 files changed

+185
-20
lines changed

packages/platform-browser/src/dom/events/hammer_gestures.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Inject, Injectable, InjectionToken, ɵConsole as Console} from '@angular/core';
9+
import {Inject, Injectable, InjectionToken, Optional, ɵConsole as Console} from '@angular/core';
1010

1111
import {DOCUMENT} from '../dom_tokens';
1212

@@ -58,6 +58,13 @@ const EVENT_NAMES = {
5858
*/
5959
export const HAMMER_GESTURE_CONFIG = new InjectionToken<HammerGestureConfig>('HammerGestureConfig');
6060

61+
62+
/** Function that loads HammerJS, returning a promise that is resolved once HammerJs is loaded. */
63+
export type HammerLoader = (() => Promise<void>) | null;
64+
65+
/** Injection token used to provide a {@link HammerLoader} to Angular. */
66+
export const HAMMER_LOADER = new InjectionToken<HammerLoader>('HammerLoader');
67+
6168
export interface HammerInstance {
6269
on(eventName: string, callback?: Function): void;
6370
off(eventName: string, callback?: Function): void;
@@ -99,8 +106,8 @@ export class HammerGestureConfig {
99106
export class HammerGesturesPlugin extends EventManagerPlugin {
100107
constructor(
101108
@Inject(DOCUMENT) doc: any,
102-
@Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig,
103-
private console: Console) {
109+
@Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, private console: Console,
110+
@Optional() @Inject(HAMMER_LOADER) private loader?: HammerLoader) {
104111
super(doc);
105112
}
106113

@@ -109,8 +116,10 @@ export class HammerGesturesPlugin extends EventManagerPlugin {
109116
return false;
110117
}
111118

112-
if (!(window as any).Hammer) {
113-
this.console.warn(`Hammer.js is not loaded, can not bind '${eventName}' event.`);
119+
if (!(window as any).Hammer && !this.loader) {
120+
this.console.warn(
121+
`The "${eventName}" event cannot be bound because Hammer.JS is not ` +
122+
`loaded and no custom loader has been specified.`);
114123
return false;
115124
}
116125

@@ -121,6 +130,44 @@ export class HammerGesturesPlugin extends EventManagerPlugin {
121130
const zone = this.manager.getZone();
122131
eventName = eventName.toLowerCase();
123132

133+
// If Hammer is not present but a loader is specified, we defer adding the event listener
134+
// until Hammer is loaded.
135+
if (!(window as any).Hammer && this.loader) {
136+
// This `addEventListener` method returns a function to remove the added listener.
137+
// Until Hammer is loaded, the returned function needs to *cancel* the registration rather
138+
// than remove anything.
139+
let cancelRegistration = false;
140+
let deregister: Function = () => { cancelRegistration = true; };
141+
142+
this.loader()
143+
.then(() => {
144+
// If Hammer isn't actually loaded when the custom loader resolves, give up.
145+
if (!(window as any).Hammer) {
146+
this.console.warn(
147+
`The custom HAMMER_LOADER completed, but Hammer.JS is not present.`);
148+
deregister = () => {};
149+
return;
150+
}
151+
152+
if (!cancelRegistration) {
153+
// Now that Hammer is loaded and the listener is being loaded for real,
154+
// the deregistration function changes from canceling registration to removal.
155+
deregister = this.addEventListener(element, eventName, handler);
156+
}
157+
})
158+
.catch(() => {
159+
this.console.warn(
160+
`The "${eventName}" event cannot be bound because the custom ` +
161+
`Hammer.JS loader failed.`);
162+
deregister = () => {};
163+
});
164+
165+
// Return a function that *executes* `deregister` (and not `deregister` itself) so that we
166+
// can change the behavior of `deregister` once the listener is added. Using a closure in
167+
// this way allows us to avoid any additional data structures to track listener removal.
168+
return () => { deregister(); };
169+
}
170+
124171
return zone.runOutsideAngular(() => {
125172
// Creating the manager bind events, must be done outside of angular
126173
const mc = this._config.buildHammer(element);

packages/platform-browser/test/dom/events/hammer_gestures_spec.ts

Lines changed: 133 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,150 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import {describe, expect, it} from '@angular/core/testing/src/testing_internal';
9-
import {HammerGestureConfig, HammerGesturesPlugin} from '@angular/platform-browser/src/dom/events/hammer_gestures';
8+
import {NgZone} from '@angular/core';
9+
import {fakeAsync, inject, tick} from '@angular/core/testing';
10+
import {afterEach, beforeEach, describe, expect, it,} from '@angular/core/testing/src/testing_internal';
11+
import {EventManager} from '@angular/platform-browser';
12+
import {HammerGestureConfig, HammerGesturesPlugin,} from '@angular/platform-browser/src/dom/events/hammer_gestures';
1013

1114
{
1215
describe('HammerGesturesPlugin', () => {
1316
let plugin: HammerGesturesPlugin;
14-
let mockConsole: any;
17+
let fakeConsole: any;
1518
if (isNode) return;
1619

17-
beforeEach(() => {
18-
mockConsole = {warn: () => {}};
19-
plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), mockConsole);
20-
});
20+
beforeEach(() => { fakeConsole = {warn: jasmine.createSpy('console.warn')}; });
21+
22+
describe('with no custom loader', () => {
23+
beforeEach(() => {
24+
plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), fakeConsole);
25+
});
2126

22-
it('should implement addGlobalEventListener', () => {
23-
spyOn(plugin, 'addEventListener').and.callFake(() => {});
27+
it('should implement addGlobalEventListener', () => {
28+
spyOn(plugin, 'addEventListener').and.callFake(() => {});
2429

25-
expect(() => plugin.addGlobalEventListener('document', 'swipe', () => {})).not.toThrowError();
30+
expect(() => {
31+
plugin.addGlobalEventListener('document', 'swipe', () => {});
32+
}).not.toThrowError();
33+
});
34+
35+
it('should warn user and do nothing when Hammer.js not loaded', () => {
36+
expect(plugin.supports('swipe')).toBe(false);
37+
expect(fakeConsole.warn)
38+
.toHaveBeenCalledWith(
39+
`The "swipe" event cannot be bound because Hammer.JS is not ` +
40+
`loaded and no custom loader has been specified.`);
41+
});
2642
});
2743

28-
it('should warn user and do nothing when Hammer.js not loaeded', () => {
29-
spyOn(mockConsole, 'warn');
44+
describe('with a custom loader', () => {
45+
// Use a fake custom loader for tests, with helper functions to resolve or reject.
46+
let loader: () => Promise<void>;
47+
let resolveLoader: () => void;
48+
let failLoader: () => void;
49+
50+
// Arbitrary element and listener for testing.
51+
let someElement: HTMLDivElement;
52+
let someListener: () => void;
53+
54+
// Keep track of whatever value is in `window.Hammer` before the test so it can be
55+
// restored afterwards so that this test doesn't care whether Hammer is actually loaded.
56+
let originalHammerGlobal: any;
57+
58+
// Fake Hammer instance ("mc") used to test the underlying event registration.
59+
let fakeHammerInstance: {on: () => void, off: () => void};
60+
61+
// Inject the NgZone so that we can make it available to the plugin through a fake
62+
// EventManager.
63+
let ngZone: NgZone;
64+
beforeEach(inject([NgZone], (z: NgZone) => { ngZone = z; }));
65+
66+
beforeEach(() => {
67+
originalHammerGlobal = (window as any).Hammer;
68+
(window as any).Hammer = undefined;
69+
70+
fakeHammerInstance = {
71+
on: jasmine.createSpy('mc.on'),
72+
off: jasmine.createSpy('mc.off'),
73+
};
74+
75+
loader = () => new Promise((resolve, reject) => {
76+
resolveLoader = resolve;
77+
failLoader = reject;
78+
});
79+
80+
// Make the hammer config return a fake hammer instance
81+
const hammerConfig = new HammerGestureConfig();
82+
spyOn(hammerConfig, 'buildHammer').and.returnValue(fakeHammerInstance);
83+
84+
plugin = new HammerGesturesPlugin(document, hammerConfig, fakeConsole, loader);
85+
86+
// Use a fake EventManager that has access to the NgZone.
87+
plugin.manager = { getZone: () => ngZone } as EventManager;
88+
89+
someElement = document.createElement('div');
90+
someListener = () => {};
91+
});
92+
93+
afterEach(() => { (window as any).Hammer = originalHammerGlobal; });
94+
95+
it('should not log a warning when HammerJS is not loaded', () => {
96+
plugin.addEventListener(someElement, 'swipe', () => {});
97+
expect(fakeConsole.warn).not.toHaveBeenCalled();
98+
});
99+
100+
it('should defer registering an event until Hammer is loaded', fakeAsync(() => {
101+
plugin.addEventListener(someElement, 'swipe', someListener);
102+
expect(fakeHammerInstance.on).not.toHaveBeenCalled();
103+
104+
(window as any).Hammer = {};
105+
resolveLoader();
106+
tick();
107+
108+
expect(fakeHammerInstance.on).toHaveBeenCalledWith('swipe', jasmine.any(Function));
109+
}));
110+
111+
it('should cancel registration if an event is removed before being added', fakeAsync(() => {
112+
const deregister = plugin.addEventListener(someElement, 'swipe', someListener);
113+
deregister();
114+
115+
(window as any).Hammer = {};
116+
resolveLoader();
117+
tick();
118+
119+
expect(fakeHammerInstance.on).not.toHaveBeenCalled();
120+
}));
121+
122+
it('should remove a listener after Hammer is loaded', fakeAsync(() => {
123+
const removeListener = plugin.addEventListener(someElement, 'swipe', someListener);
124+
125+
(window as any).Hammer = {};
126+
resolveLoader();
127+
tick();
128+
129+
removeListener();
130+
expect(fakeHammerInstance.off).toHaveBeenCalledWith('swipe', jasmine.any(Function));
131+
}));
132+
133+
it('should log a warning when the loader fails', fakeAsync(() => {
134+
plugin.addEventListener(someElement, 'swipe', () => {});
135+
failLoader();
136+
tick();
137+
138+
expect(fakeConsole.warn)
139+
.toHaveBeenCalledWith(
140+
`The "swipe" event cannot be bound because the custom Hammer.JS loader failed.`);
141+
}));
142+
143+
it('should load a warning if the loader resolves and Hammer is not present', fakeAsync(() => {
144+
plugin.addEventListener(someElement, 'swipe', () => {});
145+
resolveLoader();
146+
tick();
30147

31-
expect(plugin.supports('swipe')).toBe(false);
32-
expect(mockConsole.warn)
33-
.toHaveBeenCalledWith(`Hammer.js is not loaded, can not bind 'swipe' event.`);
148+
expect(fakeConsole.warn)
149+
.toHaveBeenCalledWith(
150+
`The custom HAMMER_LOADER completed, but Hammer.JS is not present.`);
151+
}));
34152
});
35153
});
36154
}

0 commit comments

Comments
 (0)