diff options
author | Luke Bennett <lbennett@gitlab.com> | 2018-11-14 01:05:08 +0000 |
---|---|---|
committer | Luke Bennett <lbennett@gitlab.com> | 2018-11-14 01:46:06 +0000 |
commit | 8ce39df8acaf35aa147eb7182489cca41b93fdf7 (patch) | |
tree | 740bf9502e8b0e541989c4936f76244d22be23be | |
parent | 6257d68470c4b316fb709a56686b30ccdc71cff1 (diff) | |
download | gitlab-ce-ce-attempt-to-fix-lazy_loader_spec-transient-failure-with-event-emitter.tar.gz |
Add EventEmitter to potentially improve control flow of lazy_loader_specce-attempt-to-fix-lazy_loader_spec-transient-failure-with-event-emitter
-rw-r--r-- | app/assets/javascripts/event_emitter/event_emitter_error.js | 1 | ||||
-rw-r--r-- | app/assets/javascripts/event_emitter/index.js | 95 | ||||
-rw-r--r-- | app/assets/javascripts/lazy_loader.js | 15 | ||||
-rw-r--r-- | spec/javascripts/helpers/scroll_into_view_promise_helper.js | 11 | ||||
-rw-r--r-- | spec/javascripts/lazy_loader_spec.js | 164 |
5 files changed, 188 insertions, 98 deletions
diff --git a/app/assets/javascripts/event_emitter/event_emitter_error.js b/app/assets/javascripts/event_emitter/event_emitter_error.js new file mode 100644 index 00000000000..2f3270663cf --- /dev/null +++ b/app/assets/javascripts/event_emitter/event_emitter_error.js @@ -0,0 +1 @@ +export default class EventEmitterError extends Error {} diff --git a/app/assets/javascripts/event_emitter/index.js b/app/assets/javascripts/event_emitter/index.js new file mode 100644 index 00000000000..8c3b13c8c05 --- /dev/null +++ b/app/assets/javascripts/event_emitter/index.js @@ -0,0 +1,95 @@ +import EventEmitterError from './event_emitter_error'; + +export default class EventEmitter { + constructor() { + this.listeners = {}; + } + + emit(type, detail) { + EventEmitter.validateListenerArguments(type, null, { enforceCallback: false }); + + const listeners = this.listeners[type]; + if (!EventEmitter.hasListenersOfType(listeners)) return; + + const event = new CustomEvent(type, { detail }); + + listeners.forEach(listener => EventEmitter.emitToListener(this, event, listener)); + } + + on(type, callback, options = {}) { + EventEmitter.validateListenerArguments(type, callback, { enforceCallback: true }); + if (!EventEmitter.hasListenersOfType(this.listeners[type])) this.listeners[type] = []; + + this.listeners[type].push({ + callback, + options, + }); + } + + once(type, callback, options = {}) { + EventEmitter.validateListenerArguments(type, callback); + + const onceOptions = Object.assign(options, { once: true }); + + this.on(type, callback, onceOptions); + } + + off(type, callback) { + EventEmitter.validateListenerArguments(type, callback); + + const listeners = this.listeners[type]; + if (!EventEmitter.hasListenersOfType(listeners)) return; + + if (callback) { + EventEmitter.removeMatchingListeners(listeners, callback); + } else { + delete this.listeners[type]; + } + } + + // Static methods are used to avoid prototype clutter when inheriting from EventEmitter + + static emitToListener(eventEmitter, event, { callback, options }) { + callback(event); + + if (options.once) eventEmitter.off(event.type); + } + + static hasListenersOfType(listeners = []) { + return listeners.length > 0; + } + + static removeMatchingListeners(listeners = [], callback) { + EventEmitter.findMatchingListenerIndexes(listeners, callback).forEach(listenerIndex => + listeners.splice(listenerIndex, 1), + ); + } + + static findMatchingListenerIndexes(listeners = [], callback) { + return listeners.reduce((accumulator, listener, index) => { + if (listener.callback.name !== callback.name) return accumulator; + + accumulator.push(index); + + return accumulator; + }, []); + } + + static validateListenerArguments(type, callback, validationOptions = {}) { + const { enforceCallback } = validationOptions; + + if (!type) throw new EventEmitterError('no event type provided'); + if (enforceCallback !== false) + EventEmitter.validateListenerCallback(callback, validationOptions); + } + + static validateListenerCallback(callback, validationOptions) { + const { enforceCallback } = validationOptions; + + const hasEnforcedCallback = enforceCallback === true && !callback; + const isCallbackFunction = !(callback instanceof Function); + + if (hasEnforcedCallback && isCallbackFunction) + throw new EventEmitterError('event callback is not a function'); + } +} diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index d3436b0cc53..ee909199452 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -1,11 +1,14 @@ import _ from 'underscore'; +import EventEmitter from './event_emitter'; export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; const SCROLL_THRESHOLD = 500; -export default class LazyLoader { +export default class LazyLoader extends EventEmitter { constructor(options = {}) { + super(); + this.intersectionObserver = null; this.lazyImages = []; this.observerNode = options.observerNode || '#content-body'; @@ -113,6 +116,10 @@ export default class LazyLoader { this.requestAnimationFrame(() => this.checkElementsInView()); } + emitIntersectionUpdate(isInView) { + this.emit('intersectionUpdate.lazyLoader', { isInView }); + } + checkElementsInView() { const scrollTop = window.pageYOffset; const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD; @@ -128,6 +135,7 @@ export default class LazyLoader { this.requestAnimationFrame(() => { LazyLoader.loadImage(selectedImage); }); + this.emitIntersectionUpdate(false); return false; } @@ -139,12 +147,17 @@ export default class LazyLoader { if (this.intersectionObserver) { this.intersectionObserver.observe(selectedImage); } + this.emitIntersectionUpdate(false); return false; } + this.emitIntersectionUpdate(true); return true; } + this.emitIntersectionUpdate(false); return false; }); + + if (this.lazyImages.length === 0) this.emitIntersectionUpdate(false); } static loadImage(img) { diff --git a/spec/javascripts/helpers/scroll_into_view_promise_helper.js b/spec/javascripts/helpers/scroll_into_view_promise_helper.js deleted file mode 100644 index c34c5d43357..00000000000 --- a/spec/javascripts/helpers/scroll_into_view_promise_helper.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function scrollIntoViewPromise(intersectionTarget) { - return new Promise(resolve => { - const intersectionObserver = new IntersectionObserver(entries => { - if (entries[0].isIntersecting) resolve(); - }); - - intersectionObserver.observe(intersectionTarget); - - intersectionTarget.scrollIntoView(); - }); -} diff --git a/spec/javascripts/lazy_loader_spec.js b/spec/javascripts/lazy_loader_spec.js index b9f5fcd546e..5c5f91d5431 100644 --- a/spec/javascripts/lazy_loader_spec.js +++ b/spec/javascripts/lazy_loader_spec.js @@ -1,7 +1,5 @@ import LazyLoader from '~/lazy_loader'; import { TEST_HOST } from './test_constants'; -import scrollIntoViewPromise from './helpers/scroll_into_view_promise_helper'; -import waitForPromises from './helpers/wait_for_promises'; let lazyLoader = null; @@ -39,15 +37,14 @@ describe('LazyLoader', function() { const img = document.querySelectorAll('img[data-src]')[0]; const originalDataSrc = img.getAttribute('data-src'); - scrollIntoViewPromise(img) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); - expect(img.getAttribute('src')).toBe(originalDataSrc); - expect(img).toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.once('intersectionUpdate.lazyLoader', () => { + expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(img.getAttribute('src')).toBe(originalDataSrc); + expect(img).toHaveClass('js-lazy-loaded'); + done(); + }); + + img.scrollIntoView(); }); it('should lazy load dynamically added data-src images', function(done) { @@ -57,15 +54,16 @@ describe('LazyLoader', function() { newImg.setAttribute('data-src', testPath); document.body.appendChild(newImg); - scrollIntoViewPromise(newImg) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); - expect(newImg.getAttribute('src')).toBe(testPath); - expect(newImg).toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.on('intersectionUpdate.lazyLoader', ({ detail }) => { + if (detail.isInView) return; + + expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(newImg.getAttribute('src')).toBe(testPath); + expect(newImg).toHaveClass('js-lazy-loaded'); + done(); + }); + + newImg.scrollIntoView(); }); it('should not alter normal images', function(done) { @@ -74,14 +72,13 @@ describe('LazyLoader', function() { newImg.setAttribute('src', testPath); document.body.appendChild(newImg); - scrollIntoViewPromise(newImg) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).not.toHaveBeenCalled(); - expect(newImg).not.toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.once('intersectionUpdate.lazyLoader', () => { + expect(LazyLoader.loadImage).not.toHaveBeenCalled(); + expect(newImg).not.toHaveClass('js-lazy-loaded'); + done(); + }); + + newImg.scrollIntoView(); }); it('should not load dynamically added pictures if content observer is turned off', done => { @@ -93,14 +90,13 @@ describe('LazyLoader', function() { newImg.setAttribute('data-src', testPath); document.body.appendChild(newImg); - scrollIntoViewPromise(newImg) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).not.toHaveBeenCalled(); - expect(newImg).not.toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.once('intersectionUpdate.lazyLoader', () => { + expect(LazyLoader.loadImage).not.toHaveBeenCalled(); + expect(newImg).not.toHaveClass('js-lazy-loaded'); + done(); + }); + + newImg.scrollIntoView(); }); it('should load dynamically added pictures if content observer is turned off and on again', done => { @@ -113,14 +109,15 @@ describe('LazyLoader', function() { newImg.setAttribute('data-src', testPath); document.body.appendChild(newImg); - scrollIntoViewPromise(newImg) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); - expect(newImg).toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.on('intersectionUpdate.lazyLoader', ({ detail }) => { + if (detail.isInView) return; + + expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(newImg).toHaveClass('js-lazy-loaded'); + done(); + }); + + newImg.scrollIntoView(); }); }); @@ -149,15 +146,14 @@ describe('LazyLoader', function() { const img = document.querySelectorAll('img[data-src]')[0]; const originalDataSrc = img.getAttribute('data-src'); - scrollIntoViewPromise(img) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); - expect(img.getAttribute('src')).toBe(originalDataSrc); - expect(img).toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.once('intersectionUpdate.lazyLoader', () => { + expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(img.getAttribute('src')).toBe(originalDataSrc); + expect(img).toHaveClass('js-lazy-loaded'); + done(); + }); + + img.scrollIntoView(); }); it('should lazy load dynamically added data-src images', function(done) { @@ -167,15 +163,14 @@ describe('LazyLoader', function() { newImg.setAttribute('data-src', testPath); document.body.appendChild(newImg); - scrollIntoViewPromise(newImg) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); - expect(newImg.getAttribute('src')).toBe(testPath); - expect(newImg).toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.once('intersectionUpdate.lazyLoader', () => { + expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(newImg.getAttribute('src')).toBe(testPath); + expect(newImg).toHaveClass('js-lazy-loaded'); + done(); + }); + + newImg.scrollIntoView(); }); it('should not alter normal images', function(done) { @@ -184,14 +179,13 @@ describe('LazyLoader', function() { newImg.setAttribute('src', testPath); document.body.appendChild(newImg); - scrollIntoViewPromise(newImg) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).not.toHaveBeenCalled(); - expect(newImg).not.toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.once('intersectionUpdate.lazyLoader', () => { + expect(LazyLoader.loadImage).not.toHaveBeenCalled(); + expect(newImg).not.toHaveClass('js-lazy-loaded'); + done(); + }); + + newImg.scrollIntoView(); }); it('should not load dynamically added pictures if content observer is turned off', done => { @@ -203,14 +197,13 @@ describe('LazyLoader', function() { newImg.setAttribute('data-src', testPath); document.body.appendChild(newImg); - scrollIntoViewPromise(newImg) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).not.toHaveBeenCalled(); - expect(newImg).not.toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.once('intersectionUpdate.lazyLoader', () => { + expect(LazyLoader.loadImage).not.toHaveBeenCalled(); + expect(newImg).not.toHaveClass('js-lazy-loaded'); + done(); + }); + + newImg.scrollIntoView(); }); it('should load dynamically added pictures if content observer is turned off and on again', done => { @@ -223,14 +216,13 @@ describe('LazyLoader', function() { newImg.setAttribute('data-src', testPath); document.body.appendChild(newImg); - scrollIntoViewPromise(newImg) - .then(waitForPromises) - .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); - expect(newImg).toHaveClass('js-lazy-loaded'); - done(); - }) - .catch(done.fail); + lazyLoader.once('intersectionUpdate.lazyLoader', () => { + expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(newImg).toHaveClass('js-lazy-loaded'); + done(); + }); + + newImg.scrollIntoView(); }); }); }); |