├── example ├── .npmignore ├── index.html ├── tsconfig.json ├── package.json └── index.tsx ├── media ├── demo.gif ├── header.png └── logo.png ├── src ├── loaders │ ├── index.ts │ ├── breathing │ │ ├── styles.css │ │ ├── Breathing.tsx │ │ └── README.md │ └── shimmer │ │ ├── styles.css │ │ ├── README.md │ │ └── Shimmer.tsx ├── index.ts ├── anims │ └── anims.css ├── IntendedError.ts └── Image.tsx ├── tsdx.config.js ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── main.yml ├── tsconfig.json ├── package.json └── README.md /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /media/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gokcan/react-shimmer/HEAD/media/demo.gif -------------------------------------------------------------------------------- /media/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gokcan/react-shimmer/HEAD/media/header.png -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gokcan/react-shimmer/HEAD/media/logo.png -------------------------------------------------------------------------------- /src/loaders/index.ts: -------------------------------------------------------------------------------- 1 | export { Shimmer, ShimmerProps } from './shimmer/Shimmer'; 2 | export { Breathing, BreathingProps } from './breathing/Breathing'; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { SuspenseImage as Image } from './Image'; 2 | export { ImageProps } from './Image'; 3 | 4 | // Loaders 5 | export * from './loaders'; 6 | -------------------------------------------------------------------------------- /tsdx.config.js: -------------------------------------------------------------------------------- 1 | const postcss = require('rollup-plugin-postcss'); 2 | 3 | module.exports = { 4 | rollup(config) { 5 | config.plugins.push(postcss()); 6 | return config; 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/anims/anims.css: -------------------------------------------------------------------------------- 1 | .fadein { 2 | animation-name: fadein; 3 | animation-duration: 0.3s; 4 | animation-fill-mode: forwards; 5 | } 6 | 7 | @keyframes fadein { 8 | from { opacity: 0; } 9 | to { opacity: 1; } 10 | } 11 | -------------------------------------------------------------------------------- /src/IntendedError.ts: -------------------------------------------------------------------------------- 1 | export default class IntendedError extends Error { 2 | createdAt: Date; 3 | intention: string; 4 | 5 | constructor(intention = 'forcePromiseReject', ...params: undefined[]) { 6 | super(...params); 7 | this.createdAt = new Date(); 8 | this.intention = intention; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/loaders/breathing/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --default-bg-color: #e1e2e4; 3 | } 4 | 5 | .breathing { 6 | width: 100%; 7 | height: 100%; 8 | background: var(--default-bg-color); 9 | animation: breathing ease-in-out infinite alternate; 10 | } 11 | 12 | @keyframes breathing { 13 | from { 14 | opacity: 0.25; 15 | } 16 | to { 17 | opacity: 1; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | react-shimmer 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # IDE Stuff 4 | /.idea 5 | /.vscode 6 | 7 | # dependencies 8 | node_modules 9 | 10 | # testing 11 | /coverage 12 | 13 | # builds 14 | build 15 | dist 16 | .rpt2_cache 17 | .parcel-cache 18 | 19 | # misc 20 | .DS_Store 21 | .env 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-shimmer-example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^2.0.0-beta.2", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/loaders/shimmer/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --default-bg-color: #f6f7f8; 3 | --default-bg-moving-gradient: linear-gradient(to right, rgb(238, 238, 238) 8%, rgb(222, 222, 222) 18%, rgb(238, 238, 238) 33%); 4 | } 5 | 6 | .shimmer { 7 | background: var(--default-bg-color); 8 | background-image: var(--default-bg-moving-gradient); 9 | background-repeat: no-repeat; 10 | animation: shimmering forwards infinite ease-in-out, fadein 0.02s forwards; 11 | } 12 | 13 | @keyframes fadein { 14 | from { 15 | opacity: 0; 16 | } 17 | 18 | to { 19 | opacity: 1; 20 | } 21 | } 22 | 23 | @keyframes shimmering { 24 | from { 25 | background-position: top right; 26 | } 27 | 28 | to { 29 | background-position: top left; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/loaders/breathing/Breathing.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import clsx from 'clsx'; 4 | 5 | import './styles.css'; 6 | 7 | export interface BreathingProps { 8 | className?: string; 9 | duration?: number; 10 | height?: number; 11 | width?: number; 12 | } 13 | 14 | const DEFAULT_DURATION_MS = 1000; 15 | 16 | export const Breathing = ({ 17 | className, 18 | duration = DEFAULT_DURATION_MS, 19 | height, 20 | width, 21 | }: BreathingProps) => { 22 | const style = { 23 | height, 24 | width, 25 | animationDuration: `${(duration / 1000).toFixed(1)}s`, 26 | }; 27 | return
; 28 | }; 29 | 30 | Breathing.propTypes = { 31 | className: PropTypes.string, 32 | duration: PropTypes.number, 33 | height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 34 | width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 35 | }; 36 | -------------------------------------------------------------------------------- /src/loaders/breathing/README.md: -------------------------------------------------------------------------------- 1 | ### Breathing Loader 2 | 3 | ## Usage 4 | 5 | ```jsx 6 | import React from 'react' 7 | import Image, { Breathing } from 'react-shimmer' 8 | 9 | function App(props) { 10 | return ( 11 |
12 | } 15 | /> 16 |
17 | ) 18 | } 19 | ``` 20 | 21 | or as a standalone placeholder: 22 | 23 | ```jsx 24 | import React from 'react' 25 | import { Breathing } from 'react-shimmer' 26 | 27 | function App(props) { 28 | return ( 29 |
30 | 31 |
32 | ) 33 | } 34 | ``` 35 | 36 | ### Properties 37 | 38 | Property | Type | Required | Default value | Description 39 | :--- | :--- | :--- | :--- | :--- 40 | `className`|string|no|| Override default styles with className 41 | `width`|number|no|| 42 | `height`|number|no|| 43 | `duration`|number|no|`1000`| Animation duration (ms) 44 | ----- 45 | 46 | ## License 47 | 48 | MIT © [gokcan](https://github.com/gokcan) 49 | -------------------------------------------------------------------------------- /src/loaders/shimmer/README.md: -------------------------------------------------------------------------------- 1 | ### Shimmer Loader 2 | 3 | ## Usage 4 | 5 | ```jsx 6 | import React from 'react' 7 | import Image, { Shimmer } from 'react-shimmer' 8 | 9 | function App(props) { 10 | return ( 11 |
12 | } 15 | /> 16 |
17 | ) 18 | } 19 | ``` 20 | 21 | or as a standalone placeholder: 22 | 23 | ```jsx 24 | import React from 'react' 25 | import { Shimmer } from 'react-shimmer' 26 | 27 | function App(props) { 28 | return ( 29 |
30 | 31 |
32 | ) 33 | } 34 | ``` 35 | 36 | ### Properties 37 | 38 | Property | Type | Required | Default value | Description 39 | :--- | :--- | :--- | :--- | :--- 40 | `width`|number|yes|| 41 | `height`|number|yes|| 42 | `className`|string|no|| Override default styles with className 43 | `duration`|number|no|`1600`| Animation duration (ms) 44 | ----- 45 | 46 | ## License 47 | 48 | MIT © [gokcan](https://github.com/gokcan) 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gökcan Değirmenci 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/loaders/shimmer/Shimmer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import clsx from 'clsx'; 4 | 5 | import './styles.css'; 6 | 7 | const DEFAULT_DURATION_MS = 1600; 8 | const DEFAULT_HEIGHT = 400; 9 | const DEFAULT_WIDTH = 400; 10 | 11 | export interface ShimmerProps { 12 | height: number; 13 | width: number; 14 | className?: string; 15 | duration?: number; 16 | } 17 | 18 | const calcShimmerStyle = ( 19 | width: number, 20 | height: number, 21 | duration = DEFAULT_DURATION_MS 22 | ) => ({ 23 | backgroundSize: `${width * 10}px ${height}px`, 24 | animationDuration: `${(duration / 1000).toFixed(1)}s`, 25 | }); 26 | 27 | export const Shimmer = ({ 28 | className, 29 | duration, 30 | height = DEFAULT_HEIGHT, 31 | width = DEFAULT_WIDTH, 32 | }: ShimmerProps) => { 33 | const shimmerStyle = calcShimmerStyle(width, height, duration); 34 | const style = { ...shimmerStyle, ...{ height, width } }; 35 | 36 | return
; 37 | }; 38 | 39 | Shimmer.propTypes = { 40 | height: PropTypes.number.isRequired, 41 | width: PropTypes.number.isRequired, 42 | className: PropTypes.string, 43 | duration: PropTypes.number, 44 | }; 45 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as ReactDOM from 'react-dom'; 3 | import * as React from 'react'; 4 | import { randomBytes } from 'crypto'; 5 | 6 | import { Image, Shimmer } from '../.'; 7 | 8 | const getRandomSource = () => 9 | `https://picsum.photos/2000/1440?q=${randomBytes(8).toString('hex')}`; 10 | 11 | const App = () => { 12 | const [source, setSource] = React.useState(''); 13 | 14 | React.useEffect(() => { 15 | setRandom(); 16 | }, []); 17 | 18 | const setRandom = () => { 19 | setSource(getRandomSource()); 20 | }; 21 | 22 | return ( 23 |
24 |
33 | } /> 34 |
35 | 42 |
43 | ); 44 | }; 45 | 46 | ReactDOM.render(, document.getElementById('root')); 47 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | 34 | publish: 35 | name: Publish to npm if merged to master 36 | 37 | runs-on: ubuntu-latest 38 | 39 | if: github.ref == 'refs/heads/master' 40 | 41 | needs: build 42 | 43 | steps: 44 | - uses: actions/checkout@v2 45 | 46 | - uses: actions/setup-node@v1 47 | with: 48 | node-version: 14 49 | 50 | - name: Install 51 | run: yarn install 52 | 53 | - name: Publish 54 | run: | 55 | npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} 56 | npm publish 57 | env: 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | // output .d.ts declaration files for consumers 8 | "declaration": true, 9 | // output .js.map sourcemap files for consumers 10 | "sourceMap": true, 11 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 12 | "rootDir": "./src", 13 | // stricter type-checking for stronger correctness. Recommended by TS 14 | "strict": true, 15 | // linter checks for common issues 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | // use Node's module resolution algorithm, instead of the legacy TS one 22 | "moduleResolution": "node", 23 | // transpile JSX to React.createElement 24 | "jsx": "react", 25 | // interop between ESM and CJS modules. Recommended by TS 26 | "esModuleInterop": true, 27 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 28 | "skipLibCheck": true, 29 | // error out if import and file system have a casing mismatch. Recommended by TS 30 | "forceConsistentCasingInFileNames": true, 31 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 32 | "noEmit": true, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-shimmer", 3 | "version": "3.2.0", 4 | "description": "React Image (Suspense-like) Loader component that simulates a shimmer effect", 5 | "author": "gokcan", 6 | "keywords": [ 7 | "react", 8 | "reactjs", 9 | "suspense", 10 | "react-suspense", 11 | "react-image", 12 | "loader", 13 | "loading-indicator", 14 | "shimmer", 15 | "activity-indicator", 16 | "placeholder", 17 | "spinner" 18 | ], 19 | "repository": "gokcan/react-shimmer", 20 | "license": "MIT", 21 | "main": "dist/index.js", 22 | "typings": "dist/index.d.ts", 23 | "files": [ 24 | "dist", 25 | "src" 26 | ], 27 | "engines": { 28 | "node": ">=12" 29 | }, 30 | "scripts": { 31 | "start": "tsdx watch", 32 | "build": "tsdx build", 33 | "test": "tsdx test --passWithNoTests", 34 | "lint": "tsdx lint", 35 | "prepare": "tsdx build" 36 | }, 37 | "peerDependencies": { 38 | "react": ">=16", 39 | "react-dom": ">=16" 40 | }, 41 | "husky": { 42 | "hooks": { 43 | "pre-commit": "tsdx lint" 44 | } 45 | }, 46 | "prettier": { 47 | "printWidth": 80, 48 | "semi": true, 49 | "singleQuote": true, 50 | "trailingComma": "es5" 51 | }, 52 | "module": "dist/react-shimmer.esm.js", 53 | "dependencies": { 54 | "clsx": "^1.1.0", 55 | "prop-types": "^15.7.2" 56 | }, 57 | "devDependencies": { 58 | "@types/prop-types": "^15.7.4", 59 | "@types/react": "^17.0.15", 60 | "@types/react-dom": "^17.0.9", 61 | "husky": "^7.0.1", 62 | "postcss": "^8.3.6", 63 | "react": "^17.0.2", 64 | "react-dom": "^17.0.2", 65 | "rollup-plugin-postcss": "^4.0.0", 66 | "tsdx": "^0.14.1", 67 | "tslib": "^2.3.0", 68 | "typescript": "^4.3.5" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 | 5 | > A powerful, customisable, Suspense-like `` component that (optionally) simulates a [**shimmer**](https://github.com/facebook/Shimmer) effect while __loading__. (with zero dependencies!). 6 | 7 |

