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
 | ||||
|  */ | ||||
| 
 | ||||
| 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'; | ||||
| 
 | ||||
| @ -58,6 +58,13 @@ const EVENT_NAMES = { | ||||
|  */ | ||||
| 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 { | ||||
|   on(eventName: string, callback?: Function): void; | ||||
|   off(eventName: string, callback?: Function): void; | ||||
| @ -99,8 +106,8 @@ export class HammerGestureConfig { | ||||
| export class HammerGesturesPlugin extends EventManagerPlugin { | ||||
|   constructor( | ||||
|       @Inject(DOCUMENT) doc: any, | ||||
|       @Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, | ||||
|       private console: Console) { | ||||
|       @Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, private console: Console, | ||||
|       @Optional() @Inject(HAMMER_LOADER) private loader?: HammerLoader) { | ||||
|     super(doc); | ||||
|   } | ||||
| 
 | ||||
| @ -109,8 +116,10 @@ export class HammerGesturesPlugin extends EventManagerPlugin { | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     if (!(window as any).Hammer) { | ||||
|       this.console.warn(`Hammer.js is not loaded, can not bind '${eventName}' event.`); | ||||
|     if (!(window as any).Hammer && !this.loader) { | ||||
|       this.console.warn( | ||||
|           `The "${eventName}" event cannot be bound because Hammer.JS is not ` + | ||||
|           `loaded and no custom loader has been specified.`); | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
| @ -121,6 +130,44 @@ export class HammerGesturesPlugin extends EventManagerPlugin { | ||||
|     const zone = this.manager.getZone(); | ||||
|     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(() => { | ||||
|       // Creating the manager bind events, must be done outside of angular
 | ||||
|       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 | ||||
|  * found in the LICENSE file at https://angular.io/license
 | ||||
|  */ | ||||
| import {describe, expect, it} from '@angular/core/testing/src/testing_internal'; | ||||
| import {HammerGestureConfig, HammerGesturesPlugin} from '@angular/platform-browser/src/dom/events/hammer_gestures'; | ||||
| import {NgZone} from '@angular/core'; | ||||
| 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', () => { | ||||
|     let plugin: HammerGesturesPlugin; | ||||
|     let mockConsole: any; | ||||
|     let fakeConsole: any; | ||||
|     if (isNode) return; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       mockConsole = {warn: () => {}}; | ||||
|       plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), mockConsole); | ||||
|     beforeEach(() => { fakeConsole = {warn: jasmine.createSpy('console.warn')}; }); | ||||
| 
 | ||||
|     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', () => { | ||||
|       spyOn(plugin, 'addEventListener').and.callFake(() => {}); | ||||
|     describe('with a custom loader', () => { | ||||
|       // 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', () => { | ||||
|       spyOn(mockConsole, 'warn'); | ||||
|       // Keep track of whatever value is in `window.Hammer` before the test so it can be
 | ||||
|       // restored afterwards so that this test doesn't care whether Hammer is actually loaded.
 | ||||
|       let originalHammerGlobal: any; | ||||
| 
 | ||||
|       expect(plugin.supports('swipe')).toBe(false); | ||||
|       expect(mockConsole.warn) | ||||
|           .toHaveBeenCalledWith(`Hammer.js is not loaded, can not bind 'swipe' event.`); | ||||
|       // Fake Hammer instance ("mc") used to test the underlying event registration.
 | ||||
|       let fakeHammerInstance: {on: () => void, off: () => void}; | ||||
| 
 | ||||
|       // 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