diff --git a/package-lock.json b/package-lock.json index 3c74f8d..49ca010 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "imagekitio-react", - "version": "1.0.7", + "version": "1.0.9", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8523446..eeaa8be 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imagekitio-react", - "version": "1.0.8", + "version": "1.0.9", "description": "React SDK for ImageKit.io which implements client-side upload and URL generation for use inside a react application.", "scripts": { "build": "rm -rf dist*;rollup -c", @@ -25,6 +25,9 @@ "verbose": true, "setupFilesAfterEnv": [ "/src/test/setupTests.js" + ], + "coveragePathIgnorePatterns": [ + "/src/test/mocks" ] }, "author": "ImageKit", diff --git a/src/components/IKImage/index.js b/src/components/IKImage/index.js index 7c33e93..a4c3880 100755 --- a/src/components/IKImage/index.js +++ b/src/components/IKImage/index.js @@ -117,8 +117,9 @@ class IKImage extends ImageKitComponent { triggerOriginalImageLoad() { var img = new Image(); img.onload = () => { - this.setState({ originalSrcLoaded: true }); - this.updateImageUrl(); + this.setState({ originalSrcLoaded: true }, () => { + this.updateImageUrl(); + }); } img.src = this.state.originalSrc; } @@ -136,10 +137,11 @@ class IKImage extends ImageKitComponent { const imageObserver = new IntersectionObserver(entries => { const el = entries[0]; if (el && el.isIntersecting) { - this.setState({ intersected: true }); - if (lqip && lqip.active) this.triggerOriginalImageLoad(); - imageObserver.disconnect(); - this.updateImageUrl(); + this.setState({ intersected: true }, () => { + if (lqip && lqip.active) this.triggerOriginalImageLoad(); + imageObserver.disconnect(); + this.updateImageUrl(); + }); } }, { rootMargin: `${rootMargin} 0px ${rootMargin} 0px` @@ -150,9 +152,10 @@ class IKImage extends ImageKitComponent { }) } else { // Load original image right away - this.setState({ intersected: true }); - if (lqip && lqip.active) this.triggerOriginalImageLoad(); - this.updateImageUrl(); + this.setState({ intersected: true }, () => { + if (lqip && lqip.active) this.triggerOriginalImageLoad(); + this.updateImageUrl(); + }); } } diff --git a/src/test/IKImage.test.js b/src/test/IKImage.test.js index 3ee856d..626d670 100644 --- a/src/test/IKImage.test.js +++ b/src/test/IKImage.test.js @@ -2,6 +2,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import sinon from 'sinon'; import IKImage from '../components/IKImage'; +import IntersectionObserverMock from './mocks/IntersectionObserverMock'; const urlEndpoint = 'http://ik.imagekit.io/test_imagekit_id'; const relativePath = 'default-image.jpg'; @@ -14,7 +15,7 @@ describe('IKImage', () => { describe('Absolute image path', () => { test("src with alt attribute", () => { const ikImage = shallow(); - + expect(ikImage.find('img').prop('src')).toEqual(`${absolutePath}?${global.SDK_VERSION}`); expect(ikImage.find('img').prop('alt')).toEqual('some text here'); @@ -28,7 +29,7 @@ describe('IKImage', () => { queryParameters={{ version: 5, name: 'check' }} /> ); - + const transformURL = `${absolutePathWithQuery}&${global.SDK_VERSION}&version=5&name=check`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -43,7 +44,7 @@ describe('IKImage', () => { width: 400 }]} /> ); - + const transformURL = `${absolutePath}?${global.SDK_VERSION}&tr=h-300%2Cw-400`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -52,7 +53,7 @@ describe('IKImage', () => { const ikImage = shallow( ); - + const transformURL = `${absolutePath}?${global.SDK_VERSION}&tr=q-20%2Cbl-6`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -70,7 +71,7 @@ describe('IKImage', () => { id="lqip" /> ); - + const transformURL = `${absolutePath}?${global.SDK_VERSION}&tr=h-300%2Cw-400%3Aq-20%2Cbl-6`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -79,7 +80,7 @@ describe('IKImage', () => { describe('Relative image path', () => { test("path with alt attribute", () => { const ikImage = shallow(); - + expect(ikImage.find('img').prop('src')).toEqual(`${urlEndpoint}/${relativePath}?${global.SDK_VERSION}`); expect(ikImage.find('img').prop('alt')).toEqual('some text here'); }); @@ -88,14 +89,14 @@ describe('IKImage', () => { const ikImage = shallow( ); - + const transformURL = `${urlEndpoint}/${relativePath}?${global.SDK_VERSION}&version=5&name=check`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); test("path having leading slashes", () => { const ikImage = shallow(); - + expect(ikImage.find('img').prop('src')).toEqual(`${urlEndpoint}/default-image.jpg?${global.SDK_VERSION}`); }); @@ -103,7 +104,7 @@ describe('IKImage', () => { const ikImage = shallow( ); - + expect(ikImage.find('img').prop('src')).toEqual(`http://ik.imagekit.io/test_imagekit_id/${relativePath}?${global.SDK_VERSION}`); }); @@ -111,7 +112,7 @@ describe('IKImage', () => { const ikImage = shallow( ); - + const transformURL = `${urlEndpoint}/tr:q-20,bl-6/${relativePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); expect(ikImage.find('img').prop('id')).toEqual('lqip'); @@ -124,7 +125,7 @@ describe('IKImage', () => { width: 400 }]} /> ); - + const transformURL = `${urlEndpoint}/tr:h-300,w-400/${relativePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -142,7 +143,7 @@ describe('IKImage', () => { id="lqip" /> ); - + const transformURL = `${urlEndpoint}/tr:h-300,w-400:q-20,bl-6/${relativePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); expect(ikImage.find('img').prop('id')).toEqual('lqip'); @@ -161,7 +162,7 @@ describe('IKImage', () => { id="lqip" /> ); - + const transformURL = `${urlEndpoint}/tr:h-300,w-400:q-50,bl-25${nestedImagePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); expect(ikImage.find('img').prop('id')).toEqual('lqip'); @@ -175,7 +176,7 @@ describe('IKImage', () => { height: 300 }]} /> ); - + const transformURL = `${urlEndpoint}/tr:h-300/${relativePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -187,7 +188,7 @@ describe('IKImage', () => { width: 400 }]} transformationPosition="query" /> ); - + const transformURL = `${urlEndpoint}/${relativePath}?${global.SDK_VERSION}&tr=h-300%2Cw-400`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -199,7 +200,7 @@ describe('IKImage', () => { width: 400 }]} transformationPosition="path" /> ); - + const transformURL = `${urlEndpoint}/tr:h-300,w-400/${relativePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -211,7 +212,7 @@ describe('IKImage', () => { width: 400 }]} transformationPosition="path" /> ); - + const transformURL = `${absolutePath}?${global.SDK_VERSION}&tr=h-300%2Cw-400`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -225,7 +226,7 @@ describe('IKImage', () => { 'rotation': 90 }]} /> ); - + const transformURL = `${urlEndpoint}/tr:h-300,w-400:rt-90/${relativePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -236,7 +237,7 @@ describe('IKImage', () => { 'foo': 'bar', }]} /> ); - + const transformURL = `${urlEndpoint}/tr:foo-bar/${relativePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -248,7 +249,7 @@ describe('IKImage', () => { height: 300 }]} /> ); - + const transformURL = `${urlEndpoint}/tr:foo-bar,h-300/${relativePath}?${global.SDK_VERSION}`; expect(ikImage.find('img').prop('src')).toEqual(transformURL); }); @@ -257,6 +258,53 @@ describe('IKImage', () => { describe('Component lifecycle', () => { describe('Lazy loading', () => { + // spies + const observeSpy = sinon.spy(); + let intersectionObserverSpy; + let originalNavigatorPrototype; + let originalWindowPrototype; + + const mockNavigator = (effectiveType = '4g') => { + // backup original connection value + originalNavigatorPrototype = Object.getOwnPropertyDescriptor(global.Navigator.prototype, 'connection'); + + // mock connection + Object.defineProperty(global.Navigator.prototype, 'connection', { + get: function () { + return { effectiveType }; + }, + configurable: true + }); + }; + + const restoreNavigator = () => { + const navigatorConnection = originalNavigatorPrototype || { + get: function () { }, + configurable: true + }; + + Object.defineProperty(global.Navigator.prototype, 'connection', navigatorConnection); + } + + const removeIntersectionObserverMock = () => { + originalWindowPrototype = Object.getOwnPropertyDescriptor(window, 'IntersectionObserver'); + delete window['IntersectionObserver']; + }; + + const restoreIntersectionObserverMock = () => { + Object.defineProperty(window, 'IntersectionObserver', originalWindowPrototype); + }; + + beforeEach(() => { + IntersectionObserverMock({ observe: observeSpy }); + intersectionObserverSpy = sinon.spy(window, 'IntersectionObserver'); + }); + + afterEach(() => { + intersectionObserverSpy.restore(); + observeSpy.resetHistory(); + }); + test('image should have empty src initially when marked for lazy loading', () => { const ikImage = mount( { loading="lazy" /> ); - + expect(ikImage.find('img').prop('src')).toEqual(''); }); + test('image should have actual src when element is intersected', () => { + const ikImage = mount( + + ); + + // verify that src is blank initially + expect(ikImage.find('img').prop('src')).toEqual(''); + + // verify mocks were called + expect(observeSpy.calledOnce).toEqual(true); + expect(intersectionObserverSpy.calledOnce).toEqual(true); + + // trigger element intersection callback + intersectionObserverSpy.args[0][0]([{ isIntersecting: true }]); + // update wrapper + ikImage.update(); + + const lazyLoadedURL = `${urlEndpoint}/${relativePath}?${global.SDK_VERSION}` + expect(ikImage.find('img').prop('src')).toEqual(lazyLoadedURL); + }); + test('intersection observer should disconnect when component unmounts', () => { const ikImage = shallow( { expect(observerStub.observe.disconnect.called).toEqual(true); spy.restore(); }); + + test('should set smaller threshold margin for fast connections', () => { + // mock fast network connection + mockNavigator('4g'); + + const ikImage = mount( + + ); + + // verify that src is blank initially + expect(ikImage.find('img').prop('src')).toEqual('') + + // verify mocks were called + expect(observeSpy.calledOnce).toEqual(true); + expect(intersectionObserverSpy.calledOnce).toEqual(true); + + // check rootMargin + expect(intersectionObserverSpy.args[0][1].rootMargin).toEqual('1250px 0px 1250px 0px') + + // trigger element intersection callback + intersectionObserverSpy.args[0][0]([{ isIntersecting: true }]); + // update wrapper + ikImage.update(); + + const lazyLoadedURL = `${urlEndpoint}/${relativePath}?${global.SDK_VERSION}` + expect(ikImage.find('img').prop('src')).toEqual(lazyLoadedURL); + + restoreNavigator(); + }); + + test('should set larger threshold margin for slower connections', () => { + // mock slow network connection + mockNavigator('2g'); + + const ikImage = mount( + + ); + + // verify that src is blank initially + expect(ikImage.find('img').prop('src')).toEqual('') + + // verify mocks were called + expect(observeSpy.calledOnce).toEqual(true); + expect(intersectionObserverSpy.calledOnce).toEqual(true); + + // check rootMargin + expect(intersectionObserverSpy.args[0][1].rootMargin).toEqual('2500px 0px 2500px 0px') + + // trigger element intersection callback + intersectionObserverSpy.args[0][0]([{ isIntersecting: true }]); + // update wrapper + ikImage.update(); + + const lazyLoadedURL = `${urlEndpoint}/${relativePath}?${global.SDK_VERSION}` + expect(ikImage.find('img').prop('src')).toEqual(lazyLoadedURL); + + restoreNavigator(); + }); + + // covers 'else' condition when checking for presence of IntersectionObserver in 'window' + test('should set original src if IntersectionObserver is not present', () => { + removeIntersectionObserverMock(); + + const ikImage = mount( + + ); + + expect(ikImage.find('img').prop('src')).toEqual(`${urlEndpoint}/${relativePath}?${global.SDK_VERSION}`); + + restoreIntersectionObserverMock(); + }); + + // covers 'else' condition for observer disconnection in non-lazyload cases + test('should unmount properly when lazyload is not enabled', () => { + const ikImage = shallow( + + ); + // spies + const spy = sinon.spy(ikImage.instance(), 'componentWillUnmount'); + expect(spy.called).toEqual(false); + + // trigger unmount + ikImage.unmount(); + + // verify spies + expect(spy.calledOnce).toEqual(true); + spy.restore(); + + expect(ikImage.find('img').length).toEqual(0); + }); + }); + + describe('LQIP', () => { + // spies + const observeSpy = sinon.spy(); + let intersectionObserverSpy; + + let originalImagePrototype; + let imageOnload = null; + + const stubImagePrototype = () => { + Object.defineProperty(global.Image.prototype, 'onload', { + get: function () { + return this._onload; + }, + set: function (fn) { + imageOnload = fn; + this._onload = fn; + }, + configurable: true + }); + }; + + beforeEach(() => { + IntersectionObserverMock({ observe: observeSpy }); + intersectionObserverSpy = sinon.spy(window, 'IntersectionObserver'); + + // backup the original Image.prototype.src + originalImagePrototype = Object.getOwnPropertyDescriptor(global.Image.prototype, 'src'); + + stubImagePrototype(); + }); + + afterEach(() => { + intersectionObserverSpy.restore(); + observeSpy.resetHistory(); + + // restore the original Image.prototype.src + Object.defineProperty(global.Image.prototype, 'src', originalImagePrototype); + }); + + test('image with lqip should have actual src when element is loaded', () => { + const ikImage = shallow( + + ); + + const initialURL = `${urlEndpoint}/tr:q-20,bl-6/${relativePath}?${global.SDK_VERSION}` + expect(ikImage.find('img').prop('src')).toEqual(initialURL); + + imageOnload(); + + const expectedURL = `${urlEndpoint}/${relativePath}?${global.SDK_VERSION}` + expect(ikImage.find('img').prop('src')).toEqual(expectedURL); + }); + + test('image with lqip and lazy loading should have actual src when element is intersected and loaded', () => { + const ikImage = mount( + + ); + + const lazyLoadedURL = `${urlEndpoint}/tr:q-20,bl-6/${relativePath}?${global.SDK_VERSION}` + expect(ikImage.find('img').prop('src')).toEqual(lazyLoadedURL); + + expect(observeSpy.calledOnce).toEqual(true); + expect(intersectionObserverSpy.calledOnce).toEqual(true); + + // trigger element intersection callback + intersectionObserverSpy.args[0][0]([{ isIntersecting: true }]); + ikImage.update(); + expect(ikImage.find('img').prop('src')).toEqual(lazyLoadedURL); + + // simulate image onload + imageOnload(); + ikImage.update(); + + const fullyLoadedURL = `${urlEndpoint}/${relativePath}?${global.SDK_VERSION}` + expect(ikImage.find('img').prop('src')).toEqual(fullyLoadedURL); + }); + + test('should not work for image with lqip active key set to false and lazy loading enabled', () => { + const ikImage = mount( + + ); + + expect(ikImage.find('img').prop('src')).toBeUndefined(); + + expect(observeSpy.calledOnce).toEqual(true); + expect(intersectionObserverSpy.calledOnce).toEqual(true); + + // trigger element intersection callback + intersectionObserverSpy.args[0][0]([{ isIntersecting: true }]); + ikImage.update(); + + expect(ikImage.find('img').prop('src')).toBeUndefined(); + }); }); }); }); diff --git a/src/test/mocks/IntersectionObserverMock.js b/src/test/mocks/IntersectionObserverMock.js new file mode 100644 index 0000000..cf726b4 --- /dev/null +++ b/src/test/mocks/IntersectionObserverMock.js @@ -0,0 +1,40 @@ +/** + * Utility function that mocks the `IntersectionObserver` API. Necessary for components that rely + * on it, otherwise the tests will crash. Recommended to execute inside `beforeEach`. + * @param intersectionObserverMock - Parameter that is sent to the `Object.defineProperty` + * overwrite method. `jest.fn()` mock functions can be passed here if the goal is to not only + * mock the intersection observer, but its methods. + */ +export default function IntersectionObserverMock({ + root = null, + rootMargin = '', + thresholds = [], + disconnect = () => null, + observe = () => null, + takeRecords = () => null, + unobserve = () => null, +} = {}) { + class MockIntersectionObserver { + constructor() { + this.root = root; + this.rootMargin = rootMargin; + this.thresholds = thresholds; + this.disconnect = disconnect; + this.observe = observe; + this.takeRecords = takeRecords; + this.unobserve = unobserve; + } + } + + Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver + }); + + Object.defineProperty(global, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver + }); +}