8 | 9 | NPM 10 | 11 | 12 | JavaScript Style Guide 13 | 14 | 15 | Github Actions CI Status 16 | 17 | 18 | Maintainability 19 | 20 |

21 | 22 |

23 | Header 24 |

25 | 26 | ### [__Live Demo__](https://codesandbox.io/s/nh9x1) 27 | 28 | ![](https://cdn.rawgit.com/gokcan/react-shimmer/master/media/demo.gif) 29 | 30 | ## Install 31 | 32 | ```bash 33 | npm i react-shimmer 34 | ``` 35 | 36 | or 37 | 38 | ```bash 39 | yarn add react-shimmer 40 | ``` 41 | 42 | ## Usage 43 | 44 | ```jsx 45 | import React from 'react' 46 | import { Image, Shimmer } from 'react-shimmer' 47 | 48 | function App() { 49 | return ( 50 |
51 | } 54 | /> 55 |
56 | ) 57 | } 58 | ``` 59 | 60 | ```jsx 61 | import React from 'react' 62 | import { Image, Breathing } from 'react-shimmer' 63 | 64 | function App() { 65 | return ( 66 |
67 | } 70 | /> 71 |
72 | ) 73 | } 74 | ``` 75 | 76 | or you can use your custom React component as a fallback: 77 | 78 | ```jsx 79 | import React from 'react' 80 | import { Image } from 'react-shimmer' 81 | 82 | import Spinner from './Spinner' 83 | 84 | function App(props) { 85 | return ( 86 |
87 | } 90 | /> 91 |
92 | ) 93 | } 94 | ``` 95 | 96 | ### Properties 97 | 98 | Property | Type | Required | Default value | Description 99 | :--- | :--- | :--- | :--- | :--- 100 | `src`|string|yes|| 101 | `fallback`|ReactNode|yes|| 102 | `errorFallback`|func|no|| 103 | `onLoad`|func|no|| 104 | `delay`|number|no|| Delay in milliseconds before showing the `fallback` 105 | `fadeIn`|bool|no|false|Use built-in fade animation on img 106 | `NativeImgProps`|React.ImgHTMLAttributes|no|| 107 | ----- 108 | 109 | ## Contributing 110 | --- 111 | 112 | Feel free to send PRs. 113 | 114 | ## License 115 | 116 | MIT © [gokcan](https://github.com/gokcan) 117 | -------------------------------------------------------------------------------- /src/Image.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @class SuspenseImage 3 | * @version 3.2.0 4 | * @author github.com/gokcan 5 | */ 6 | 7 | import React, { ReactNode, ImgHTMLAttributes, Component } from 'react'; 8 | import PropTypes from 'prop-types'; 9 | import clsx from 'clsx'; 10 | 11 | import IntendedError from './IntendedError'; 12 | 13 | import './anims/anims.css'; 14 | 15 | export interface ImageProps { 16 | src: string; 17 | fallback: ReactNode; 18 | errorFallback?: (err: string) => ReactNode; 19 | onLoad?: (image: HTMLImageElement) => any; 20 | delay?: number; 21 | fadeIn?: boolean; 22 | NativeImgProps?: ImgHTMLAttributes; 23 | } 24 | 25 | interface State { 26 | isLoading: boolean; 27 | error?: string; 28 | } 29 | 30 | const initialState: State = { 31 | isLoading: false, 32 | error: '', 33 | }; 34 | 35 | export class SuspenseImage extends Component { 36 | static propTypes = { 37 | src: PropTypes.string.isRequired, 38 | fallback: PropTypes.element.isRequired, 39 | errorFallback: PropTypes.func, 40 | onLoad: PropTypes.func, 41 | delay: PropTypes.number, 42 | fadeIn: PropTypes.bool, 43 | NativeImgProps: PropTypes.object, 44 | }; 45 | 46 | state: State = { ...initialState }; 47 | 48 | timeoutId?: NodeJS.Timeout; 49 | forceReject?: (reason: Error) => void; 50 | _isMounted = false; 51 | 52 | imgRef = React.createRef(); 53 | 54 | componentDidMount() { 55 | this._isMounted = true; 56 | this.start(); 57 | } 58 | 59 | componentDidUpdate(prevProps: ImageProps) { 60 | const { src } = this.props; 61 | if (src && src !== prevProps.src) { 62 | this.safeClearTimeout(); 63 | this.forceReject && this.forceReject(new IntendedError()); 64 | this.setState({ ...initialState }, () => this.start()); 65 | } 66 | } 67 | 68 | componentWillUnmount() { 69 | this._isMounted = false; 70 | this.forceReject = undefined; 71 | this.safeClearTimeout(); 72 | } 73 | 74 | private start = async () => { 75 | const { src, fallback, delay } = this.props; 76 | if (!src || !fallback) { 77 | const errorMessage = 'src and fallback props must be provided.'; 78 | if (process.env.NODE_ENV !== 'production') { 79 | console.error(errorMessage); 80 | } 81 | this.setState({ error: errorMessage }); 82 | return; 83 | } 84 | /* 85 | * To avoid instant loading 'flash' while downloading images with high-speed internet connection 86 | * (or downloading smaller images that do not need much loading-time), 87 | * user may want to give delay before starting to show the loading indicator. 88 | */ 89 | if (delay && delay > 0) { 90 | this.timeoutId = setTimeout(() => { 91 | this.timeoutId = undefined; 92 | if (!this.state.error && this._isMounted) { 93 | this.setState({ isLoading: true }); 94 | } 95 | }, delay); 96 | } else { 97 | this.setState({ isLoading: true }); 98 | } 99 | 100 | this.tryLoadImage(); 101 | }; 102 | 103 | private loadImage = async (): Promise => { 104 | const img = this.imgRef.current; 105 | 106 | if (!img) { 107 | return; 108 | } 109 | 110 | const { onLoad } = this.props; 111 | return new Promise((resolve, reject) => { 112 | this.forceReject = reject; 113 | 114 | const onResolve = async () => { 115 | if (img.decode !== undefined) { 116 | try { 117 | await img.decode(); 118 | } catch (error) { 119 | if (process.env.NODE_ENV !== 'production') { 120 | console.error( 121 | 'An Error occurred while trying to decode an image', 122 | error 123 | ); 124 | } 125 | } 126 | } 127 | resolve(); 128 | if (onLoad) { 129 | onLoad(img); 130 | } 131 | }; 132 | 133 | const onReject = () => { 134 | reject( 135 | new Error('An Error occurred while trying to download an image') 136 | ); 137 | }; 138 | 139 | if (img.complete) { 140 | onResolve(); 141 | } else { 142 | img.onload = onResolve; 143 | } 144 | img.onerror = onReject; 145 | }); 146 | }; 147 | 148 | private tryLoadImage = async (): Promise => { 149 | try { 150 | await this.loadImage(); 151 | if (this._isMounted) { 152 | this.setState({ isLoading: false }); 153 | } 154 | } catch (error) { 155 | // If this is an intended(forced) rejection, don't make it visible to user. 156 | if (!(error instanceof IntendedError) && this._isMounted) { 157 | this.setState({ error, isLoading: false }); 158 | } 159 | } 160 | }; 161 | 162 | private safeClearTimeout() { 163 | if (this.timeoutId) { 164 | clearTimeout(this.timeoutId); 165 | this.timeoutId = undefined; 166 | } 167 | } 168 | 169 | render() { 170 | const { error, isLoading } = this.state; 171 | const { src, fallback, errorFallback, fadeIn, NativeImgProps } = this.props; 172 | const { className, ...stripClassname } = NativeImgProps || {}; 173 | 174 | if (isLoading) { 175 | return fallback; 176 | } else if (error) { 177 | return errorFallback ? ( 178 | errorFallback(error) 179 | ) : ( 180 | 181 | ❌ 182 | 183 | ); 184 | } else if (src) { 185 | return ( 186 | 198 | ); 199 | } 200 | 201 | return null; 202 | } 203 | } 204 | 205 | export { SuspenseImage as Image }; 206 | --------------------------------------------------------------------------------