├── .eslintrc ├── .github └── workflows │ └── buildDeploy.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── UPGRADE.md ├── build.js ├── dev ├── ErrorBoundry.tsx ├── app.tsx ├── index.html ├── index.js └── sw.js ├── jest.config.cjs ├── jest.setup.ts ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── Img.tsx ├── imagePromiseFactory.ts ├── index.test.jsx ├── index.tsx └── useImage.tsx └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:react/recommended"], 8 | "parser": "babel-eslint", 9 | "parserOptions": { 10 | "ecmaVersion": 2018, 11 | "sourceType": "module", 12 | "ecmaFeatures": { 13 | "jsx": true 14 | } 15 | }, 16 | "settings": { 17 | "react": { 18 | "version": "detect" 19 | } 20 | }, 21 | "plugins": ["react", "react-hooks"], 22 | "globals": { 23 | "test": true, 24 | "expect": true 25 | }, 26 | "rules": { 27 | "linebreak-style": ["error", "unix"], 28 | "react-hooks/rules-of-hooks": "error", 29 | "react-hooks/exhaustive-deps": "warn" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/buildDeploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: push 4 | 5 | env: 6 | CI: true 7 | NODE_VER: 20 8 | NETLIFY_SITE_ID: 89194942-bd48-4c23-a181-7e489c17eabc 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ env.NODE_VER }} 19 | 20 | - uses: bahmutov/npm-install@v1 21 | 22 | - name: prettier 23 | run: npx prettier '**/*.{js?(on|x),ts?(x),y?(a)ml,graphql,md,css}' --check 24 | 25 | - name: typescript 26 | run: npx tsc --noEmit 27 | 28 | build: 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-node@v4 34 | with: 35 | node-version: ${{ env.NODE_VER }} 36 | - uses: bahmutov/npm-install@v1 37 | 38 | - name: build 39 | run: npm run build 40 | 41 | - name: build package 42 | run: npm version --no-git-tag-version 0.0.0-${{ github.sha }} && npm pack 43 | 44 | - name: Upload build artifact 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: build 48 | path: dist 49 | 50 | - name: Upload package 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: react-image-0.0.0-${{ github.sha }}.tgz 54 | path: react-image-0.0.0-${{ github.sha }}.tgz 55 | 56 | test: 57 | runs-on: ubuntu-latest 58 | needs: build 59 | 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: actions/setup-node@v4 63 | with: 64 | node-version: ${{ env.NODE_VER }} 65 | - uses: bahmutov/npm-install@v1 66 | 67 | - name: test 68 | run: npm test 69 | 70 | - name: download build artifact 71 | uses: actions/download-artifact@v4 72 | with: 73 | name: build 74 | path: dist 75 | 76 | - name: test built libs 77 | run: npm run test:dist 78 | 79 | - name: Setup env vars 80 | run: | 81 | printf -v SHORT_COMMIT_MESSAGE '%q ' `git log --oneline -n 1 HEAD --format=%B` 82 | echo "GIT_SHORT_COMMIT_MESSAGE=$SHORT_COMMIT_MESSAGE" | tee -a $GITHUB_ENV 83 | echo "GIT_SHORT_REVISION=$(echo ${GITHUB_SHA} | cut -c1-7)" | tee -a $GITHUB_ENV 84 | 85 | - name: Deploy visual tests to Netlify 86 | uses: nwtgck/actions-netlify@v3 87 | id: deploy-to-netlify 88 | with: 89 | publish-dir: dist/dev 90 | github-token: ${{ secrets.GITHUB_TOKEN }} 91 | deploy-message: '[${{ github.ref_name }}@${{ env.GIT_SHORT_REVISION }}] ${{ env.GIT_SHORT_COMMIT_MESSAGE }}' 92 | enable-pull-request-comment: false 93 | enable-commit-comment: false 94 | enable-commit-status: false 95 | # Use custom alias for non master branch Deploy Previews only 96 | alias: ${{ github.ref_name != 'master' && env.RUN_NAME || '' }} 97 | github-deployment-environment: '[${{ github.ref_name }}] visual tests' 98 | fails-without-credentials: true 99 | production-deploy: ${{ github.ref_name == 'master' }} 100 | env: 101 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 102 | NETLIFY_SITE_ID: ${{ env.NETLIFY_SITE_ID }} 103 | 104 | deploy: 105 | runs-on: ubuntu-latest 106 | needs: [lint, test] 107 | if: ${{ github.ref_name == 'master' }} 108 | 109 | steps: 110 | - uses: actions/checkout@v4 111 | - uses: actions/setup-node@v4 112 | with: 113 | node-version: ${{ env.NODE_VER }} 114 | - uses: bahmutov/npm-install@v1 115 | 116 | - name: setup .npmrc 117 | run: npm config set //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN 118 | env: 119 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 120 | 121 | - name: download build artifact 122 | uses: actions/download-artifact@v4 123 | with: 124 | name: build 125 | path: dist 126 | 127 | - name: check if package is newer than the published version 128 | id: checkPublish 129 | run: echo "::set-output name=shouldPublish::`npm run -s isNewerThanPublished`" 130 | 131 | - name: publish release package if needed 132 | if: steps.checkPublish.outputs.shouldPublish == 'true' 133 | run: npm publish 134 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .env 4 | build/ 5 | npm-debug.log 6 | disc/ 7 | coverage/ 8 | umd/ 9 | dist/ 10 | tags 11 | *.d.ts 12 | *.tgz 13 | jsSrc/ 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | semi: false, 3 | singleQuote: true, 4 | bracketSpacing: false 5 | } 6 | 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # next 2 | 3 | - BREAKING: removed umd build 4 | - BREAKING: Img can now accept a ref 5 | 6 | # 4.1.0 7 | 8 | - Add support for React 18 9 | 10 | # 4.0.3 11 | 12 | - Update peerDependencies in package.json 13 | 14 | # 4.0.2 15 | 16 | - add IE support 17 | 18 | # 4.0.1 19 | 20 | - export types ImgProps/useImageProps 21 | 22 | # 4.0.0 23 | 24 | - BREAKING: all exports are now named exports 25 | 26 | # 3.0.3 27 | 28 | - build hooks for umd 29 | 30 | # 3.0.2 31 | 32 | - dont include typescript libs in build modules 33 | - include esm modules 34 | 35 | # 3.0.1 36 | 37 | - include missing files 38 | 39 | # 3.0.0 40 | 41 | - move to typescript 42 | - add useImage hook 43 | - allow for an image loader to be injected 44 | - BREAKING: requires react 16.8 or higher 45 | 46 | # 2.4.0 47 | 48 | - fix: TS Interface Error for 'src' attribute. Related to issue: #260 49 | 50 | # 2.3.0 51 | 52 | - fix: typescript declarations 53 | 54 | # 2.2.2 55 | 56 | - add: typescript declarations 57 | 58 | # 2.2.1 59 | 60 | - fix: Removes warnings of unsafe lifecycle methods from console due to react 16.9 update. 61 | 62 | # 2.2.0 63 | 64 | - fix:Use correct case for crossOrigin and ensure prop is used both for the initial image fetch and in the final `` element 65 | 66 | # 2.1.3 67 | 68 | - fix: nullify callbacks before removing - #237 69 | 70 | # 2.1.2 71 | 72 | - fix: don't call handlers multiple times, fixes: #236 73 | 74 | # 2.1.1 75 | 76 | - fix: unset incorrect prop in https://github.com/mbrevda/react-image/pull/223 77 | 78 | # 2.1.0 79 | 80 | - Add: abort image download on unmount https://github.com/mbrevda/react-image/pull/223 81 | 82 | # 2.0.0 83 | 84 | - build: move to rollup 85 | - Fix: Don't return a bool from constructor https://github.com/mbrevda/react-image/pull/220 86 | 87 | # 1.5.1 88 | 89 | - update babel loader to v7 90 | 91 | # 1.5.0 92 | 93 | - Add: `loaderContainer`/`unloaderContainer` (#208, #211). Thanks @eedrah! 94 | - Test: test built libs 95 | 96 | # 1.4.1 97 | 98 | - Fix: strip dev-specific code when compiling 99 | 100 | # 1.4.0 101 | 102 | - Add: `container` props 103 | - Fix: issue deleting `src` prop in Safari (#87) 104 | - Add: `babel-runtime` as peer dep for https://pnpm.js.org/ (#199, #200). Thanks @vjpr! 105 | - Add: (crude) demo including transitions 106 | 107 | # 1.3.1 108 | 109 | - bug: Don't pass decode prop to underlying `` 110 | 111 | # 1.3.0 112 | 113 | - Use img.decode() by default where available 114 | 115 | # 1.2.0 116 | 117 | - Add support for React 16 118 | 119 | # 1.0.1 120 | 121 | - move to new prop-types package 122 | - add 100% test coverage 123 | 124 | # 1.0.0 125 | 126 | - Renamed to react-image 127 | 128 | # 0.6.3 129 | 130 | - Housekeeping: update dependencies 131 | - Add recipes 132 | 133 | # 0.6.2 134 | 135 | - Fix Readme formatting 136 | 137 | # 0.6.1 138 | 139 | - Start iteration at current location 140 | 141 | # 0.6.0 142 | 143 | - Add a cache so that we don't attempt the same image twice (per page load) 144 | 145 | # 0.5.0 146 | 147 | - Fix issue where index would overshoot available sources 148 | - Don't try setting state if `this.i` was already destroyed, which probably means that we have been unmounted 149 | 150 | # 0.4.2 151 | 152 | - Remove Browsierfy config 153 | 154 | # 0.4.1 155 | 156 | - Revert 0.4.0 157 | 158 | # 0.3.0 159 | 160 | - Don't overshoot sourceList when state.currentIndex 161 | - Ensure state has been set before trying to load images when new props are delivered 162 | 163 | # 0.2.0 164 | 165 | - Restart the loading process when src prop changes 166 | 167 | # 0.1.0 168 | 169 | - Don't use until we know the image can be rendered. This will prevent the "jumping" 170 | when loading an image and the preloader is displayed at the same time as the image 171 | 172 | # 0.0.11 173 | 174 | - Don't require `src` to be set 175 | 176 | # 0.0.10 177 | 178 | - Made react a peer depends 179 | 180 | # 0.0.8 181 | 182 | - Return `null` instead of false from React component. Thanks @tikotzky! 183 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to React Image 2 | 3 | You can contribute to `react-image` in these ways: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Reporting Bugs 11 | 12 | You encountered a bug? Report it by [opening a new issue](https://github.com/mbrevda/react-image/issues) on repository! 13 | 14 | ## Proposing Changes 15 | 16 | Pull requests are the best way to propose changes to the codebase and contribute. Follow this guide to send your PR: 17 | 18 | 1. Fork the repo, clone it and create your branch from `master`. 19 | 2. Commit the changes in created branch. 20 | 3. [Submit a pull request (referencing the issue)!](https://github.com/mbrevda/react-image/pulls) 21 | 22 | ## License 23 | 24 | `react-image` is available under the MIT License 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Moshe Brevda 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Image 🏝 🏖 🏜 2 | 3 | [![npm](https://img.shields.io/npm/v/react-image.svg?style=flat-square)](https://www.npmjs.com/package/react-image) 4 | [![npm](https://img.shields.io/npm/l/react-image.svg?style=flat-square)](https://www.npmjs.com/package/react-image) 5 | [![npm](https://img.shields.io/npm/dt/react-image.svg?style=flat-square)](https://www.npmjs.com/package/react-image) 6 | [![npm](https://img.shields.io/npm/dm/react-image.svg?style=flat-square)](https://www.npmjs.com/package/react-image) 7 | [![Known Vulnerabilities](https://snyk.io/test/github/mbrevda/react-image/badge.svg)](https://snyk.io/test/github/mbrevda/react-image) 8 | 9 | **React Image** is an `` tag replacement and hook for [React.js](https://facebook.github.io/react/), supporting fallback to alternate sources when loading an image fails. 10 | 11 | **React Image** allows one or more images to be used as fallback images in the event that the browser couldn't load the previous image. When using the component, you can specify any React element to be used before an image is loaded (i.e. a spinner) or in the event that the specified image(s) could not be loaded. When using the hook this can be achieved by wrapping the component with [``](https://reactjs.org/docs/react-api.html#reactsuspense) and specifying the `fallback` prop. 12 | 13 | **React Image** uses the `useImage` hook internally which encapsulates all the image loading logic. This hook works with React Suspense by default and will suspend painting until the image is downloaded and decoded by the browser. 14 | 15 | ## Getting started 16 | 17 | 1. To include the code locally in ES6, CommonJS, or UMD format, install `react-image` using npm: 18 | 19 | ``` 20 | npm install react-image --save 21 | ``` 22 | 23 | 2. To include the code globally from a cdn: 24 | 25 | ```html 26 | 27 | ``` 28 | 29 | ## Dependencies 30 | 31 | `react-image` has no external dependencies, aside from a version of `react` and `react-dom` which support hooks and `@babel/runtime`. 32 | 33 | ## Documentation 34 | 35 | You can use the standalone component, documented below, or the `useImage` hook. 36 | 37 | ### useImage(): 38 | 39 | The `useImage` hook allows for incorporating `react-image`'s logic in any component. When using the hook, the component can be wrapped in `` to keep it from rendering until the image is ready. Specify the `fallback` prop to show a spinner or any other component to the user while the browser is loading. The hook will throw an error if it fails to find any images. You can wrap your component with an [Error Boundary](https://reactjs.org/docs/code-splitting.html#error-boundaries) to catch this scenario and do/show something. 40 | 41 | Example usage: 42 | 43 | ```js 44 | import React, {Suspense} from 'react' 45 | import {useImage} from 'react-image' 46 | 47 | function MyImageComponent() { 48 | const {src} = useImage({ 49 | srcList: 'https://www.example.com/foo.jpg', 50 | }) 51 | 52 | return 53 | } 54 | 55 | export default function MyComponent() { 56 | return ( 57 | 58 | 59 | 60 | ) 61 | } 62 | ``` 63 | 64 | ### `useImage` API: 65 | 66 | - `srcList`: a string or array of strings. `useImage` will try loading these one at a time and returns after the first one is successfully loaded 67 | 68 | - `imgPromise`: a promise that accepts a url and returns a promise which resolves if the image is successfully loaded or rejects if the image doesn't load. You can inject an alternative implementation for advanced custom behaviour such as logging errors or dealing with servers that return an image with a 404 header 69 | 70 | - `useSuspense`: boolean. By default, `useImage` will tell React to suspend rendering until an image is downloaded. Suspense can be disabled by setting this to false. 71 | 72 | **returns:** 73 | 74 | - `src`: the resolved image address 75 | - `isLoading`: the currently loading status. Note: this is never true when using Suspense 76 | - `error`: any errors ecountered, if any 77 | 78 | ### Standalone component (legacy) 79 | 80 | When possible, you should use the `useImage` hook. This provides for greater flexibility and provides support for React Suspense. 81 | 82 | Include `react-image` in your component: 83 | 84 | ```js 85 | import {Img} from 'react-image' 86 | ``` 87 | 88 | and set a source for the image: 89 | 90 | ```js 91 | const myComponent = () => 92 | ``` 93 | 94 | will resolve to: 95 | 96 | ```js 97 | 98 | ``` 99 | 100 | If the image cannot be loaded, **`` will not be rendered**, preventing a "broken" image from showing. 101 | 102 | ### Multiple fallback images: 103 | 104 | When `src` is specified as an array, `react-image` will attempt to load all the images specified in the array, starting at the first and continuing until an image has been successfully loaded. 105 | 106 | ```js 107 | const myComponent = () => ( 108 | 111 | ) 112 | ``` 113 | 114 | If an image has previously been attempted unsuccessfully, `react-image` will not retry loading it again until the page is reloaded. 115 | 116 | ### Show a "spinner" or other element before the image is loaded: 117 | 118 | ```js 119 | const myComponent = () => ( 120 | 124 | ) 125 | ``` 126 | 127 | If an image was previously loaded successfully (since the last time the page was loaded), the loader will not be shown and the image will be rendered immediately instead. 128 | 129 | ### Show a fallback element if none of the images could be loaded: 130 | 131 | ```js 132 | const myComponent = () => ( 133 | 137 | ) 138 | ``` 139 | 140 | ### NOTE: 141 | 142 | The following options only apply to the `` component, not to the `useImage` hook. When using the hook you can inject a custom image resolver with custom behaviour as required. 143 | 144 | ### Decode before paint 145 | 146 | By default and when supported by the browser, `react-image` uses [`Image.decode()`](https://html.spec.whatwg.org/multipage/embedded-content.html#dom-img-decode) to decode the image and only render it when it's fully ready to be painted. While this doesn't matter much for vector images (such as svg's) which are rendered immediately, decoding the image before painting prevents the browser from hanging or flashing while the image is decoded. If this behaviour is undesirable, it can be disabled by setting the `decode` prop to `false`: 147 | 148 | ```js 149 | const myComponent = () => ( 150 | 151 | ) 152 | ``` 153 | 154 | ### Loading images with a CORS policy 155 | 156 | When loading images from another domain with a [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_settings_attributes), you may find you need to use the `crossorigin` attribute. For example: 157 | 158 | ```js 159 | const myComponent = () => ( 160 | 161 | ) 162 | ``` 163 | 164 | ### Animations and other advanced uses 165 | 166 | A wrapper element `container` can be used to facilitate higher level operations which are beyond the scope of this project. `container` takes a single property, `children` which is whatever is passed in by **React Image** (i.e. the final `` or the loaders). 167 | 168 | For example, to animate the display of the image (and animate out the loader) a wrapper can be set: 169 | 170 | ```js 171 | { 174 | return
{children}
175 | }} 176 | /> 177 | ``` 178 | 179 | By default, the loader and unloader components will also be wrapped by the `container` component. These can be set independently by passing a container via `loaderContainer` or `unloaderContainer`. To disable the loader or unloader from being wrapped, pass a noop to `loaderContainer` or `unloaderContainer` (like `unloaderContainer={img => img}`). 180 | 181 | ## Recipes 182 | 183 | ### Delay rendering until element is visible (lazy rendering) 184 | 185 | By definition, **React Image** will try loading images right away. This may be undesirable in some situations, such as when the page has many images. As with any react element, rendering can be delayed until the image is actually visible in the viewport using popular libraries such as [`react-visibility-sensor`](https://www.npmjs.com/package/react-visibility-sensor). Here is a quick sample (psudocode/untested!): 186 | 187 | ```js 188 | import {Img} from 'react-image' 189 | import VisibilitySensor from 'react-visibility-sensor' 190 | 191 | const myComponent = () => 192 | 193 | 194 | 195 | ``` 196 | 197 | Note: it is not necessary to use **React Image** to prevent loading of images past "the fold" (i.e. not currently visible in the window). Instead just use the native HTML `` element and the `loading="lazy"` prop. See more [here](https://addyosmani.com/blog/lazy-loading/). 198 | 199 | ### Animate image loading 200 | 201 | see above 202 | 203 | ## License 204 | 205 | `react-image` is available under the MIT License 206 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # 4.0.0 2 | 3 | All upgrade are now named exports, so: 4 | 5 | ```js 6 | import Img from 'react-image' 7 | ``` 8 | 9 | needs to be changed to: 10 | 11 | ```js 12 | import {Img} from 'react-image' 13 | ``` 14 | 15 | # 3.0.0 16 | 17 | This version requires a version of react that supports hook (16.8 or greater) 18 | 19 | # 1.0.0 20 | 21 | For users of the original `react-image` only: please note props and behaviors changes for this release: 22 | 23 | - `srcSet` is not supported 24 | - `onLoad` & `onError` callbacks are currently private 25 | - `lazy` has been removed from the core lib. To lazy load your images, see the recipes section [here](https://github.com/mbrevda/react-image#delay-rendering-until-element-is-visible) 26 | 27 | If you have a need for any of these params, feel free to send a PR. You can also open an issue to discuss your use case. 28 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | import {rm} from 'node:fs/promises' 2 | import url from 'node:url' 3 | import {context, build} from 'esbuild' 4 | import open from 'open' 5 | 6 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) 7 | const distOutdir = './dist/dev' 8 | 9 | const buildOpts = { 10 | entryPoints: ['./src/index.tsx', './src/Img.tsx', './src/useImage.tsx'], 11 | external: ['react', 'react-dom'], 12 | bundle: true, 13 | splitting: true, 14 | outdir: './dist/esm', 15 | format: 'esm', 16 | sourcemap: false, 17 | minify: true, 18 | jsxDev: false, // MODE === 'dev', 19 | jsx: 'automatic', 20 | } 21 | 22 | const devBuildOpts = { 23 | entryPoints: ['./dev/app.tsx', './dev/index.html', './dev/sw.js'], 24 | bundle: true, 25 | splitting: true, 26 | outdir: distOutdir, 27 | format: 'esm', 28 | sourcemap: true, 29 | minify: process.env.NODE_ENV !== 'development', 30 | jsxDev: true, 31 | jsx: 'automatic', 32 | loader: {'.html': 'copy'}, 33 | } 34 | 35 | await rm('./dist', {recursive: true, force: true}) 36 | 37 | if (process.env.NODE_ENV !== 'development') { 38 | // build esm version 39 | await Promise.all([ 40 | build(buildOpts), 41 | 42 | // build cjs version 43 | build({ 44 | ...buildOpts, 45 | format: 'cjs', 46 | outdir: './dist/cjs', 47 | splitting: false, 48 | }), 49 | 50 | // build dev site 51 | build(devBuildOpts), 52 | ]) 53 | } else { 54 | const ctx = await context(devBuildOpts) 55 | await ctx.watch() 56 | let {port} = await ctx.serve({servedir: distOutdir}) 57 | open(`http://localhost:${port}`) 58 | await ctx.dispose() 59 | } 60 | 61 | process.on('unhandledRejection', console.error) 62 | process.on('uncaughtException', console.error) 63 | -------------------------------------------------------------------------------- /dev/ErrorBoundry.tsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | 3 | export interface ErrorBoundary { 4 | props: { 5 | children: React.ReactNode 6 | onError?: React.ReactNode 7 | } 8 | } 9 | export class ErrorBoundary extends Component implements ErrorBoundary { 10 | state: { 11 | hasError: boolean 12 | error: Error | null 13 | } 14 | onError: React.ReactNode 15 | 16 | constructor(props) { 17 | super(props) 18 | this.state = {hasError: false, error: null} 19 | this.onError = props.onError 20 | } 21 | 22 | static getDerivedStateFromError(error) { 23 | // Update state so the next render will show the fallback UI. 24 | return {hasError: error, error} 25 | } 26 | 27 | render() { 28 | if (this.state.hasError) { 29 | if (this.onError) return this.onError 30 | // You can render any custom fallback UI 31 | return Something went wrong. {this.state.error?.message} 32 | } 33 | 34 | return this.props.children 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /dev/app.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Suspense, 3 | useState, 4 | useEffect, 5 | useRef, 6 | useLayoutEffect, 7 | } from 'react' 8 | import {createRoot} from 'react-dom/client' 9 | import {Img, useImage} from '../src/index' 10 | import {ErrorBoundary} from './ErrorBoundry' 11 | 12 | navigator.serviceWorker.register('/sw.js', {scope: './'}) 13 | new EventSource('/esbuild').addEventListener('change', () => location.reload()) 14 | 15 | const randSeconds = (min, max) => 16 | Math.floor(Math.random() * (max - min + 1) + min) 17 | 18 | function Timer({delay}) { 19 | const [startTime, setStartTime] = useState(Date.now()) 20 | const [elapsedTime, setElapsedTime] = useState(0) 21 | const maxTimeReached = elapsedTime / 1000 > delay 22 | const remainingTime = delay - Math.trunc(elapsedTime / 1000) 23 | 24 | useEffect(() => { 25 | if (maxTimeReached) return 26 | const timer = setTimeout(() => setElapsedTime(Date.now() - startTime), 1000) 27 | return () => clearTimeout(timer) 28 | }, [elapsedTime]) 29 | 30 | useEffect(() => { 31 | setStartTime(Date.now()) 32 | setElapsedTime(0) 33 | }, [delay]) 34 | 35 | return ( 36 |
37 | Delayed: {delay} seconds 38 | {!maxTimeReached && <>, image should show in: {remainingTime}s} 39 |
40 |
41 |
42 | ) 43 | } 44 | 45 | function GlobalTimer({until}) { 46 | const [startTime] = useState(Date.now()) 47 | const [elapsedTime, setElapsedTime] = useState(0) 48 | const maxTimeReached = elapsedTime / 1000 - 2 > until 49 | 50 | useEffect(() => { 51 | if (maxTimeReached) return 52 | const timer = setTimeout(() => setElapsedTime(Date.now() - startTime), 1000) 53 | return () => clearTimeout(timer) 54 | }, [elapsedTime]) 55 | 56 | return ( 57 |
58 |

React Image visual tests

59 |
60 | Test will load on page load. For a test to pass, one or more images 61 | should show in a green box or the text "✅ test passed" should show. 62 | Note that test are delayed by a random amount of time. 63 |
64 | {!maxTimeReached ? ( 65 |

Elapsed seconds: {Math.trunc(elapsedTime / 1000)}

66 | ) : ( 67 | <> 68 |

Max time elapsed!

69 | All images should be loaded at this point 70 |
71 | 72 | )} 73 |
74 |
75 | ) 76 | } 77 | 78 | const HooksLegacyExample = ({rand}) => { 79 | const {src, isLoading, error} = useImage({ 80 | srcList: [ 81 | 'https://www.example.com/non-existant-image.jpg', 82 | `/delay/${rand * 1000}/https://picsum.photos/200`, // will be loaded 83 | ], 84 | useSuspense: false, 85 | }) 86 | 87 | return ( 88 |
89 |

Using hooks Legacy

90 | 91 | {isLoading &&
Loading...
} 92 | {error &&
Error! {error.msg}
} 93 | {src && } 94 | {!isLoading && !error && !src && ( 95 |
Nothing to show - thats not good!
96 | )} 97 |
98 | ) 99 | } 100 | 101 | const HooksSuspenseExample = ({rand}) => { 102 | const {src} = useImage({ 103 | srcList: [ 104 | 'https://www.example.com/foo.png', 105 | `/delay/${rand * 1000}/https://picsum.photos/200`, // will be loaded 106 | ], 107 | }) 108 | 109 | return ( 110 |
111 | 112 |
113 | ) 114 | } 115 | 116 | const ReuseCache = ({renderId}) => { 117 | const src = `https://picsum.photos/200?rand=${renderId}` 118 | const [networkCalls, setNetworkCalls] = useState(0) 119 | 120 | useEffect(() => { 121 | setTimeout(() => { 122 | const entires = performance.getEntriesByName(src) 123 | setNetworkCalls(entires.length) 124 | }, 1000) 125 | }) 126 | 127 | return ( 128 |
129 |

Suspense should reuse cache and only make one network call

130 |
131 | {networkCalls < 1 && ❓ test pending} 132 | {networkCalls === 1 && ✅ test passed} 133 | {networkCalls > 1 && ( 134 | 135 | ❌ test failed. If DevTools is open, ensure "Disable Cache" in the 136 | network tab is disabled 137 | 138 | )} 139 |
140 |
Network Calls detected: {networkCalls} (expecting just 1)
141 |
142 |
143 | To test this manually, check the Network Tab in DevTools to ensure the 144 | url 145 | {src} was only called once 146 |
147 |
148 |
149 | 150 | Loading... (Suspense fallback)
}> 151 | 156 | 161 |
162 | 163 | 164 | ) 165 | } 166 | 167 | function ChangeSrc({renderId}) { 168 | const getSrc = () => { 169 | const rand = randSeconds(500, 900) 170 | return `https://picsum.photos/200?rand=${rand}` 171 | } 172 | const [src, setSrc] = useState([getSrc()]) 173 | const [loadedSecondSource, setLoadedSecondSource] = useState( 174 | null, 175 | ) 176 | const imgRef = useRef(null) 177 | 178 | useEffect(() => { 179 | if (src.length < 2) return 180 | 181 | let id = setInterval( 182 | () => setLoadedSecondSource(imgRef.current?.src === src[1]), 183 | 250, 184 | ) 185 | return () => clearInterval(id) 186 | }, [renderId, src]) 187 | 188 | useEffect(() => { 189 | // switch sources after 1 second 190 | setTimeout(() => setSrc((prev) => [...prev, getSrc()]), 1000) 191 | }, [renderId]) 192 | 193 | // on rerender, reset the src list 194 | useEffect(() => { 195 | setSrc(() => [getSrc()]) 196 | setLoadedSecondSource(null) 197 | }, [renderId]) 198 | 199 | return ( 200 | <> 201 |

202 | Change src 203 |

204 |
205 | {loadedSecondSource === null && ❓ test pending} 206 | {loadedSecondSource === true && ✅ test passed} 207 | {loadedSecondSource === false && ❌ test failed} 208 |
209 | Src list: 210 | {src.map((url, index) => { 211 | return ( 212 |
213 | {index + 1}. {url} 214 |
215 | ) 216 | })} 217 |
218 |
219 | This test will load an image and then switch sources after 1 second. It 220 | should then rerender with the new source. To manually confirm, ensure 221 | the loaded image's source is the second item in the Src list 222 |
223 |
224 | Loading...} 229 | unloader={
this is the unloader
} 230 | /> 231 | 232 | ) 233 | } 234 | 235 | function App() { 236 | const imageOn404 = 237 | 'https://i9.ytimg.com/s_p/OLAK5uy_mwasty2cJpgWIpr61CqWRkHIT7LC62u7s/sddefault.jpg?sqp=CJz5ye8Fir7X7AMGCNKz4dEF&rs=AOn4CLC-JNn9jj-oFw94oM574w36xUL1iQ&v=5a3859d2' 238 | const tmdbImg = 239 | 'https://image.tmdb.org/t/p/w500/kqjL17yufvn9OVLyXYpvtyrFfask.jpg' 240 | 241 | // http://i.imgur.com/ozEaj1Z.jpg 242 | const rand1 = randSeconds(1, 8) 243 | const rand2 = randSeconds(2, 10) 244 | const rand3 = randSeconds(2, 10) 245 | const rand4 = randSeconds(2, 10) 246 | const rand5 = randSeconds(2, 10) 247 | const [renderId, setRenderId] = useState(Math.random()) 248 | const [swRegistered, setSwRegistered] = useState(false) 249 | 250 | useLayoutEffect(() => { 251 | navigator.serviceWorker.ready.then(() => { 252 | setSwRegistered(true) 253 | }) 254 | }, []) 255 | 256 | if (!swRegistered) return
Waiting for server...
257 | 258 | return ( 259 | <> 260 | 285 | 286 |
287 |
288 |
289 | 290 | 291 |
292 |
293 | 294 |
295 |
296 |

Should show

297 | 298 | Loading...
} 302 | unloader={
❎ test failed
} 303 | /> 304 |
305 |
306 |

Should not show anything

307 | ✅ test passed
} 311 | /> 312 |
313 |
314 |

Should show unloader

315 | Loading...
} 319 | unloader={
✅ test passed
} 320 | /> 321 | 322 |
323 | 324 |
325 |
326 |

Suspense

327 | 328 | 329 | Loading... (Suspense fallback)
}> 330 | 335 |
336 | 337 | 338 |
339 |

Suspense wont load

340 | ✅ test passed
}> 341 | Loading... (Suspense fallback)}> 342 | 347 | 348 | 349 | 350 |
351 | 352 |
353 |
354 |
355 | 356 |

