diff --git a/src/components/card/card-img-lazy.spec.js b/src/components/card/card-img-lazy.spec.js index 747cef50de5..6eed7706406 100644 --- a/src/components/card/card-img-lazy.spec.js +++ b/src/components/card/card-img-lazy.spec.js @@ -11,17 +11,7 @@ describe('card-image', () => { } }) expect(wrapper.is('img')).toBe(true) - }) - - it('default has data src attribute', async () => { - const wrapper = mount(BCardImgLazy, { - context: { - props: { - src: 'https://picsum.photos/600/300/?image=25' - } - } - }) - expect(wrapper.attributes('src')).toContain('data:image/svg+xml') + expect(wrapper.attributes('src')).toBeDefined() }) it('default does not have alt attribute', async () => { @@ -43,10 +33,14 @@ describe('card-image', () => { } } }) - expect(wrapper.attributes('width')).toBeDefined() - expect(wrapper.attributes('width')).toBe('1') - expect(wrapper.attributes('height')).toBeDefined() - expect(wrapper.attributes('height')).toBe('1') + expect(wrapper.attributes('width')).not.toBeDefined() + expect(wrapper.attributes('height')).not.toBeDefined() + // Without IntersectionObserver support, the main image is shown + // and the value of the width and height props are used (null in this case) + // expect(wrapper.attributes('width')).toBeDefined() + // expect(wrapper.attributes('width')).toBe('1') + // expect(wrapper.attributes('height')).toBeDefined() + // expect(wrapper.attributes('height')).toBe('1') }) it('default has class "card-img"', async () => { diff --git a/src/components/image/README.md b/src/components/image/README.md index b3486b3af7f..32bececc2a9 100644 --- a/src/components/image/README.md +++ b/src/components/image/README.md @@ -209,9 +209,8 @@ The default `blank-color` is `transparent`. Lazy loading images uses [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) -if supported by the browser (or polyfill), otherwise it uses the document `scroll`, `resize`, and -`transitionend` events to determine if the image is in view in order to trigger the loading of the -final image. Scrolling of other elements is not monitored, and will not trigger image loading. +if supported by the browser (or via a polyfill) to detect with the image should be shown. If +`IntersectionObserver` support is _not detected_, then the image will _always_ be shown. ### Usage diff --git a/src/components/image/img-lazy.js b/src/components/image/img-lazy.js index 9149fac008c..66681da6fc5 100644 --- a/src/components/image/img-lazy.js +++ b/src/components/image/img-lazy.js @@ -1,14 +1,11 @@ import Vue from '../../utils/vue' -import { BImg } from './img' import { getComponentConfig } from '../../utils/config' -import { getBCR, eventOn, eventOff } from '../../utils/dom' import { hasIntersectionObserverSupport } from '../../utils/env' +import { VBVisible } from '../../directives/visible' +import { BImg } from './img' const NAME = 'BImgLazy' -const THROTTLE = 100 -const EVENT_OPTIONS = { passive: true, capture: false } - export const props = { src: { type: String, @@ -81,24 +78,23 @@ export const props = { default: false }, offset: { + // Distance away from viewport (in pixels) before being + // considered "visible" type: [Number, String], default: 360 - }, - throttle: { - type: [Number, String], - default: THROTTLE } } // @vue/component export const BImgLazy = /*#__PURE__*/ Vue.extend({ name: NAME, + directives: { + bVisible: VBVisible + }, props, data() { return { - isShown: false, - scrollTimeout: null, - observer: null + isShown: this.show } }, computed: { @@ -118,121 +114,66 @@ export const BImgLazy = /*#__PURE__*/ Vue.extend({ watch: { show(newVal, oldVal) { if (newVal !== oldVal) { - this.isShown = newVal - if (!newVal) { - // Make sure listeners are re-enabled if img is force set to blank - this.setListeners(true) + // If IntersectionObserver support is not available, image is always shown + const visible = hasIntersectionObserverSupport ? newVal : true + this.isShown = visible + if (visible !== newVal) { + // Ensure the show prop is synced (when no IntersectionObserver) + this.$nextTick(this.updateShowProp) } } }, isShown(newVal, oldVal) { if (newVal !== oldVal) { // Update synched show prop - this.$emit('update:show', newVal) + this.updateShowProp() } } }, - created() { - this.isShown = this.show - }, mounted() { - if (this.isShown) { - this.setListeners(false) - } else { - this.setListeners(true) - } - }, - activated() /* istanbul ignore next */ { - if (!this.isShown) { - this.setListeners(true) - } - }, - deactivated() /* istanbul ignore next */ { - this.setListeners(false) - }, - beforeDestroy() { - this.setListeners(false) + // If IntersectionObserver is not available, image is always shown + this.isShown = hasIntersectionObserverSupport ? this.show : true }, methods: { - setListeners(on) { - if (this.scrollTimeout) { - clearTimeout(this.scrollTimeout) - this.scrollTimeout = null - } - /* istanbul ignore next: JSDOM doen't support IntersectionObserver */ - if (this.observer) { - this.observer.unobserve(this.$el) - this.observer.disconnect() - this.observer = null - } - const winEvts = ['scroll', 'resize', 'orientationchange'] - winEvts.forEach(evt => eventOff(window, evt, this.onScroll, EVENT_OPTIONS)) - eventOff(this.$el, 'load', this.checkView, EVENT_OPTIONS) - eventOff(document, 'transitionend', this.onScroll, EVENT_OPTIONS) - if (on) { - /* istanbul ignore if: JSDOM doen't support IntersectionObserver */ - if (hasIntersectionObserverSupport) { - this.observer = new IntersectionObserver(this.doShow, { - root: null, // viewport - rootMargin: `${parseInt(this.offset, 10) || 0}px`, - threshold: 0 // percent intersection - }) - this.observer.observe(this.$el) - } else { - // Fallback to scroll/etc events - winEvts.forEach(evt => eventOn(window, evt, this.onScroll, EVENT_OPTIONS)) - eventOn(this.$el, 'load', this.checkView, EVENT_OPTIONS) - eventOn(document, 'transitionend', this.onScroll, EVENT_OPTIONS) - } - } + updateShowProp() { + this.$emit('update:show', this.isShown) }, - doShow(entries) { - if (entries && (entries[0].isIntersecting || entries[0].intersectionRatio > 0.0)) { + doShow(visible) { + // If IntersectionObserver is not supported, the callback + // will be called with `null` rather than `true` or `false` + if ((visible || visible === null) && !this.isShown) { this.isShown = true - this.setListeners(false) - } - }, - checkView() { - // check bounding box + offset to see if we should show - /* istanbul ignore next: should rarely occur */ - if (this.isShown) { - this.setListeners(false) - return - } - const offset = parseInt(this.offset, 10) || 0 - const docElement = document.documentElement - const view = { - l: 0 - offset, - t: 0 - offset, - b: docElement.clientHeight + offset, - r: docElement.clientWidth + offset - } - // JSDOM Doesn't support BCR, but we fake it in the tests - const box = getBCR(this.$el) - if (box.right >= view.l && box.bottom >= view.t && box.left <= view.r && box.top <= view.b) { - // image is in view (or about to be in view) - this.doShow([{ isIntersecting: true }]) - } - }, - onScroll() { - /* istanbul ignore if: should rarely occur */ - if (this.isShown) { - this.setListeners(false) - } else { - clearTimeout(this.scrollTimeout) - this.scrollTimeout = setTimeout(this.checkView, parseInt(this.throttle, 10) || THROTTLE) } } }, render(h) { + const directives = [] + if (!this.isShown) { + // We only add the visible directive if we are not shown + directives.push({ + // Visible directive will silently do nothing if + // IntersectionObserver is not supported + name: 'b-visible', + // Value expects a callback (passed one arg of `visible` = `true` or `false`) + value: this.doShow, + modifiers: { + // Root margin from viewport + [`${parseInt(this.offset, 10) || 0}`]: true, + // Once the image is shown, stop observing + once: true + } + }) + } + return h(BImg, { + directives, props: { // Computed value props src: this.computedSrc, blank: this.computedBlank, width: this.computedWidth, height: this.computedHeight, - // Passthough props + // Passthrough props alt: this.alt, blankColor: this.blankColor, fluid: this.fluid, diff --git a/src/components/image/img-lazy.spec.js b/src/components/image/img-lazy.spec.js index ed5cfea2189..abb6d92be6f 100644 --- a/src/components/image/img-lazy.spec.js +++ b/src/components/image/img-lazy.spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { waitNT } from '../../../tests/utils' +import { waitNT, waitRAF } from '../../../tests/utils' import { BImgLazy } from './img-lazy' const src = 'https://picsum.photos/1024/400/?image=41' @@ -7,6 +7,7 @@ const src = 'https://picsum.photos/1024/400/?image=41' describe('img-lazy', () => { it('has root element "img"', async () => { const wrapper = mount(BImgLazy, { + attachToDocument: true, propsData: { src: src } @@ -18,6 +19,7 @@ describe('img-lazy', () => { it('is initially shown show prop is set', async () => { const wrapper = mount(BImgLazy, { + attachToDocument: true, propsData: { src: src, show: true @@ -31,111 +33,64 @@ describe('img-lazy', () => { wrapper.destroy() }) - it('shows when show prop is set', async () => { + it('shows when IntersectionObserver not supported', async () => { const wrapper = mount(BImgLazy, { + attachToDocument: true, propsData: { src: src, show: false } }) + expect(wrapper.is('img')).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.vm.isShown).toBe(true) + + // It appears that vue-test-utils does not run unbind when the directive is + // removed from the element. Only when the component is destroyed... unlike Vue + // Our directive instance should not exist + // let observer = wrapper.element.__bv__visibility_observer + // expect(observer).not.toBeDefined() + expect(wrapper.attributes('src')).toBeDefined() - expect(wrapper.attributes('src')).toContain('data:image/svg+xml;charset=UTF-8') + expect(wrapper.attributes('src')).toContain(src) wrapper.setProps({ show: true }) await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + expect(wrapper.attributes('src')).toBe(src) + expect(wrapper.vm.isShown).toBe(true) + + // Our directive instance should not exist + // observer = wrapper.element.__bv__visibility_observer + // expect(observer).not.toBeDefined() wrapper.setProps({ show: false }) await waitNT(wrapper.vm) - expect(wrapper.attributes('src')).toContain('data:image/svg+xml;charset=UTF-8') - - wrapper.destroy() - }) - - // These tests are wrapped in a new describe to limit the scope of the getBCR Mock - describe('scroll events', () => { - const origGetBCR = Element.prototype.getBoundingClientRect - - jest.useFakeTimers() - - afterEach(() => { - // Restore prototype - Element.prototype.getBoundingClientRect = origGetBCR - }) - - it('triggers check on resize event event', async () => { - const src = 'https://picsum.photos/1024/400/?image=41' - - // Fake getBCR initially "off screen" - Element.prototype.getBoundingClientRect = jest.fn(() => ({ - width: 24, - height: 24, - top: 10000, - left: 10000, - bottom: -10000, - right: -10000 - })) - - const wrapper = mount(BImgLazy, { - attachToDocument: true, - propsData: { - src: src, - offset: 500 - } - }) - expect(wrapper.is('img')).toBe(true) - - expect(wrapper.attributes('src')).toBeDefined() - expect(wrapper.attributes('src')).toContain('data:image/svg+xml;charset=UTF-8') - - expect(wrapper.vm.scrollTimeout).toBe(null) - - // Fake getBCR "in view" - Element.prototype.getBoundingClientRect = jest.fn(() => ({ - width: 24, - height: 24, - top: 0, - left: 0, - bottom: 0, - right: 0 - })) - - window.dispatchEvent(new UIEvent('resize')) - - await wrapper.vm.$nextTick() - await wrapper.vm.$nextTick() - - expect(wrapper.vm.scrollTimeout).not.toBe(null) - - // Since JSDOM doesnt support getBCR, we fake it by setting - // the data prop to shown - // wrapper.setData({ - // isShown: true - // }) - - // Advance the setTimeout - jest.runOnlyPendingTimers() - - await wrapper.vm.$nextTick() - await wrapper.vm.$nextTick() - - expect(wrapper.vm.scrollTimeout).toBe(null) - - expect(wrapper.attributes('src')).toContain(src) - - window.dispatchEvent(new UIEvent('resize')) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() - expect(wrapper.vm.scrollTimeout).toBe(null) + expect(wrapper.attributes('src')).toContain(src) - wrapper.destroy() + // Our directive instance should not exist + // observer = wrapper.element.__bv__visibility_observer + // expect(observer).not.toBeDefined() - Element.prototype.getBoundingClientRect = origGetBCR - }) + wrapper.destroy() }) }) diff --git a/src/directives/visible.js b/src/directives/visible.js index 8bc6e355a4d..8ef04b63d76 100644 --- a/src/directives/visible.js +++ b/src/directives/visible.js @@ -36,7 +36,7 @@ import { requestAF } from '../utils/dom' import { isFunction } from '../utils/inspect' import { keys } from '../utils/object' -const PROP_NAME = '__bv__visibility_observer' +const OBSERVER_PROP_NAME = '__bv__visibility_observer' class VisibilityObserver { constructor(el, options, vnode) { @@ -52,19 +52,14 @@ class VisibilityObserver { } createObserver(vnode) { + // Remove any previous observer if (this.observer) { - // Remove any previous observer /* istanbul ignore next */ this.stop() } - if (this.doneOnce) { - // Should only be called once - /* istanbul ignore next */ - return - } - - if (!isFunction(this.callback)) { + // Should only be called once and `callback` prop should be a function + if (this.doneOnce || !isFunction(this.callback)) { /* istanbul ignore next */ return } @@ -83,8 +78,8 @@ class VisibilityObserver { }) } catch { // No IntersectionObserver support, so just stop trying to observe - this.donOnce = true - this.observer = null + this.doneOnce = true + this.observer = undefined this.callback(null) return } @@ -93,7 +88,11 @@ class VisibilityObserver { /* istanbul ignore next: IntersectionObserver not supported in JSDOM */ vnode.context.$nextTick(() => { requestAF(() => { - this.observer.observe(this.el) + // Placed in an `if` just in case we were destroyed before + // this `requestAnimationFrame` runs + if (this.observer) { + this.observer.observe(this.el) + } }) }) } @@ -122,11 +121,11 @@ class VisibilityObserver { } const destroy = el => { - const observer = el[PROP_NAME] + const observer = el[OBSERVER_PROP_NAME] if (observer && observer.stop) { observer.stop() } - delete el[PROP_NAME] + delete el[OBSERVER_PROP_NAME] } const bind = (el, { value, modifiers }, vnode) => { @@ -148,20 +147,20 @@ const bind = (el, { value, modifiers }, vnode) => { // Destroy any previous observer destroy(el) // Create new observer - el[PROP_NAME] = new VisibilityObserver(el, options, vnode) + el[OBSERVER_PROP_NAME] = new VisibilityObserver(el, options, vnode) // Store the current modifiers on the object (cloned) - el[PROP_NAME]._prevModifiers = { ...modifiers } + el[OBSERVER_PROP_NAME]._prevModifiers = { ...modifiers } } // When the directive options may have been updated (or element) -const update = (el, { value, oldValue, modifiers }, vnode) => { +const componentUpdated = (el, { value, oldValue, modifiers }, vnode) => { // Compare value/oldValue and modifiers to see if anything has changed // and if so, destroy old observer and create new observer /* istanbul ignore next */ if ( value !== oldValue || - !el[PROP_NAME] || - !looseEqual(modifiers, el[PROP_NAME]._prevModifiers) + !el[OBSERVER_PROP_NAME] || + !looseEqual(modifiers, el[OBSERVER_PROP_NAME]._prevModifiers) ) { // Re-bind on element bind(el, { value, modifiers }, vnode) @@ -177,6 +176,6 @@ const unbind = el => { // Export the directive export const VBVisible = { bind, - update, + componentUpdated, unbind }