├── src ├── interface.ts ├── utils │ └── index.ts ├── tests │ ├── utils.spec.ts │ └── index.spec.tsx └── index.tsx ├── .npmignore ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json └── .circleci └── config.yml /src/interface.ts: -------------------------------------------------------------------------------- 1 | import { ImgHTMLAttributes } from "react"; 2 | 3 | export interface LazyLoadProps extends Partial> { 4 | ratio: number; 5 | src: string; 6 | placeholder: string; 7 | force?: boolean; 8 | onVisible?: Function; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { LazyLoadProps } from "../interface"; 2 | 3 | export const getReturnProps = (props: LazyLoadProps) => { 4 | const returnProps = { ...props }; 5 | delete returnProps.onVisible; 6 | delete returnProps.force; 7 | 8 | return returnProps; 9 | }; 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | test/ 3 | node_modules/ 4 | coverage/ 5 | tests/ 6 | .vscode/ 7 | .dockerignore 8 | .gitignore 9 | .npmrc 10 | .prettierrc 11 | .yarnrc 12 | jest.config.jest 13 | tsconfig.json 14 | yarn.lock 15 | .DS_Store 16 | .editorconfig 17 | jest.config.js 18 | tslint.json 19 | webpack.config.js 20 | .circleci -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "CommonJs", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": false, 20 | "jsx": "react", 21 | "outDir": "./dist", 22 | "declaration": true, 23 | "declarationDir": "./dist/types", 24 | "types": [] 25 | }, 26 | "include": [ 27 | "src/index.tsx", 28 | "src/utils/index.tsx" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/tests/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { getReturnProps } from "../utils"; 2 | import faker from "faker"; 3 | import { LazyLoadProps } from "../interface"; 4 | import sinon from "sinon"; 5 | 6 | const sandbox = sinon.createSandbox(); 7 | 8 | describe("Utils tests", () => { 9 | it("should return props without onVisible & force", () => { 10 | // Arrange 11 | let props: LazyLoadProps; 12 | props = { 13 | ratio: 0.1, 14 | placeholder: faker.random.word(), 15 | force: true, 16 | onVisible: sandbox.stub(), 17 | src: faker.random.word(), 18 | }; 19 | 20 | // Act 21 | const returnProps = getReturnProps(props); 22 | 23 | // Assert 24 | expect(returnProps.onVisible).toBeUndefined(); 25 | expect(returnProps.force).toBeUndefined(); 26 | expect(returnProps.src).toBe(props.src); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Lazy Images 2 | 3 | React lazy load images with IntersectionObserver. 4 | 5 | ## Usage 6 | 7 | ```js 8 | import React from 'react'; 9 | import Lazy from "react-intersection-images" 10 | 11 | const App = () => { 12 | const placeholder = "https://picsum.photos/id/237/500/300" 13 | 14 | const generateImg = () => `https://picsum.photos/id/${Math.floor(Math.random() * 999)}/500/300` 15 | 16 | return ( 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | ); 26 | } 27 | 28 | export default App; 29 | 30 | ``` 31 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/alt-text */ 2 | import React, { useState, useRef } from "react"; 3 | import { LazyLoadProps } from "./interface"; 4 | import { getReturnProps } from "./utils"; 5 | 6 | function Lazy(props: LazyLoadProps): JSX.Element { 7 | const returnProps = getReturnProps(props); 8 | 9 | const [currentSrc, setCurrentSrc] = useState( 10 | props.force ? props.src : props.placeholder 11 | ); 12 | const el = useRef(null); 13 | 14 | const handleChange = ([root]: any) => { 15 | if ( 16 | root.intersectionRatio > Number(props.ratio) && 17 | root.isIntersecting === true 18 | ) { 19 | setCurrentSrc(props.src); 20 | observer.disconnect(); 21 | if (props.onVisible) props.onVisible(); 22 | } 23 | }; 24 | 25 | if (typeof window === "undefined" || !("IntersectionObserver" in window)) { 26 | return ; 27 | } 28 | 29 | const observer = new IntersectionObserver(handleChange, { 30 | threshold: props.ratio, 31 | }); 32 | 33 | const handleObserve = () => { 34 | observer.observe(el.current as any); 35 | }; 36 | 37 | return ( 38 | 45 | ); 46 | } 47 | 48 | export default Lazy; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-intersection-images", 3 | "description": "React lazy loading images with Intersection Observer", 4 | "author": "baris.esen@trendyol.com", 5 | "version": "0.4.1", 6 | "main": "dist/", 7 | "types": "dist/types/index.d.ts", 8 | "repository": "https://github.com/Trendyol/react-intersection-images", 9 | "dependencies": { 10 | "react": "16.9.0", 11 | "react-dom": "16.9.0", 12 | "react-scripts": "^3.4.1", 13 | "typescript": "^3.9.6" 14 | }, 15 | "scripts": { 16 | "build-tsc": "tsc", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "cov": "react-scripts test --coverage", 20 | "prettier:fix": " prettier --write src" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | }, 37 | "devDependencies": { 38 | "@testing-library/jest-dom": "^5.11.0", 39 | "@testing-library/react": "^10.4.4", 40 | "@testing-library/user-event": "^7.1.2", 41 | "@types/faker": "^4.1.12", 42 | "@types/node": "^14.0.19", 43 | "@types/react": "^16.9.41", 44 | "@types/react-dom": "^16.9.8", 45 | "@types/sinon": "^9.0.4", 46 | "faker": "^4.1.0", 47 | "prettier": "2.0.5", 48 | "sinon": "^9.0.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | defaults: &defaults 4 | docker: 5 | - image: circleci/node:10 6 | 7 | jobs: 8 | test: 9 | <<: *defaults 10 | steps: 11 | - add_ssh_keys: 12 | fingerprints: 13 | - "91:fb:bf:33:f7:8a:b5:9e:99:c1:64:3a:f3:5b:c1:df" 14 | - checkout 15 | - restore_cache: 16 | keys: 17 | - v1-dependencies-{{ checksum "package.json" }} 18 | - v1-dependencies- 19 | - run: npm install 20 | - run: 21 | name: Run build 22 | command: npm run build-tsc 23 | - save_cache: 24 | paths: 25 | - node_modules 26 | key: v1-dependencies-{{ checksum "package.json" }} 27 | - run: npm build 28 | - persist_to_workspace: 29 | root: . 30 | paths: 31 | - README.md 32 | - CHANGELOG.md 33 | - LICENSE 34 | - package.json 35 | - package-lock.json 36 | - .npmignore 37 | - dist 38 | deploy: 39 | <<: *defaults 40 | steps: 41 | - attach_workspace: 42 | at: . 43 | - run: 44 | name: List Workspace 45 | command: ls 46 | - run: 47 | name: Authenticate with registry 48 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc 49 | - run: 50 | name: Publish package 51 | command: npm publish 52 | beta_deploy: 53 | <<: *defaults 54 | steps: 55 | - attach_workspace: 56 | at: . 57 | - run: 58 | name: List Workspace 59 | command: ls 60 | - run: 61 | name: Authenticate with registry 62 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc 63 | - run: 64 | name: Publish package 65 | command: npm publish --tag beta 66 | 67 | workflows: 68 | version: 2 69 | test: 70 | jobs: 71 | - test 72 | test-deploy: 73 | jobs: 74 | - test: 75 | filters: 76 | tags: 77 | only: /^v.*/ 78 | - hold: 79 | type: approval 80 | requires: 81 | - test 82 | filters: 83 | branches: 84 | only: master 85 | - deploy: 86 | requires: 87 | - hold 88 | filters: 89 | branches: 90 | only: master 91 | test-betadeploy: 92 | jobs: 93 | - test: 94 | filters: 95 | tags: 96 | only: /^v.*/ 97 | - hold: 98 | type: approval 99 | requires: 100 | - test 101 | filters: 102 | branches: 103 | only: master 104 | - beta_deploy: 105 | requires: 106 | - hold 107 | filters: 108 | branches: 109 | only: master 110 | -------------------------------------------------------------------------------- /src/tests/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, fireEvent } from "@testing-library/react"; 3 | import Lazy from "../index"; 4 | import { LazyLoadProps } from "../interface"; 5 | import faker from "faker"; 6 | import sinon, { SinonSpy, SinonStub } from "sinon"; 7 | import { act } from "react-dom/test-utils"; 8 | 9 | const sandbox = sinon.createSandbox(); 10 | 11 | describe("Lazy tests", () => { 12 | let props: LazyLoadProps; 13 | 14 | let observeStub: SinonSpy; 15 | let unobserveStub: SinonSpy; 16 | let disconnectStub: SinonSpy; 17 | 18 | beforeEach(() => { 19 | props = { 20 | placeholder: faker.image.imageUrl(200), 21 | src: faker.image.imageUrl(300), 22 | ratio: 0.1, 23 | force: false, 24 | }; 25 | }); 26 | 27 | describe("Not support intersectionObserver environment", () => { 28 | it("should return image without lazy loading on one image", () => { 29 | // Act 30 | const { container } = render(); 31 | 32 | // Assert 33 | expect(container.getElementsByTagName("img").length).toBe(1); 34 | expect(container.getElementsByTagName("img")[0].src).toBe(props.src); 35 | }); 36 | 37 | it("should return image without lazy loading on multiple images", () => { 38 | // Act 39 | const { container } = render( 40 |
41 | 42 | 43 | 44 |
45 | ); 46 | 47 | // Assert 48 | expect(container.getElementsByTagName("img")[0].src).toBe(props.src); 49 | expect(container.getElementsByTagName("img")[2].src).toBe(props.src); 50 | }); 51 | }); 52 | 53 | describe("IntersectionObserver supported environment", () => { 54 | beforeEach(() => { 55 | observeStub = sandbox.spy(); 56 | unobserveStub = sandbox.spy(); 57 | disconnectStub = sandbox.spy(); 58 | 59 | (window.IntersectionObserver as any) = sandbox.spy(() => ({ 60 | observe: observeStub, 61 | unobserve: unobserveStub, 62 | disconnect: disconnectStub, 63 | })); 64 | }); 65 | 66 | afterEach(() => { 67 | sandbox.verifyAndRestore(); 68 | delete window.IntersectionObserver; 69 | }); 70 | 71 | it("should render images with placeholder", () => { 72 | // Act 73 | const { container } = render(); 74 | 75 | // Assert 76 | expect(container.getElementsByTagName("img")[0].src).toBe( 77 | props.placeholder 78 | ); 79 | expect(container.getElementsByTagName("img")[0].dataset.src).toBe( 80 | props.src 81 | ); 82 | }); 83 | 84 | it("should render without placeholder on force props is true", () => { 85 | props.force = true; 86 | 87 | // Act 88 | const { container } = render(); 89 | 90 | // Assets 91 | expect(container.getElementsByTagName("img")[0].src).toBe(props.src); 92 | }); 93 | 94 | it("should call observer.observe method on img onload event fired", () => { 95 | // Act 96 | const { container } = render(); 97 | fireEvent.load(container.getElementsByTagName("img")[0]); 98 | 99 | // Assert 100 | expect(observeStub.calledOnce).toBe(true); 101 | }); 102 | 103 | it("should show loaded image on observer intersectionRatio bigger than props.ratio", () => { 104 | // Arrange 105 | const intersectionObeserverSpy = (window.IntersectionObserver as unknown) as SinonSpy; 106 | 107 | // Act 108 | const { container } = render(); 109 | 110 | act(() => { 111 | intersectionObeserverSpy.getCalls()[0].args[0]([ 112 | { 113 | intersectionRatio: 0.3, 114 | isIntersecting: true, 115 | }, 116 | ]); 117 | }); 118 | 119 | // Assert 120 | expect(container.getElementsByTagName("img")[0].src).toBe(props.src); 121 | expect(disconnectStub.calledOnce).toBe(true); 122 | }); 123 | 124 | it("should show placeholder image on observer intersectionRatio bigger than props.ratio & isIntersecting is false", () => { 125 | // Arrange 126 | const intersectionObeserverSpy = (window.IntersectionObserver as unknown) as SinonSpy; 127 | 128 | // Act 129 | const { container } = render(); 130 | 131 | act(() => { 132 | intersectionObeserverSpy.getCalls()[0].args[0]([ 133 | { 134 | intersectionRatio: 0.3, 135 | isIntersecting: false, 136 | }, 137 | ]); 138 | }); 139 | 140 | // Assert 141 | expect(container.getElementsByTagName("img")[0].src).toBe( 142 | props.placeholder 143 | ); 144 | expect(disconnectStub.calledOnce).toBe(false); 145 | }); 146 | 147 | it("should call onVisible callback on observer intersectionRatio bigger than props.ratio", () => { 148 | // Arrange 149 | const intersectionObeserverSpy = (window.IntersectionObserver as unknown) as SinonSpy; 150 | props.onVisible = sandbox.stub(); 151 | 152 | // Act 153 | render(); 154 | 155 | act(() => { 156 | intersectionObeserverSpy.getCalls()[0].args[0]([ 157 | { 158 | intersectionRatio: 0.3, 159 | isIntersecting: true, 160 | }, 161 | ]); 162 | }); 163 | 164 | // Assert 165 | expect((props.onVisible as SinonStub).calledOnce).toBe(true); 166 | }); 167 | }); 168 | }); 169 | --------------------------------------------------------------------------------