feat(platform-browser): allow lazy-loading HammerJS (#23906)
PR Close #23906
This commit is contained in:
		
							parent
							
								
									5cf82f8f3f
								
							
						
					
					
						commit
						313bdce590
					
				| @ -6,7 +6,7 @@ | |||||||
|  * found in the LICENSE file at https://angular.io/license
 |  * found in the LICENSE file at https://angular.io/license
 | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| import {Inject, Injectable, InjectionToken, ɵConsole as Console} from '@angular/core'; | import {Inject, Injectable, InjectionToken, Optional, ɵConsole as Console} from '@angular/core'; | ||||||
| 
 | 
 | ||||||
| import {DOCUMENT} from '../dom_tokens'; | import {DOCUMENT} from '../dom_tokens'; | ||||||
| 
 | 
 | ||||||
| @ -58,6 +58,13 @@ const EVENT_NAMES = { | |||||||
|  */ |  */ | ||||||
| export const HAMMER_GESTURE_CONFIG = new InjectionToken<HammerGestureConfig>('HammerGestureConfig'); | export const HAMMER_GESTURE_CONFIG = new InjectionToken<HammerGestureConfig>('HammerGestureConfig'); | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | /** Function that loads HammerJS, returning a promise that is resolved once HammerJs is loaded. */ | ||||||
|  | export type HammerLoader = (() => Promise<void>) | null; | ||||||
|  | 
 | ||||||
|  | /** Injection token used to provide a {@link HammerLoader} to Angular. */ | ||||||
|  | export const HAMMER_LOADER = new InjectionToken<HammerLoader>('HammerLoader'); | ||||||
|  | 
 | ||||||
| export interface HammerInstance { | export interface HammerInstance { | ||||||
|   on(eventName: string, callback?: Function): void; |   on(eventName: string, callback?: Function): void; | ||||||
|   off(eventName: string, callback?: Function): void; |   off(eventName: string, callback?: Function): void; | ||||||
| @ -99,8 +106,8 @@ export class HammerGestureConfig { | |||||||
| export class HammerGesturesPlugin extends EventManagerPlugin { | export class HammerGesturesPlugin extends EventManagerPlugin { | ||||||
|   constructor( |   constructor( | ||||||
|       @Inject(DOCUMENT) doc: any, |       @Inject(DOCUMENT) doc: any, | ||||||
|       @Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, |       @Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, private console: Console, | ||||||
|       private console: Console) { |       @Optional() @Inject(HAMMER_LOADER) private loader?: HammerLoader) { | ||||||
|     super(doc); |     super(doc); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -109,8 +116,10 @@ export class HammerGesturesPlugin extends EventManagerPlugin { | |||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!(window as any).Hammer) { |     if (!(window as any).Hammer && !this.loader) { | ||||||
|       this.console.warn(`Hammer.js is not loaded, can not bind '${eventName}' event.`); |       this.console.warn( | ||||||
|  |           `The "${eventName}" event cannot be bound because Hammer.JS is not ` + | ||||||
|  |           `loaded and no custom loader has been specified.`); | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -121,6 +130,44 @@ export class HammerGesturesPlugin extends EventManagerPlugin { | |||||||
|     const zone = this.manager.getZone(); |     const zone = this.manager.getZone(); | ||||||
|     eventName = eventName.toLowerCase(); |     eventName = eventName.toLowerCase(); | ||||||
| 
 | 
 | ||||||
|  |     // If Hammer is not present but a loader is specified, we defer adding the event listener
 | ||||||
|  |     // until Hammer is loaded.
 | ||||||
|  |     if (!(window as any).Hammer && this.loader) { | ||||||
|  |       // This `addEventListener` method returns a function to remove the added listener.
 | ||||||
|  |       // Until Hammer is loaded, the returned function needs to *cancel* the registration rather
 | ||||||
|  |       // than remove anything.
 | ||||||
|  |       let cancelRegistration = false; | ||||||
|  |       let deregister: Function = () => { cancelRegistration = true; }; | ||||||
|  | 
 | ||||||
|  |       this.loader() | ||||||
|  |           .then(() => { | ||||||
|  |             // If Hammer isn't actually loaded when the custom loader resolves, give up.
 | ||||||
|  |             if (!(window as any).Hammer) { | ||||||
|  |               this.console.warn( | ||||||
|  |                   `The custom HAMMER_LOADER completed, but Hammer.JS is not present.`); | ||||||
|  |               deregister = () => {}; | ||||||
|  |               return; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (!cancelRegistration) { | ||||||
|  |               // Now that Hammer is loaded and the listener is being loaded for real,
 | ||||||
|  |               // the deregistration function changes from canceling registration to removal.
 | ||||||
|  |               deregister = this.addEventListener(element, eventName, handler); | ||||||
|  |             } | ||||||
|  |           }) | ||||||
|  |           .catch(() => { | ||||||
|  |             this.console.warn( | ||||||
|  |                 `The "${eventName}" event cannot be bound because the custom ` + | ||||||
|  |                 `Hammer.JS loader failed.`); | ||||||
|  |             deregister = () => {}; | ||||||
|  |           }); | ||||||
|  | 
 | ||||||
|  |       // Return a function that *executes* `deregister` (and not `deregister` itself) so that we
 | ||||||
|  |       // can change the behavior of `deregister` once the listener is added. Using a closure in
 | ||||||
|  |       // this way allows us to avoid any additional data structures to track listener removal.
 | ||||||
|  |       return () => { deregister(); }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return zone.runOutsideAngular(() => { |     return zone.runOutsideAngular(() => { | ||||||
|       // Creating the manager bind events, must be done outside of angular
 |       // Creating the manager bind events, must be done outside of angular
 | ||||||
|       const mc = this._config.buildHammer(element); |       const mc = this._config.buildHammer(element); | ||||||
|  | |||||||
| @ -5,32 +5,150 @@ | |||||||
|  * Use of this source code is governed by an MIT-style license that can be |  * Use of this source code is governed by an MIT-style license that can be | ||||||
|  * found in the LICENSE file at https://angular.io/license
 |  * found in the LICENSE file at https://angular.io/license
 | ||||||
|  */ |  */ | ||||||
| import {describe, expect, it} from '@angular/core/testing/src/testing_internal'; | import {NgZone} from '@angular/core'; | ||||||
| import {HammerGestureConfig, HammerGesturesPlugin} from '@angular/platform-browser/src/dom/events/hammer_gestures'; | import {fakeAsync, inject, tick} from '@angular/core/testing'; | ||||||
|  | import {afterEach, beforeEach, describe, expect, it,} from '@angular/core/testing/src/testing_internal'; | ||||||
|  | import {EventManager} from '@angular/platform-browser'; | ||||||
|  | import {HammerGestureConfig, HammerGesturesPlugin,} from '@angular/platform-browser/src/dom/events/hammer_gestures'; | ||||||
| 
 | 
 | ||||||
| { | { | ||||||
|   describe('HammerGesturesPlugin', () => { |   describe('HammerGesturesPlugin', () => { | ||||||
|     let plugin: HammerGesturesPlugin; |     let plugin: HammerGesturesPlugin; | ||||||
|     let mockConsole: any; |     let fakeConsole: any; | ||||||
|     if (isNode) return; |     if (isNode) return; | ||||||
| 
 | 
 | ||||||
|     beforeEach(() => { |     beforeEach(() => { fakeConsole = {warn: jasmine.createSpy('console.warn')}; }); | ||||||
|       mockConsole = {warn: () => {}}; | 
 | ||||||
|       plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), mockConsole); |     describe('with no custom loader', () => { | ||||||
|  |       beforeEach(() => { | ||||||
|  |         plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), fakeConsole); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should implement addGlobalEventListener', () => { | ||||||
|  |         spyOn(plugin, 'addEventListener').and.callFake(() => {}); | ||||||
|  | 
 | ||||||
|  |         expect(() => { | ||||||
|  |           plugin.addGlobalEventListener('document', 'swipe', () => {}); | ||||||
|  |         }).not.toThrowError(); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should warn user and do nothing when Hammer.js not loaded', () => { | ||||||
|  |         expect(plugin.supports('swipe')).toBe(false); | ||||||
|  |         expect(fakeConsole.warn) | ||||||
|  |             .toHaveBeenCalledWith( | ||||||
|  |                 `The "swipe" event cannot be bound because Hammer.JS is not ` + | ||||||
|  |                 `loaded and no custom loader has been specified.`); | ||||||
|  |       }); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should implement addGlobalEventListener', () => { |     describe('with a custom loader', () => { | ||||||
|       spyOn(plugin, 'addEventListener').and.callFake(() => {}); |       // Use a fake custom loader for tests, with helper functions to resolve or reject.
 | ||||||
|  |       let loader: () => Promise<void>; | ||||||
|  |       let resolveLoader: () => void; | ||||||
|  |       let failLoader: () => void; | ||||||
| 
 | 
 | ||||||
|       expect(() => plugin.addGlobalEventListener('document', 'swipe', () => {})).not.toThrowError(); |       // Arbitrary element and listener for testing.
 | ||||||
|     }); |       let someElement: HTMLDivElement; | ||||||
|  |       let someListener: () => void; | ||||||
| 
 | 
 | ||||||
|     it('should warn user and do nothing when Hammer.js not loaeded', () => { |       // Keep track of whatever value is in `window.Hammer` before the test so it can be
 | ||||||
|       spyOn(mockConsole, 'warn'); |       // restored afterwards so that this test doesn't care whether Hammer is actually loaded.
 | ||||||
|  |       let originalHammerGlobal: any; | ||||||
| 
 | 
 | ||||||
|       expect(plugin.supports('swipe')).toBe(false); |       // Fake Hammer instance ("mc") used to test the underlying event registration.
 | ||||||
|       expect(mockConsole.warn) |       let fakeHammerInstance: {on: () => void, off: () => void}; | ||||||
|           .toHaveBeenCalledWith(`Hammer.js is not loaded, can not bind 'swipe' event.`); | 
 | ||||||
|  |       // Inject the NgZone so that we can make it available to the plugin through a fake
 | ||||||
|  |       // EventManager.
 | ||||||
|  |       let ngZone: NgZone; | ||||||
|  |       beforeEach(inject([NgZone], (z: NgZone) => { ngZone = z; })); | ||||||
|  | 
 | ||||||
|  |       beforeEach(() => { | ||||||
|  |         originalHammerGlobal = (window as any).Hammer; | ||||||
|  |         (window as any).Hammer = undefined; | ||||||
|  | 
 | ||||||
|  |         fakeHammerInstance = { | ||||||
|  |           on: jasmine.createSpy('mc.on'), | ||||||
|  |           off: jasmine.createSpy('mc.off'), | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         loader = () => new Promise((resolve, reject) => { | ||||||
|  |           resolveLoader = resolve; | ||||||
|  |           failLoader = reject; | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         // Make the hammer config return a fake hammer instance
 | ||||||
|  |         const hammerConfig = new HammerGestureConfig(); | ||||||
|  |         spyOn(hammerConfig, 'buildHammer').and.returnValue(fakeHammerInstance); | ||||||
|  | 
 | ||||||
|  |         plugin = new HammerGesturesPlugin(document, hammerConfig, fakeConsole, loader); | ||||||
|  | 
 | ||||||
|  |         // Use a fake EventManager that has access to the NgZone.
 | ||||||
|  |         plugin.manager = { getZone: () => ngZone } as EventManager; | ||||||
|  | 
 | ||||||
|  |         someElement = document.createElement('div'); | ||||||
|  |         someListener = () => {}; | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       afterEach(() => { (window as any).Hammer = originalHammerGlobal; }); | ||||||
|  | 
 | ||||||
|  |       it('should not log a warning when HammerJS is not loaded', () => { | ||||||
|  |         plugin.addEventListener(someElement, 'swipe', () => {}); | ||||||
|  |         expect(fakeConsole.warn).not.toHaveBeenCalled(); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should defer registering an event until Hammer is loaded', fakeAsync(() => { | ||||||
|  |            plugin.addEventListener(someElement, 'swipe', someListener); | ||||||
|  |            expect(fakeHammerInstance.on).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |            (window as any).Hammer = {}; | ||||||
|  |            resolveLoader(); | ||||||
|  |            tick(); | ||||||
|  | 
 | ||||||
|  |            expect(fakeHammerInstance.on).toHaveBeenCalledWith('swipe', jasmine.any(Function)); | ||||||
|  |          })); | ||||||
|  | 
 | ||||||
|  |       it('should cancel registration if an event is removed before being added', fakeAsync(() => { | ||||||
|  |            const deregister = plugin.addEventListener(someElement, 'swipe', someListener); | ||||||
|  |            deregister(); | ||||||
|  | 
 | ||||||
|  |            (window as any).Hammer = {}; | ||||||
|  |            resolveLoader(); | ||||||
|  |            tick(); | ||||||
|  | 
 | ||||||
|  |            expect(fakeHammerInstance.on).not.toHaveBeenCalled(); | ||||||
|  |          })); | ||||||
|  | 
 | ||||||
|  |       it('should remove a listener after Hammer is loaded', fakeAsync(() => { | ||||||
|  |            const removeListener = plugin.addEventListener(someElement, 'swipe', someListener); | ||||||
|  | 
 | ||||||
|  |            (window as any).Hammer = {}; | ||||||
|  |            resolveLoader(); | ||||||
|  |            tick(); | ||||||
|  | 
 | ||||||
|  |            removeListener(); | ||||||
|  |            expect(fakeHammerInstance.off).toHaveBeenCalledWith('swipe', jasmine.any(Function)); | ||||||
|  |          })); | ||||||
|  | 
 | ||||||
|  |       it('should log a warning when the loader fails', fakeAsync(() => { | ||||||
|  |            plugin.addEventListener(someElement, 'swipe', () => {}); | ||||||
|  |            failLoader(); | ||||||
|  |            tick(); | ||||||
|  | 
 | ||||||
|  |            expect(fakeConsole.warn) | ||||||
|  |                .toHaveBeenCalledWith( | ||||||
|  |                    `The "swipe" event cannot be bound because the custom Hammer.JS loader failed.`); | ||||||
|  |          })); | ||||||
|  | 
 | ||||||
|  |       it('should load a warning if the loader resolves and Hammer is not present', fakeAsync(() => { | ||||||
|  |            plugin.addEventListener(someElement, 'swipe', () => {}); | ||||||
|  |            resolveLoader(); | ||||||
|  |            tick(); | ||||||
|  | 
 | ||||||
|  |            expect(fakeConsole.warn) | ||||||
|  |                .toHaveBeenCalledWith( | ||||||
|  |                    `The custom HAMMER_LOADER completed, but Hammer.JS is not present.`); | ||||||
|  |          })); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user