diff --git a/src/image/Image.tsx b/src/image/Image.tsx index e472ee2..a25a6c3 100644 --- a/src/image/Image.tsx +++ b/src/image/Image.tsx @@ -62,18 +62,12 @@ export const Image = ({ return; } else { // Use IntersectionObserver as fallback - if (observer.current) observer.current.disconnect(); - if (imgRef.current) { addIntersectionObserver(); } return () => { if (observer.current) { - if (imgRef.current) { - observer.current.unobserve(imgRef.current); - } - observer.current.disconnect(); } }; diff --git a/src/image/Picture.tsx b/src/image/Picture.tsx index 0f340b1..adfc488 100644 --- a/src/image/Picture.tsx +++ b/src/image/Picture.tsx @@ -52,13 +52,9 @@ export const Picture = forwardRef( ) => { const splitSrc = src?.split('/f/'); const webpSrc = `${splitSrc[0]}/filters:format(webp)/f/${splitSrc[1]}`; - let webpSrcset = srcSet || webpSrc; - - if (webpSrcset) { - webpSrcset = webpSrcset - .replace(/\/filters:(.*)\/f\//gm, '/filters:$1:format(webp)/f/') - .replace(/\/(?!filters:)([^/]*)\/f\//gm, '/$1/filters:format(webp)/f/'); - } + const webpSrcset = (srcSet || webpSrc) + .replace(/\/filters:(.*)\/f\//gm, '/filters:$1:format(webp)/f/') + .replace(/\/(?!filters:)([^/]*)\/f\//gm, '/$1/filters:format(webp)/f/'); return ( diff --git a/src/image/__tests__/Image.test.tsx b/src/image/__tests__/Image.test.tsx new file mode 100644 index 0000000..d9a3486 --- /dev/null +++ b/src/image/__tests__/Image.test.tsx @@ -0,0 +1,213 @@ +import * as React from 'react'; +import { cleanup, render, act, screen, waitFor } from '@testing-library/react'; +import { unmountComponentAtNode } from 'react-dom'; + +import { createIntersectionObserver } from '../createIntersectionObserver'; +import * as helpers from '../helpers'; +import { Image } from '../Image'; + +const storyblokImage = + 'https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg'; + +jest.mock('../createIntersectionObserver'); + +describe('[image] Image', () => { + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it('should render an image with the src to load', async () => { + act(() => { + render(flowers); + }); + + expect(screen.getByAltText('')).toHaveStyle('opacity: 1'); + + expect(screen.getByAltText('flowers')).not.toHaveAttribute('src'); + expect(screen.getByAltText('flowers')).toHaveAttribute('data-src'); + }); + + it('should let native loading handle loading if supported', async () => { + global.HTMLImageElement.prototype.loading = 'lazy'; + + act(() => { + render(flowers); + }); + + expect(screen.getByAltText('flowers')).toHaveAttribute('src'); + }); + + it('should use io as loading fallback', async () => { + global.HTMLImageElement.prototype.loading = undefined; + delete global.HTMLImageElement.prototype.loading; + + const setLoadingMock = jest.fn(); + + jest + .spyOn(React, 'useState') + .mockImplementationOnce(() => [false, setLoadingMock]); + + const disconnect = jest.fn(); + const createIoMock = jest.fn(() => ({ + disconnect, + })); + + (createIntersectionObserver as jest.Mock).mockReset(); + (createIntersectionObserver as jest.Mock).mockImplementation(createIoMock); + + act(() => { + render(flowers); + }); + + await waitFor(() => + expect(createIntersectionObserver as jest.Mock).toHaveBeenCalled(), + ); + + act(() => { + ((createIntersectionObserver as jest.Mock).mock as any).calls[0][1](); + }); + + expect(setLoadingMock).toHaveBeenCalledWith(true); + }); + + it('should disconnect io on unmount', async () => { + global.HTMLImageElement.prototype.loading = undefined; + delete global.HTMLImageElement.prototype.loading; + + const container = document.createElement('div'); + document.body.appendChild(container); + + jest.spyOn(React, 'useRef').mockReturnValueOnce({ + current: { src: storyblokImage }, + }); + + const disconnect = jest.fn(); + + (createIntersectionObserver as jest.Mock).mockImplementation(() => ({ + disconnect, + })); + + act(() => { + render(flowers, { container }); + }); + + expect(disconnect).not.toHaveBeenCalled(); + + unmountComponentAtNode(container); + + await waitFor(() => expect(disconnect).toHaveBeenCalled()); + }); + + it('should not add io if already loading', async () => { + global.HTMLImageElement.prototype.loading = undefined; + + const disconnect = jest.fn(); + const createIoMock = jest.fn(() => ({ + disconnect, + })); + + (createIntersectionObserver as jest.Mock).mockImplementation(createIoMock); + + act(() => { + render(flowers); + }); + + expect(createIoMock).not.toHaveBeenCalled(); + }); + + it('should not add io if no image ref', async () => { + global.HTMLImageElement.prototype.loading = undefined; + delete global.HTMLImageElement.prototype.loading; + + const disconnect = jest.fn(); + const createIoMock = jest.fn(() => ({ + disconnect, + })); + + (createIntersectionObserver as jest.Mock).mockReset(); + (createIntersectionObserver as jest.Mock).mockImplementation(createIoMock); + + let ref = {} as any; + Object.defineProperty(ref, 'current', { + get: jest.fn(() => false), + set: jest.fn(), + }); + jest.spyOn(React, 'useRef').mockReturnValue(ref); + + act(() => { + render(flowers); + }); + + await waitFor(() => + expect(createIntersectionObserver as jest.Mock).not.toHaveBeenCalled(), + ); + + ref = {}; + Object.defineProperty(ref, 'current', { + get: jest.fn(() => true), + set: jest.fn(), + }); + jest.spyOn(React, 'useRef').mockReturnValueOnce(ref); + + act(() => { + render(flowers); + }); + + await waitFor(() => + expect(createIntersectionObserver as jest.Mock).toHaveBeenCalled(), + ); + }); + + it('should hide placeholder on load', async () => { + global.HTMLImageElement.prototype.loading = 'lazy'; + + jest.spyOn(helpers, 'useImageLoader').mockImplementation(() => ({ + onLoad: jest.fn(), + isLoaded: true, + setLoaded: jest.fn(), + })); + + act(() => { + render(flowers); + }); + + expect(screen.getByAltText('')).toHaveStyle('opacity: 0'); + expect(screen.getByAltText('flowers')).toHaveAttribute('src'); + expect(screen.getByAltText('flowers')).not.toHaveAttribute('data-src'); + }); + + it('should set loaded if img complete', async () => { + global.HTMLImageElement.prototype.loading = 'lazy'; + + const setLoaded = jest.fn(); + + jest.spyOn(console, 'error').mockImplementation(jest.fn()); + + jest.spyOn(helpers, 'useImageLoader').mockImplementation(() => ({ + onLoad: jest.fn(), + isLoaded: false, + setLoaded, + })); + + jest.spyOn(React, 'useRef').mockImplementation(() => ({ + current: { src: 'image.png', complete: true }, + })); + + act(() => { + render(flowers); + }); + + expect(setLoaded).toHaveBeenCalled(); + }); + + it('should render null if src is not a storyblok asset', async () => { + jest.spyOn(console, 'error').mockImplementation(jest.fn()); + + act(() => { + render(); + }); + + expect(screen.queryByTestId('img')).toBeNull(); + }); +}); diff --git a/src/image/__tests__/Picture.test.tsx b/src/image/__tests__/Picture.test.tsx new file mode 100644 index 0000000..02470bc --- /dev/null +++ b/src/image/__tests__/Picture.test.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { cleanup, render, act, screen } from '@testing-library/react'; + +import { Picture } from '../Picture'; + +const storyblokImage = + 'https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg'; + +describe('[image] Picture', () => { + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it('should not load src initially', async () => { + act(() => { + render(); + }); + + expect(screen.getByAltText('flowers')).not.toHaveAttribute('src'); + expect(screen.getByAltText('flowers')).toHaveAttribute('data-src'); + }); + + it('should set alt to empty string if undefined', async () => { + act(() => { + render(); + }); + + expect(screen.getByAltText('')).toBeInTheDocument(); + }); + + it('should add webp srcset', async () => { + act(() => { + render(); + }); + + expect( + screen.getByAltText('flowers').parentElement.childNodes[0], + ).toHaveAttribute('type', 'image/webp'); + expect( + (screen.getByAltText('flowers').parentElement + .childNodes[0] as any).getAttribute('srcSet'), + ).toMatch(/filters:format\(webp\)/); + }); + + it('should load eager if not lazy', async () => { + act(() => { + render(); + }); + + expect(screen.getByAltText('flowers')).toHaveAttribute('loading', 'eager'); + }); +}); diff --git a/src/image/__tests__/createIntersectionObserver.test.tsx b/src/image/__tests__/createIntersectionObserver.test.tsx new file mode 100644 index 0000000..8e018f7 --- /dev/null +++ b/src/image/__tests__/createIntersectionObserver.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { cleanup, render, act } from '@testing-library/react'; + +import { createIntersectionObserver } from '../createIntersectionObserver'; + +describe('[image] createIntersectionObserver', () => { + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it('should polyfill io if needed', async () => { + const callbackMock = jest.fn(); + const component =
; + + act(() => { + render(component); + }); + + createIntersectionObserver(document.querySelector('#test'), callbackMock); + }); + + it('should trigger if visible', async () => { + const callbackMock = jest.fn(); + let callback; + const component =
; + + const observe = jest.fn(); + const unobserve = jest.fn(); + const disconnect = jest.fn(); + const ioMock = jest.fn((cb) => { + callback = cb; + + return { + observe, + unobserve, + disconnect, + }; + }); + + window.IntersectionObserver = ioMock as any; + + act(() => { + render(component); + }); + + const target = document.querySelector('#test'); + createIntersectionObserver(target as any, callbackMock); + + callback([{ target, isIntersecting: true }]); + + expect(callbackMock).toHaveBeenCalled(); + }); + + it('should not trigger if not visible', async () => { + const callbackMock = jest.fn(); + let callback; + const component =
; + + const observe = jest.fn(); + const unobserve = jest.fn(); + const disconnect = jest.fn(); + const ioMock = jest.fn((cb) => { + callback = cb; + + return { + observe, + unobserve, + disconnect, + }; + }); + + window.IntersectionObserver = ioMock as any; + + act(() => { + render(component); + }); + + const target = document.querySelector('#test'); + createIntersectionObserver(target as any, callbackMock); + + callback([ + { target: document.querySelector('body'), isIntersecting: true }, + ]); + + expect(callbackMock).not.toHaveBeenCalled(); + }); + + it('should not trigger if not intersecting', async () => { + const callbackMock = jest.fn(); + let callback; + const component =
; + + const observe = jest.fn(); + const unobserve = jest.fn(); + const disconnect = jest.fn(); + const ioMock = jest.fn((cb) => { + callback = cb; + + return { + observe, + unobserve, + disconnect, + }; + }); + + window.IntersectionObserver = ioMock as any; + + act(() => { + render(component); + }); + + const target = document.querySelector('#test'); + createIntersectionObserver(target as any, callbackMock); + + callback([{ target, isIntersecting: false }]); + + expect(callbackMock).not.toHaveBeenCalled(); + }); + + it('should have different rootmargin based on connection speed', async () => { + const callbackMock = jest.fn(); + const optionsMock = jest.fn(); + const component =
; + + const observe = jest.fn(); + const ioMock = jest.fn((_, options) => { + optionsMock(options); + + return { + observe, + }; + }); + + window.IntersectionObserver = ioMock as any; + + act(() => { + render(component); + }); + + const target = document.querySelector('#test'); + createIntersectionObserver(target as any, callbackMock); + + expect(optionsMock).toHaveBeenLastCalledWith({ rootMargin: '2500px' }); + + Object.defineProperty(window.navigator, 'connection', { + value: { + effectiveType: '4g', + }, + }); + + createIntersectionObserver(target as any, callbackMock); + + expect(optionsMock).toHaveBeenLastCalledWith({ rootMargin: '1250px' }); + }); +}); diff --git a/src/image/__tests__/getImageProps.test.ts b/src/image/__tests__/getImageProps.test.ts new file mode 100644 index 0000000..f155d1b --- /dev/null +++ b/src/image/__tests__/getImageProps.test.ts @@ -0,0 +1,59 @@ +import { getImageProps } from '../getImageProps'; + +const storyblokImage = + 'https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg'; + +describe('[image] getImageProps', () => { + it('should return normal src if fixed and fluid not set', async () => { + const props = getImageProps(storyblokImage); + + expect(props.src).toBeDefined(); + expect(props.width).toBe(3310); + expect(props.height).toBe(2192); + }); + + it('should optimize props for fixed', async () => { + const props = getImageProps(storyblokImage, { fixed: [200, 200] }); + + expect(props.src).toBeDefined(); + expect(props.srcSet).toContain(' 1x'); + expect(props.srcSet).toContain(' 2x'); + expect(props.srcSet).toContain(' 3x'); + expect(props.width).toBe(3310); + expect(props.height).toBe(2192); + }); + + it('should optimize props for fluid', async () => { + const props = getImageProps(storyblokImage, { fluid: 1080 }); + + expect(props.src).toBeDefined(); + expect(props.sizes).toBeDefined(); + expect(props.srcSet).toMatch(/(.*\dw.*){5}/gim); + expect(props.width).toBe(3310); + expect(props.height).toBe(2192); + }); + + it('should not put fluid sizes that are larger than original', async () => { + const props = getImageProps(storyblokImage, { fluid: 5000 }); + + expect(props.srcSet).toMatch(/(.*\dw.*){3}/gim); + }); + + it('should support width and height fluid', async () => { + const props = getImageProps(storyblokImage, { fluid: [1920, 1080] }); + + expect(props.srcSet).toContain('x1080'); + }); + + it('should not set smart filter if configured', async () => { + const props = getImageProps(storyblokImage, { smart: false }); + + expect(props.src).not.toContain('/smart'); + }); + + it('should return empty props if no src', async () => { + const props = getImageProps(''); + + expect(props).toMatchObject({}); + }); +}); diff --git a/src/image/__tests__/helpers.test.tsx b/src/image/__tests__/helpers.test.tsx new file mode 100644 index 0000000..8d72e9a --- /dev/null +++ b/src/image/__tests__/helpers.test.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { cleanup, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useImageLoader } from '../helpers'; + +const currentTarget = document.createElement('img'); +currentTarget.src = 'test'; +const event = { currentTarget } as any; + +describe('[bridge] helpers: useImageLoader', () => { + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it('should load image on load', async () => { + const setLoadedMock = jest.fn(); + jest + .spyOn(React, 'useState') + .mockImplementation(() => [false, setLoadedMock]); + + jest.spyOn(global, 'Image').mockImplementation(() => ({} as any)); + + const { result } = renderHook(() => useImageLoader()); + + await act(async () => { + result.current.onLoad(event); + + await waitFor(() => expect(result.current.isLoaded).toBeTruthy()); + }); + }); + + it('should decode image on load if needed', async () => { + const setLoadedMock = jest.fn(); + jest + .spyOn(React, 'useState') + .mockImplementation(() => [false, setLoadedMock]); + + jest.spyOn(global, 'Image').mockImplementation( + () => + ({ + decode: () => + new Promise((resolve) => { + resolve(); + }), + } as any), + ); + + const { result } = renderHook(() => useImageLoader()); + + await act(async () => { + result.current.onLoad(event); + + await waitFor(() => expect(result.current.isLoaded).toBeTruthy()); + }); + }); + + it('should not load image if already loaded', async () => { + const imgMock = jest.fn(() => ({} as any)); + jest.spyOn(global, 'Image').mockImplementation(imgMock); + + const { result } = renderHook(() => useImageLoader()); + + await act(async () => { + result.current.setLoaded(true); + + await waitFor(() => expect(result.current.isLoaded).toBeTruthy()); + + result.current.onLoad(event); + + expect(imgMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/image/createIntersectionObserver.ts b/src/image/createIntersectionObserver.ts index c096589..32362ef 100644 --- a/src/image/createIntersectionObserver.ts +++ b/src/image/createIntersectionObserver.ts @@ -40,5 +40,6 @@ export const createIntersectionObserver = async ( // Add element to the observer io.observe(el); + return io; };