using hooks & suspense

357 | 358 | Loading...
}> 359 | 360 | 361 | 362 |
363 | 364 |
365 | 366 | 367 | 368 |
369 | 370 | 371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 | 379 | ) 380 | } 381 | 382 | const node = document.createElement('div') 383 | node.id = 'root' 384 | document.body.appendChild(node) 385 | const rootElement = document.getElementById('root') as HTMLElement 386 | createRoot(rootElement).render() 387 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /dev/index.js: -------------------------------------------------------------------------------- 1 | import url from 'node:url' 2 | import {parseArgs} from 'node:util' 3 | import {context} from 'esbuild' 4 | import {rm} from 'node:fs/promises' 5 | import open from 'open' 6 | 7 | // const app = express() 8 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) 9 | const outdir = __dirname + '../dist/dev' 10 | const { 11 | values: {build}, 12 | } = parseArgs({ 13 | options: { 14 | build: {type: 'boolean', default: false}, 15 | }, 16 | }) 17 | 18 | await rm(outdir, {recursive: true, force: true}) 19 | 20 | const buildOpts = { 21 | entryPoints: [ 22 | __dirname + './app.tsx', 23 | __dirname + './index.html', 24 | __dirname + './sw.js', 25 | ], 26 | bundle: true, 27 | splitting: true, 28 | outdir, 29 | format: 'esm', 30 | sourcemap: true, 31 | minify: false, 32 | jsxDev: true, 33 | jsx: 'automatic', 34 | loader: {'.html': 'copy'}, 35 | } 36 | 37 | const ctx = await context(buildOpts) 38 | if (!build) { 39 | await ctx.watch() 40 | let {port} = await ctx.serve({servedir: outdir}) 41 | open(`http://localhost:${port}`) 42 | } else { 43 | await ctx.rebuild() 44 | await ctx.dispose() 45 | } 46 | process.on('unhandledRejection', console.error) 47 | process.on('uncaughtException', console.error) 48 | process.on('exit', () => ctx && ctx.dispose()) 49 | -------------------------------------------------------------------------------- /dev/sw.js: -------------------------------------------------------------------------------- 1 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) 2 | 3 | self.addEventListener('install', () => self.skipWaiting()) 4 | self.addEventListener('activate', (e) => { 5 | e.waitUntil(clients.claim()) 6 | }) 7 | 8 | async function delayFetch(url) { 9 | const [_, delay] = url.pathname.match(/\/delay\/(\d*).*/, '') 10 | await sleep(delay) 11 | const request = new Request(url.pathname.replace(/\/delay\/\d*\//, '')) 12 | return await fetch(request) 13 | } 14 | 15 | self.addEventListener('fetch', async (event) => { 16 | const url = new URL(event.request.url) 17 | 18 | if (!event.request.url.startsWith(url.origin + '/delay/')) { 19 | console.log('not delaying', event.request.url) 20 | return fetch(event.request) 21 | } 22 | 23 | event.respondWith(delayFetch(url)) 24 | }) 25 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cache: false, 3 | collectCoverage: false, // doesnt seem to with with esbuild-jest-transform 4 | collectCoverageFrom: ['src/**/*.ts*'], 5 | coverageReporters: ['json', 'lcov', 'text-summary', 'html'], 6 | setupFiles: ['/jest.setup.ts'], 7 | testEnvironmentOptions: { 8 | url: 'http://localhost', 9 | }, 10 | testEnvironment: 'jsdom', 11 | transform: { 12 | '^.+\\.m?(|j|t)sx?$': [ 13 | 'esbuild-jest-transform', 14 | { 15 | sourcemap: true, 16 | jsx: 'automatic', 17 | external: ['react', 'react-dom'], 18 | }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import {TextEncoder} from 'util' 2 | 3 | Object.defineProperty(window, 'TextEncoder', { 4 | writable: true, 5 | value: TextEncoder, 6 | }) 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-image", 3 | "version": "4.1.0", 4 | "description": "React Image is an tag replacement for react, featuring preloader and multiple image fallback support", 5 | "scripts": { 6 | "build": "node build.js && npm run build:types && mv dist/src/*.d.ts dist", 7 | "build:types": "tsc -p tsconfig.json --emitDeclarationOnly", 8 | "codecov": "codecov", 9 | "dev": "NODE_ENV=development node --watch build.js", 10 | "pretty": "prettier \"**/*.{?(cj|mj|t|j)s?(on|on5|x),y?(a)ml,graphql,md,css}\" --write", 11 | "isNewerThanPublished": "semver `npm -s view $npm_package_name dist-tags.${TAG:-latest}` --range \"<$npm_package_version\" > /dev/null && echo true || echo false", 12 | "test": "jest", 13 | "test:dist": "for i in cjs esm; do cp src/*test.* dist/$i; echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json; done && npm run test -- dist" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+ssh://git@github.com/mbrevda/react-image.git" 18 | }, 19 | "keywords": [ 20 | "reactjs", 21 | "img", 22 | "image", 23 | "loader", 24 | "fallback", 25 | "react image", 26 | "react-image", 27 | "react img multi", 28 | "react-img-multi", 29 | "react image fallback", 30 | "react image loader", 31 | "react image preloader", 32 | "react images", 33 | "placeholder", 34 | "placeholders", 35 | "react image transition", 36 | "react image fade", 37 | "image transition", 38 | "image fade" 39 | ], 40 | "files": [ 41 | "*.md", 42 | "dist/esm/*.js", 43 | "dist/cjs/*.js", 44 | "!**/*.test.js", 45 | "*.d.ts" 46 | ], 47 | "module": "dist/esm/index.js", 48 | "main": "dist/cjs/index.js", 49 | "types": "dist/index.d.ts", 50 | "type": "module", 51 | "author": "mbrevda@gmail.com", 52 | "license": "MIT", 53 | "bugs": { 54 | "url": "https://github.com/mbrevda/react-image/issues" 55 | }, 56 | "homepage": "https://github.com/mbrevda/react-image#readme", 57 | "devDependencies": { 58 | "@testing-library/react": "16.3.0", 59 | "@types/jest": "29.5.14", 60 | "@types/react": "18.3.23", 61 | "@types/react-dom": "18.3.7", 62 | "codecov": "3.8.3", 63 | "esbuild": "0.25.5", 64 | "esbuild-jest": "0.5.0", 65 | "esbuild-jest-transform": "2.0.0", 66 | "jest": "29.7.0", 67 | "jest-environment-jsdom": "29.7.0", 68 | "open": "10.1.2", 69 | "prettier": "3.5.3", 70 | "react": "18.3.1", 71 | "react-dom": "18.3.1", 72 | "semver": "7.7.2", 73 | "typescript": "5.8.3" 74 | }, 75 | "peerDependencies": { 76 | "react": ">=16.8", 77 | "react-dom": ">=16.8" 78 | }, 79 | "overrides": { 80 | "esbuild-jest-transform": { 81 | "esbuild": "$esbuild" 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "automerge": true, 3 | "automergeType": "branch-push", 4 | "enabled": true, 5 | "extends": [":base"], 6 | "platformAutomerge": true, 7 | "rebaseStalePrs": true, 8 | "recreateClosed": true, 9 | "semanticCommits": "enabled", 10 | "timezone": "UTC", 11 | "transitiveRemediation": true 12 | } 13 | -------------------------------------------------------------------------------- /src/Img.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef} from 'react' 2 | import useImage, {useImageProps} from './useImage' 3 | import imagePromiseFactory from './imagePromiseFactory' 4 | 5 | export type ImgProps = Omit< 6 | React.DetailedHTMLProps< 7 | React.ImgHTMLAttributes, 8 | HTMLImageElement 9 | >, 10 | 'src' 11 | > & 12 | Omit & { 13 | src: useImageProps['srcList'] // same types, different name 14 | loader?: JSX.Element | null 15 | unloader?: JSX.Element | null 16 | decode?: boolean 17 | crossorigin?: string 18 | container?: (children: React.ReactNode) => JSX.Element 19 | loaderContainer?: (children: React.ReactNode) => JSX.Element 20 | unloaderContainer?: (children: React.ReactNode) => JSX.Element 21 | } 22 | 23 | const passthroughContainer = (x) => x 24 | 25 | function Img( 26 | { 27 | decode = true, 28 | src: srcList = [], 29 | loader = null, 30 | unloader = null, 31 | container = passthroughContainer, 32 | loaderContainer = passthroughContainer, 33 | unloaderContainer = passthroughContainer, 34 | imgPromise, 35 | crossorigin, 36 | useSuspense = false, 37 | ...imgProps // anything else will be passed to the element 38 | }: ImgProps, 39 | ref, 40 | ): JSX.Element | null { 41 | imgPromise = 42 | imgPromise || imagePromiseFactory({decode, crossOrigin: crossorigin}) 43 | const {src, isLoading} = useImage({ 44 | srcList, 45 | imgPromise, 46 | useSuspense, 47 | }) 48 | 49 | // console.log({src, isLoading, resolvedSrc, useSuspense}) 50 | 51 | // show img if loaded 52 | if (src) return container() 53 | 54 | // show loader if we have one and were still trying to load image 55 | if (!useSuspense && isLoading) return loaderContainer(loader) 56 | 57 | // show unloader if we have one and we have no more work to do 58 | if (!useSuspense && unloader) return unloaderContainer(unloader) 59 | 60 | return null 61 | } 62 | 63 | export default forwardRef(Img) 64 | -------------------------------------------------------------------------------- /src/imagePromiseFactory.ts: -------------------------------------------------------------------------------- 1 | // returns a Promisized version of Image() api 2 | export default ({decode = true, crossOrigin = ''}) => 3 | (src): Promise => { 4 | return new Promise((resolve, reject) => { 5 | const i = new Image() 6 | if (crossOrigin) i.crossOrigin = crossOrigin 7 | i.onload = () => { 8 | decode && i.decode ? i.decode().then(resolve).catch(reject) : resolve() 9 | } 10 | i.onerror = reject 11 | i.src = src 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/index.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Img} from './' 3 | import {render, act, cleanup, waitFor} from '@testing-library/react' 4 | import ReactDOMServer from 'react-dom/server' 5 | 6 | afterEach(cleanup) 7 | const imgPromise = (decode) => (src) => { 8 | return new Promise((resolve, reject) => { 9 | const img = new Image() 10 | images.push(img) 11 | img.decode = () => Promise.resolve() 12 | img.onload = () => { 13 | decode ? img.decode().then(resolve).catch(reject) : resolve() 14 | } 15 | img.onerror = reject 16 | img.src = src 17 | 18 | // mock loading 19 | src.endsWith('LOAD') ? img.onload() : img.onerror() 20 | }) 21 | } 22 | 23 | describe('Img', () => { 24 | test('render with src string, after load', () => { 25 | const {getByAltText} = render( 26 | , 27 | ) 28 | waitFor(() => expect(getByAltText('').src).toEqual(location.href + 'foo')) 29 | }) 30 | 31 | test('render with src array', () => { 32 | const {getByAltText} = render( 33 | , 34 | ) 35 | 36 | waitFor(() => expect(getByAltText('').src).toEqual(location.href + 'foo')) 37 | }) 38 | 39 | // https://github.com/kentcdodds/react-testing-library/issues/281 40 | test('render with decode=true', () => { 41 | const {getByAltText} = render( 42 | , 43 | ) 44 | waitFor(() => 45 | expect(getByAltText('').src).toEqual(location.href + 'fooDecode'), 46 | ) 47 | }) 48 | 49 | test('fallback to next image', () => { 50 | const {getByAltText} = render( 51 | , 52 | ) 53 | 54 | waitFor(() => expect(getByAltText('').src).toEqual(location.href + 'bar')) 55 | }) 56 | 57 | test('ensure missing image isnt rendered to browser', () => { 58 | const {container} = render( 59 | , 60 | ) 61 | 62 | expect(container.innerHTML).toEqual('') 63 | }) 64 | 65 | test('show loader', () => { 66 | const {container} = render( 67 | Loading...} alt="" />, 68 | ) 69 | waitFor(() => 70 | expect(container.innerHTML).toEqual('Loading...'), 71 | ) 72 | }) 73 | 74 | test('clear loader after load', async () => { 75 | const {container, getByAltText} = render( 76 | Loading...} 79 | alt="" 80 | imgPromise={imgPromise} 81 | />, 82 | ) 83 | waitFor(() => expect(getByAltText('').src).toEqual(location.href + 'foo')) 84 | }) 85 | 86 | test('show unloader', async () => { 87 | const {container} = render( 88 | Could not load image!} 90 | imgPromise={imgPromise} 91 | />, 92 | ) 93 | 94 | waitFor(() => 95 | expect(container.innerHTML).toEqual('Could not load image!'), 96 | ) 97 | }) 98 | 99 | test('update image on src prop change', () => { 100 | const {rerender, getByAltText} = render( 101 | , 102 | ) 103 | rerender() 104 | waitFor(() => expect(getByAltText('').src).toEqual(location.href + 'bar')) 105 | }) 106 | 107 | test('start over on src prop change', async () => { 108 | const {getByAltText, rerender} = render( 109 | , 110 | ) 111 | 112 | waitFor(() => expect(getByAltText('').src).toEqual(location.href + 'bar')) 113 | rerender() 114 | waitFor(() => expect(getByAltText('').src).toEqual(location.href + 'baz')) 115 | }) 116 | 117 | test('updated props no src', async () => { 118 | const {container, rerender} = render( 119 | , 120 | ) 121 | 122 | rerender() 123 | waitFor(() => expect(container.innerHTML).toEqual('')) 124 | }) 125 | 126 | test('onError does nothing if unmounted', async () => { 127 | const {unmount} = render() 128 | 129 | //unmount() 130 | setTimeout(() => unmount(), 1) 131 | }) 132 | }) 133 | 134 | describe('ssr', () => { 135 | test('should ssr a loader', () => { 136 | const html = ReactDOMServer.renderToStaticMarkup( 137 | Loading...} mockImage={{}} />, 138 | ) 139 | expect(html).toEqual('Loading...') 140 | }) 141 | 142 | test('should ssr nothing if only src is set', () => { 143 | const html = ReactDOMServer.renderToStaticMarkup( 144 | , 145 | ) 146 | expect(html).toEqual('') 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import Img, {ImgProps} from './Img' 2 | import useImage, {useImageProps} from './useImage' 3 | export {Img, useImage, type ImgProps, type useImageProps} 4 | -------------------------------------------------------------------------------- /src/useImage.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import imagePromiseFactory from './imagePromiseFactory' 3 | 4 | export type useImageProps = { 5 | srcList: string | string[] 6 | imgPromise?: (...args: any[]) => Promise 7 | useSuspense?: boolean 8 | } 9 | 10 | const removeBlankArrayElements = (a) => a.filter((x) => x) 11 | const stringToArray = (x) => (Array.isArray(x) ? x : [x]) 12 | const cache = {} 13 | 14 | // sequential map.find for promises 15 | const promiseFind = (arr, promiseFactory) => { 16 | let done = false 17 | return new Promise((resolve, reject) => { 18 | const queueNext = (src) => { 19 | return promiseFactory(src).then(() => { 20 | done = true 21 | resolve(src) 22 | }) 23 | } 24 | 25 | arr 26 | .reduce((p, src) => { 27 | // ensure we aren't done before enqueuing the next source 28 | return p.catch(() => { 29 | if (!done) return queueNext(src) 30 | }) 31 | }, queueNext(arr.shift())) 32 | .catch(reject) 33 | }) 34 | } 35 | 36 | export default function useImage({ 37 | srcList, 38 | imgPromise = imagePromiseFactory({decode: true}), 39 | useSuspense = true, 40 | }: useImageProps): {src: string | undefined; isLoading: boolean; error: any} { 41 | const [, setIsSettled] = useState(false) 42 | const sourceList = removeBlankArrayElements(stringToArray(srcList)) 43 | const sourceKey = sourceList.join('') 44 | 45 | if (!cache[sourceKey]) { 46 | // create promise to loop through sources and try to load one 47 | cache[sourceKey] = { 48 | promise: promiseFind(sourceList, imgPromise), 49 | cache: 'pending', 50 | error: null, 51 | } 52 | } 53 | 54 | // when promise resolves/reject, update cache & state 55 | if (cache[sourceKey].cache === 'resolved') { 56 | return {src: cache[sourceKey].src, isLoading: false, error: null} 57 | } 58 | 59 | if (cache[sourceKey].cache === 'rejected') { 60 | if (useSuspense) throw cache[sourceKey].error 61 | return {isLoading: false, error: cache[sourceKey].error, src: undefined} 62 | } 63 | 64 | cache[sourceKey].promise 65 | // if a source was found, update cache 66 | // when not using suspense, update state to force a rerender 67 | .then((src) => { 68 | cache[sourceKey] = {...cache[sourceKey], cache: 'resolved', src} 69 | if (!useSuspense) setIsSettled(sourceKey) 70 | }) 71 | 72 | // if no source was found, or if another error occurred, update cache 73 | // when not using suspense, update state to force a rerender 74 | .catch((error) => { 75 | cache[sourceKey] = {...cache[sourceKey], cache: 'rejected', error} 76 | if (!useSuspense) setIsSettled(sourceKey) 77 | }) 78 | 79 | // cache[sourceKey].cache === 'pending') 80 | if (useSuspense) throw cache[sourceKey].promise 81 | return {isLoading: true, src: undefined, error: null} 82 | } 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, // emit declerations 4 | "declarationDir": "./dist", // to this dir 5 | "outDir": "./dist", // but all other code to this dir 6 | "sourceMap": true, // allow sourcemap support 7 | "strictNullChecks": true, // enable strict null checks as a best practice 8 | // "module": "es6", // specify module code generation 9 | "jsx": "react", // use typescript to transpile jsx to js 10 | "allowJs": true, // allow a partial TypeScript and JavaScript codebase 11 | "allowSyntheticDefaultImports": true, 12 | // Include typings from built-in lib declarations 13 | "lib": ["es6", "es2019", "dom"], 14 | "target": "esnext", 15 | "skipLibCheck": true 16 | }, 17 | "include": ["*/**/*.ts", "*/**/*.tsx"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | --------------------------------------------------------------------------------