summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Bennett <lbennett@gitlab.com>2018-11-14 01:05:08 +0000
committerLuke Bennett <lbennett@gitlab.com>2018-11-14 01:46:06 +0000
commit8ce39df8acaf35aa147eb7182489cca41b93fdf7 (patch)
tree740bf9502e8b0e541989c4936f76244d22be23be
parent6257d68470c4b316fb709a56686b30ccdc71cff1 (diff)
downloadgitlab-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.js1
-rw-r--r--app/assets/javascripts/event_emitter/index.js95
-rw-r--r--app/assets/javascripts/lazy_loader.js15
-rw-r--r--spec/javascripts/helpers/scroll_into_view_promise_helper.js11
-rw-r--r--spec/javascripts/lazy_loader_spec.js164
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();
});
});
});