├── .codesandbox └── ci.json ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml └── workflows │ └── main.yml ├── .gitignore ├── .husky ├── post-merge └── pre-commit ├── .prettierignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo ├── package.json ├── public │ ├── icons.svg │ ├── index.html │ └── vue.svg ├── src │ ├── App.tsx │ ├── GitHubRepo.tsx │ ├── components.tsx │ ├── index.tsx │ └── strings.ts └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── sonar-project.properties ├── src ├── config.ts ├── global.d.ts ├── index.tsx ├── modules │ ├── cache.ts │ ├── helpers.ts │ ├── hooks.tsx │ └── utils.ts ├── provider.tsx └── types.ts ├── test ├── __fixtures__ │ ├── buttons.svg │ ├── circles.svg │ ├── datahref.svg │ ├── dots.svg │ ├── icons.svg │ ├── main.css │ ├── play.svg │ ├── react.png │ ├── react.svg │ ├── styles.svg │ ├── styles_with_css_variables.svg │ ├── tiger.svg │ └── utf8.svg ├── __setup__ │ ├── global.d.ts │ └── vitest.setup.ts ├── __snapshots__ │ ├── index.spec.tsx.snap │ └── unsupported.spec.tsx.snap ├── index.spec.tsx ├── indexWithPersistentCache.spec.tsx ├── modules │ └── cache.spec.ts ├── tsconfig.json └── unsupported.spec.tsx ├── tsconfig.json └── vitest.config.mts /.codesandbox/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "sandboxes": ["/demo"], 3 | "node": "18" 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: '🐛 Bug report' 2 | description: Report a reproducible bug or regression 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you for reporting an issue :pray:. 8 | 9 | This issue tracker is for reporting reproducible bugs or regression's found in [react-inlinesvg](https://github.com/gilbarbara/react-inlinesvg). 10 | If you have a question about how to achieve something and are struggling, please post a question 11 | inside of react-inlinesvg's [Discussions tab](https://github.com/gilbarbara/react-inlinesvg/discussions). 12 | 13 | Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: 14 | - [Discussions tab](https://github.com/gilbarbara/react-inlinesvg/discussions) 15 | - [Open Issues](https://github.com/gilbarbara/react-inlinesvg/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) 16 | - [Closed Issues](https://github.com/gilbarbara/react-inlinesvg/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed) 17 | 18 | > If you have Typescript errors try upgrading (or maybe downgrading) your version of Typescript and @types/react. This package doesn't have any control over the typescript errors you may be seeing. 19 | 20 | The more information you fill in, the better the community can help you. 21 | - type: textarea 22 | id: description 23 | attributes: 24 | label: Describe the bug 25 | description: Provide a clear and concise description of the challenge you are running into. 26 | validations: 27 | required: true 28 | - type: input 29 | id: link 30 | attributes: 31 | label: Your minimal, reproducible example 32 | description: | 33 | Please add a link to a minimal reproduction. 34 | Note: 35 | - Your bug may get fixed much faster if we can run your code. 36 | - To create a shareable code example for web, you can use CodeSandbox (https://codesandbox.io/s/new) or Stackblitz (https://stackblitz.com/). 37 | - Please make sure the example is complete and runnable - e.g. avoid localhost URLs. 38 | - Feel free to fork the demo CodeSandbox example to reproduce your issue: https://codesandbox.io/s/github/gilbarbara/react-inlinesvg/main/demo 39 | placeholder: | 40 | e.g. Code Sandbox, Stackblitz, etc. 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: steps 45 | attributes: 46 | label: Steps to reproduce 47 | description: Describe the steps we have to take to reproduce the behavior. 48 | placeholder: | 49 | 1. Go to '...' 50 | 2. Click on '....' 51 | 3. Scroll down to '....' 52 | 4. See error 53 | validations: 54 | required: true 55 | - type: textarea 56 | id: expected 57 | attributes: 58 | label: Expected behavior 59 | description: Provide a clear and concise description of what you expected to happen. 60 | placeholder: | 61 | As a user, I expected ___ behavior but i am seeing ___ 62 | validations: 63 | required: true 64 | - type: dropdown 65 | attributes: 66 | label: How often does this bug happen? 67 | description: | 68 | Following the reproduction steps above, how easily are you able to reproduce this bug? 69 | options: 70 | - Every time 71 | - Often 72 | - Sometimes 73 | - Only once 74 | - type: textarea 75 | id: screenshots_or_videos 76 | attributes: 77 | label: Screenshots or Videos 78 | description: | 79 | If applicable, add screenshots or a video to help explain your problem. 80 | For more information on the supported file image/file types and the file size limits, please refer 81 | to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files 82 | placeholder: | 83 | You can drag your video or image files inside of this editor ↓ 84 | - type: textarea 85 | id: platform 86 | attributes: 87 | label: Platform 88 | description: | 89 | If the problem is specific to a platform, please let us know which one. 90 | placeholder: | 91 | - OS: [e.g. macOS, Windows, Linux, iOS, Android] 92 | - Browser: [e.g. Chrome, Safari, Firefox, React Native] 93 | - Version: [e.g. 91.1] 94 | - type: input 95 | id: rswp-version 96 | attributes: 97 | label: react-inlinesvg version 98 | description: | 99 | Please let us know the exact version of react-inlinesvg you were using when the issue occurred. Please don't just put in "latest", as this is subject to change. 100 | placeholder: | 101 | e.g. 4.0.0 102 | validations: 103 | required: true 104 | - type: input 105 | id: ts-version 106 | attributes: 107 | label: TypeScript version 108 | description: | 109 | If you are using TypeScript, please let us know the exact version of TypeScript you were using when the issue occurred. 110 | placeholder: | 111 | e.g. 5.1.0 112 | validations: 113 | required: true 114 | - type: input 115 | id: build-tool 116 | attributes: 117 | label: Build tool 118 | description: | 119 | If the issue is specific to a build tool, please let us know which one. 120 | placeholder: | 121 | e.g. webpack, vite, rollup, parcel, create-react-app, next.js, etc. 122 | - type: textarea 123 | id: additional 124 | attributes: 125 | label: Additional context 126 | description: Add any other context about the problem here. 127 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🗣 Feature Request / Question / Help 4 | url: https://github.com/gilbarbara/react-spotify-web-playback/discussions/new 5 | about: How does it work with...? I have an idea... 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | tags: ['v*'] 7 | pull_request: 8 | branches: ['*'] 9 | 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: ${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | main: 18 | name: Validate and Deploy 19 | runs-on: ubuntu-latest 20 | 21 | env: 22 | CI: true 23 | 24 | steps: 25 | - name: Setup timezone 26 | uses: zcong1993/setup-timezone@master 27 | with: 28 | timezone: America/Sao_Paulo 29 | 30 | - name: Setup repo 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup Node 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 22 37 | registry-url: 'https://registry.npmjs.org' 38 | 39 | - name: Install pnpm 40 | uses: pnpm/action-setup@v4 41 | with: 42 | version: 9 43 | run_install: false 44 | 45 | - name: Get pnpm store directory 46 | shell: bash 47 | run: | 48 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 49 | 50 | - name: Setup pnpm cache 51 | uses: actions/cache@v4 52 | with: 53 | path: ${{ env.STORE_PATH }} 54 | key: "${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}" 55 | restore-keys: | 56 | ${{ runner.os }}-pnpm-store- 57 | 58 | - name: Install Packages 59 | run: pnpm install 60 | timeout-minutes: 3 61 | 62 | - name: Validate 63 | if: "!startsWith(github.ref, 'refs/tags/')" 64 | run: pnpm run validate 65 | timeout-minutes: 3 66 | 67 | - name: SonarCloud Scan 68 | if: "!startsWith(github.ref, 'refs/tags/')" 69 | uses: SonarSource/sonarqube-scan-action@master 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 73 | 74 | - name: Publish Package 75 | if: startsWith(github.ref, 'refs/tags/') 76 | run: npm publish 77 | env: 78 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .tmp/ 3 | coverage/ 4 | dist/ 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | ./node_modules/.bin/repo-tools install-packages 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | ./node_modules/.bin/repo-tools check-remote && npm run validate 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | esm 3 | lib 4 | node_modules 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to react-inlinesvg 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | **Reporting Bugs** 6 | Before creating bug reports, please check this [list](https://github.com/gilbarbara/react-inlinesvg/issues) as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible. 7 | 8 | **Pull Requests** 9 | Before submitting a new pull request, open a new issue to discuss it. It may already been implemented but not published or we might have found the same situation before and decide against it. 10 | 11 | In any case: 12 | 13 | - Format files using these rules [EditorConfig](https://github.com/gilbarbara/react-inlinesvg/blob/master/.editorconfig) 14 | - Follow the [ESLint](https://github.com/gilbarbara/react-inlinesvg/blob/master/.eslintrc) styleguide. 15 | 16 | Thank you! 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014, Matthew Dapena-Tretter / Gil Barbara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-inlinesvg 2 | 3 | [![NPM version](https://badge.fury.io/js/react-inlinesvg.svg)](https://www.npmjs.com/package/react-inlinesvg) [![CI](https://github.com/gilbarbara/react-inlinesvg/actions/workflows/main.yml/badge.svg)](https://github.com/gilbarbara/react-inlinesvg/actions/workflows/main.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=gilbarbara_react-inlinesvg&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=gilbarbara_react-inlinesvg) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=gilbarbara_react-inlinesvg&metric=coverage)](https://sonarcloud.io/summary/new_code?id=gilbarbara_react-inlinesvg) 4 | 5 | Load inline, local, or remote SVGs in your React components. 6 | 7 | View the [demo](https://codesandbox.io/s/github/gilbarbara/react-inlinesvg/tree/main/demo) 8 | 9 | ## Highlights 10 | 11 | - 🏖 **Easy to use:** Just set the `src` 12 | - 🛠 **Flexible:** Personalize the options to fit your needs 13 | - ⚡️ **Smart:** Async requests will be cached. 14 | - 🚀 **SSR:** Render a loader until the DOM is available 15 | - 🟦 **Typescript:** Nicely typed 16 | 17 | ## Usage 18 | 19 | ```sh 20 | npm i react-inlinesvg 21 | ``` 22 | 23 | And import it into your code: 24 | 25 | ```tsx 26 | import React from 'react'; 27 | import SVG from 'react-inlinesvg'; 28 | 29 | export default function App() { 30 | return ( 31 |
32 | 38 |
39 | ); 40 | } 41 | ``` 42 | 43 | ## Props 44 | 45 | **src** {string} - **required**. 46 | The SVG file you want to load. It can be a require, URL, or a string (base64 or URL encoded). 47 | _If you are using create-react-app and your file resides in the `public` directory, you can use the path directly without require._ 48 | 49 | **baseURL** {string} 50 | An URL to prefix each ID in case you use the `` tag and `uniquifyIDs`. 51 | 52 | **children** {ReactNode} 53 | The fallback content in case of a fetch error or unsupported browser. 54 | 55 | ``` 56 | 57 | fallback 58 | 59 | ``` 60 | 61 | **cacheRequests** {boolean} ▶︎ `true` 62 | Cache remote SVGs. 63 | Starting in version 4.x, you can also cache the files permanently, read more [below](#caching). 64 | 65 | **description** {string} 66 | A description for your SVG. It will override an existing `` tag. 67 | 68 | **fetchOptions** {RequestInit} 69 | Custom options for the [request](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request). 70 | 71 | **innerRef** {React.Ref} 72 | Set a ref in SVGElement. 73 | 74 | >The SVG is processed and parsed so the ref won't be set on the initial render. 75 | You can use the `onLoad` callback to get and use the ref instead. 76 | 77 | **loader** {node} 78 | A component to be shown while the SVG is loading. 79 | 80 | **onError** {function} 81 | A callback to be invoked if loading the SVG fails. 82 | This will receive a single argument with: 83 | 84 | - a `FetchError` with: 85 | 86 | ```typescript 87 | { 88 | message: string; 89 | type: string; 90 | errno: string; 91 | code: string; 92 | } 93 | ``` 94 | 95 | - or an `InlineSVGError`, which has the following properties: 96 | 97 | ```typescript 98 | { 99 | name: 'InlineSVGError'; 100 | data: object; // optional 101 | message: string; 102 | } 103 | ``` 104 | 105 | **onLoad** {function}. 106 | A callback to be invoked upon successful load. 107 | This will receive 2 arguments: the `src` prop and an `isCached` boolean 108 | 109 | **preProcessor** {function} ▶︎ `string` 110 | A function to process the contents of the SVG text before parsing. 111 | 112 | **title** {string | null} 113 | A title for your SVG. It will override an existing `` tag. 114 | If `null` is passed, the `<title>` tag will be removed. 115 | 116 | **uniqueHash** {string} ▶︎ a random 8 characters string `[A-Za-z0-9]` 117 | A string to use with `uniquifyIDs`. 118 | 119 | **uniquifyIDs** {boolean} ▶︎ `false` 120 | Create unique IDs for each icon. 121 | 122 | > Any additional props will be passed down to the SVG element. 123 | 124 | ### Example 125 | 126 | ```jsx 127 | <SVG 128 | baseURL="/home" 129 | cacheRequests={true} 130 | description="The React logo" 131 | loader={<span>Loading...</span>} 132 | onError={(error) => console.log(error.message)} 133 | onLoad={(src, isCached) => console.log(src, isCached)} 134 | preProcessor={(code) => code.replace(/fill=".*?"/g, 'fill="currentColor"')} 135 | src="https://cdn.svgporn.com/logos/react.svg" 136 | title="React" 137 | uniqueHash="a1f8d1" 138 | uniquifyIDs={true} 139 | /> 140 | ``` 141 | 142 | ## Caching 143 | 144 | You can use the browser's cache to store the SVGs permanently. 145 | To set it up, wrap your app with the cache provider: 146 | 147 | ```typescript 148 | import { createRoot } from 'react-dom/client'; 149 | import CacheProvider from 'react-inlinesvg/provider'; 150 | import App from './App'; 151 | 152 | createRoot(document.getElementById('root')!).render( 153 | <CacheProvider> 154 | <App /> 155 | </CacheProvider> 156 | ); 157 | ``` 158 | 159 | > Be aware of the limitations of the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache). 160 | 161 | ## Browser Support 162 | 163 | Any browsers that support inlining [SVGs](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg) and [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) will work. 164 | 165 | If you need to support legacy browsers, include a polyfill for `fetch` and `Number.isNaN` in your app. 166 | 167 | ## CORS 168 | 169 | If you are loading remote SVGs, you must ensure it has [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) support. 170 | 171 | ## Why do you need this package? 172 | 173 | One of the reasons SVGs are awesome is that you can style them with CSS. 174 | Unfortunately, this is not useful in practice because the style element has to be in the same document. This leaves you with three bad options: 175 | 176 | 1. Embed the CSS in the SVG document 177 | - Can't use your CSS preprocessors (LESS, SASS) 178 | - Can't target parent elements (button hover, etc.) 179 | - Makes maintenance difficult 180 | 2. Link to a CSS file in your SVG document 181 | - Sharing styles with your HTML means duplicating paths across your project, making maintenance a pain 182 | - Not sharing styles with your HTML means extra HTTP requests (and likely 183 | duplicating paths between different SVGs) 184 | - Still can't target parent elements 185 | - Your SVG becomes coupled to your external stylesheet, complicating reuse. 186 | 3. Embed the SVG in your HTML 187 | - Bloats your HTML 188 | - SVGs can't be cached by browsers between pages. 189 | - A maintenance nightmare 190 | 191 | But there's an alternative that sidesteps these issues: load the SVG with a GET request and then embed it in the document. This is what this component does. 192 | 193 | ### Note 194 | 195 | The SVG [`<use>`](http://css-tricks.com/svg-use-external-source) element can be used to achieve something similar to this component. See [this article](http://taye.me/blog/svg/a-guide-to-svg-use-elements) for more information and [this table](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use#Browser_compatibility) for browser support and caveats. 196 | 197 | ## Credits 198 | 199 | Thanks to [@matthewwithanm](https://github.com/matthewwithanm) for creating this component and so kindly transferring it to me. 200 | I'll definitely keep up the good work! ❤️ 201 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-inlinesvg-demo", 3 | "version": "4.2.0", 4 | "description": "An SVG loader component for ReactJS", 5 | "keywords": [], 6 | "dependencies": { 7 | "react": "^18.3.1", 8 | "react-dom": "^18.3.1", 9 | "react-inlinesvg": "latest", 10 | "react-scripts": "^5.0.1", 11 | "styled-components": "^6.1.15" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "22.13.4", 15 | "@types/react": "18.3.18", 16 | "@types/react-dom": "18.3.5", 17 | "typescript": "5.7.3" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /demo/public/icons.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 | <svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"> 3 | <defs> 4 | <radialGradient cx="50%" cy="50%" fx="50%" fy="50%" r="50%" id="radialGradient-1"> 5 | <stop stop-color="#FAD961" offset="0%"></stop> 6 | <stop stop-color="#F76B1C" offset="100%"></stop> 7 | </radialGradient> 8 | <symbol id="rain" viewBox="0 0 32 29"> 9 | <path d="M24.1408446,4.83412162 C23.7658446,4.83412162 23.4078446,4.89612162 23.0368446,4.94512162 C21.6488446,2.48012162 19.0688446,0.834121622 16.1418446,0.834121622 C13.1548446,0.834121622 10.5768446,2.50012162 9.20184459,4.93512162 C8.84884459,4.88812162 8.49584459,4.83412162 8.14184459,4.83412162 C3.73184459,4.83412162 0.141844595,8.42312162 0.141844595,12.8351216 C0.141844595,17.2471216 3.73184459,20.8351216 8.14184459,20.8351216 L24.1408446,20.8351216 C28.5548446,20.8351216 32.1408446,17.2471216 32.1408446,12.8351216 C32.1408446,8.42312162 28.5548446,4.83412162 24.1408446,4.83412162 Z M24.1408446,16.8351216 L8.14184459,16.8351216 C5.93484459,16.8351216 4.14184459,15.0401216 4.14184459,12.8351216 C4.14184459,10.6421216 6.08184459,8.94912162 8.14584459,8.89012162 C8.15484459,9.83312162 8.31784459,10.7591216 8.64584459,11.6341216 L12.3918446,10.2321216 C12.2238446,9.78812162 12.1418446,9.31712162 12.1418446,8.83612162 C12.1418446,6.63112162 13.9348446,4.83512162 16.1418446,4.83512162 C17.4348446,4.83512162 18.6078446,5.47712162 19.3408446,6.47512162 C17.4128446,7.93612162 16.1418446,10.2311216 16.1418446,12.8361216 L20.1418446,12.8361216 C20.1418446,10.6311216 21.9378446,8.83612162 24.1408446,8.83612162 C26.3518446,8.83612162 28.1408446,10.6311216 28.1408446,12.8361216 C28.1408446,15.0411216 26.3518446,16.8351216 24.1408446,16.8351216 Z M3.42184459,28.2731216 C2.67184459,29.0231216 1.45284459,29.0231216 0.702844595,28.2731216 C-0.0471554054,27.5231216 -0.0471554054,26.3041216 0.702844595,25.5541216 C1.45284459,24.8041216 6.14084459,22.8341216 6.14084459,22.8341216 C6.14084459,22.8341216 4.17184459,27.5231216 3.42184459,28.2731216 Z M11.4268446,28.2731216 C10.6768446,29.0231216 9.46184459,29.0231216 8.70784459,28.2731216 C7.95684459,27.5231216 7.95684459,26.3041216 8.70784459,25.5541216 C9.46184459,24.8041216 14.1458446,22.8341216 14.1458446,22.8341216 C14.1458446,22.8341216 12.1808446,27.5231216 11.4268446,28.2731216 Z M19.4218446,28.2731216 C18.6718446,29.0231216 17.4528446,29.0231216 16.7028446,28.2731216 C15.9528446,27.5231216 15.9528446,26.3041216 16.7028446,25.5541216 C17.4528446,24.8041216 22.1408446,22.8351216 22.1408446,22.8351216 C22.1408446,22.8351216 20.1718446,27.5231216 19.4218446,28.2731216 Z" fill="#D8D8D8"></path> 10 | </symbol> 11 | <symbol id="cloud" viewBox="0 0 32 20"> 12 | <path d="M23.999,4 C23.624,4 23.266,4.062 22.895,4.111 C21.507,1.646 18.927,0 16,0 C13.013,0 10.435,1.666 9.06,4.101 C8.707,4.054 8.354,4 8,4 C3.59,4 0,7.589 0,12.001 C0,16.413 3.59,20.001 8,20.001 L23.999,20.001 C28.413,20.001 31.999,16.413 31.999,12.001 C31.999,7.589 28.413,4 23.999,4 Z M23.999,16.001 L8,16.001 C5.793,16.001 4,14.206 4,12.001 C4,9.808 5.94,8.115 8.004,8.056 C8.013,8.999 8.176,9.925 8.504,10.8 L12.25,9.398 C12.082,8.954 12,8.483 12,8.002 C12,5.797 13.793,4.001 16,4.001 C17.293,4.001 18.466,4.643 19.199,5.641 C17.271,7.102 16,9.397 16,12.002 L20,12.002 C20,9.797 21.796,8.002 23.999,8.002 C26.21,8.002 27.999,9.797 27.999,12.002 C27.999,14.207 26.21,16.001 23.999,16.001 Z" fill="#A0D6FF"></path> 13 | </symbol> 14 | <symbol id="sun" viewBox="0 0 32 32"> 15 | <path d="M16.001,12 C18.204,12 20.001,13.795 20.001,16 C20.001,18.205 18.204,20 16.001,20 C13.798,20 12.001,18.205 12.001,16 C12.001,13.795 13.798,12 16.001,12 L16.001,12 Z M16.001,8 C11.583,8 8.001,11.582 8.001,16 C8.001,20.418 11.583,24 16.001,24 C20.419,24 24,20.418 24,16 C24,11.582 20.419,8 16.001,8 L16.001,8 Z M14,2 C14,3.104 14.896,4 16,4 C17.104,4 18,3.104 18,2 C18,0.896 17.104,0 16,0 C14.896,0 14,0.896 14,2 Z M4,6 C4,7.104 4.896,8 6,8 C7.104,8 8,7.104 8,6 C8,4.896 7.104,4 6,4 C4.896,4 4,4.896 4,6 Z M2,14 C3.105,14 4,14.895 4,16 C4,17.107 3.105,18 2,18 C0.895,18 0,17.107 0,16 C0,14.895 0.895,14 2,14 Z M4,26 C4,27.104 4.896,28 6,28 C7.104,28 8,27.104 8,26 C8,24.896 7.104,24 6,24 C4.896,24 4,24.896 4,26 Z M14,30 C14,28.891 14.895,28 16,28 C17.108,28 18,28.891 18,30 C18,31.102 17.108,32 16,32 C14.895,32 14,31.102 14,30 Z M24,26 C24,27.104 24.896,28 26,28 C27.104,28 28,27.104 28,26 C28,24.896 27.104,24 26,24 C24.896,24 24,24.896 24,26 Z M30,18 C28.896,18 28,17.104 28,16 C28,14.893 28.896,14 30,14 C31.104,14 32,14.893 32,16 C32,17.104 31.104,18 30,18 Z M24,6 C24,7.104 24.896,8 26,8 C27.104,8 28,7.104 28,6 C28,4.896 27.104,4 26,4 C24.896,4 24,4.896 24,6 Z" fill="url(#radialGradient-1)"></path> 16 | </symbol> 17 | </defs> 18 | </svg> 19 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | 4 | <head> 5 | <meta charset="utf-8"> 6 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 7 | <meta name="theme-color" content="#000000"> 8 | <title>react-inlinesvg demo 9 | 10 | 11 | 12 | 15 |
16 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /demo/public/vue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import SVG from 'react-inlinesvg'; 3 | 4 | import { Grid, GridItem, SubTitle, Title, Wrapper } from './components'; 5 | import GitHubRepo from './GitHubRepo'; 6 | import { base64, markup, urlEncoded } from './strings'; 7 | 8 | const { env } = process; 9 | 10 | env.PUBLIC_URL = env.PUBLIC_URL ?? ''; 11 | 12 | export default function App() { 13 | const ref = React.useRef(null); 14 | 15 | return ( 16 | 17 | 18 | react-inlinesvg 19 |

An SVG loader component for ReactJS

20 | 21 | 22 |

Remote URL

23 |
24 | 25 |
26 |
27 | 28 |

Local File

29 |
30 | 31 |
32 |
33 | 34 | 35 |

Fallback

36 |
37 | {/* This file doesn't exist */} 38 | 39 | Angular 44 | 45 |
46 |
47 |
48 | From string 49 | 50 | 51 |

Base 64

52 |
53 | 54 |
55 |
56 | 57 |

URL Encoded

58 |
59 | 60 |
61 |
62 | 63 |

Markup

64 |
65 | 66 |
67 |
68 |
69 | 70 | With symbols 71 | 72 | 73 |

Symbol: Sun

74 |
75 | 76 | 77 | 78 |
79 |
80 | 81 |

Symbol: Cloud

82 |
83 | 84 | 85 | 86 |
87 |
88 | 89 |

Symbol: Rain

90 |
91 | 92 | 93 | 94 |
95 |
96 |
97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /demo/src/GitHubRepo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Wrapper = styled.a` 5 | position: fixed; 6 | top: 0; 7 | right: 0; 8 | 9 | &:hover { 10 | .octo-arm { 11 | animation: octocat-wave 560ms ease-in-out; 12 | } 13 | } 14 | 15 | svg { 16 | fill: #f04; 17 | color: #fff; 18 | } 19 | 20 | .octo-arm { 21 | transform-origin: 130px 106px; 22 | } 23 | 24 | @keyframes octocat-wave { 25 | 0%, 26 | 100% { 27 | transform: rotate(0); 28 | } 29 | 20%, 30 | 60% { 31 | transform: rotate(-25deg); 32 | } 33 | 40%, 34 | 80% { 35 | transform: rotate(10deg); 36 | } 37 | } 38 | `; 39 | 40 | function GitHubRepo() { 41 | return ( 42 | 49 | 62 | 63 | ); 64 | } 65 | 66 | export default GitHubRepo; 67 | -------------------------------------------------------------------------------- /demo/src/components.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | interface GridProps { 4 | size?: number; 5 | } 6 | 7 | export const SubTitle = styled.h2` 8 | margin-bottom: 20px; 9 | margin-top: 30px; 10 | `; 11 | 12 | export const Title = styled.h1` 13 | margin-bottom: 20px; 14 | margin-top: 0; 15 | `; 16 | 17 | export const Grid = styled.div` 18 | display: grid; 19 | grid-template-columns: ${({ size = 3 }) => css`repeat(${size}, 1fr)`}; 20 | grid-column-gap: 15px; 21 | grid-row-gap: 15px; 22 | justify-content: center; 23 | margin: 50px auto 0; 24 | max-width: 960px; 25 | 26 | @media (min-width: 1024px) { 27 | grid-column-gap: 30px; 28 | grid-row-gap: 30px; 29 | } 30 | 31 | ${SubTitle} + & { 32 | margin-top: 0; 33 | } 34 | `; 35 | 36 | export const GridItem = styled.div` 37 | h4 { 38 | margin-top: 0; 39 | margin-bottom: 20px; 40 | } 41 | 42 | > div { 43 | align-items: center; 44 | display: flex; 45 | justify-content: center; 46 | margin-top: auto; 47 | } 48 | 49 | svg { 50 | height: auto; 51 | width: 100%; 52 | } 53 | `; 54 | 55 | export const Wrapper = styled.main` 56 | font-family: sans-serif; 57 | padding: 15px; 58 | text-align: center; 59 | `; 60 | -------------------------------------------------------------------------------- /demo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import App from './App'; 5 | 6 | const container = document.getElementById('root'); 7 | 8 | if (container) { 9 | const root = createRoot(container); 10 | 11 | root.render(); 12 | } 13 | -------------------------------------------------------------------------------- /demo/src/strings.ts: -------------------------------------------------------------------------------- 1 | export const base64 = 2 | 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTYycHgiIGhlaWdodD0iMTYycHgiIHZpZXdCb3g9IjAgMCAxNjIgMTYyIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj4KICAgIDxnPgogICAgICAgIDxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIgcG9pbnRzPSIxNyAwIDE3IDE2MiAxNDUgODEiPjwvcG9seWdvbj4KICAgIDwvZz4KPC9zdmc+Cg=='; 3 | export const urlEncoded = 4 | 'data:image/svg+xml,%3Csvg%20width%3D%22162px%22%20height%3D%22162px%22%20viewBox%3D%220%200%20162%20162%22%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20preserveAspectRatio%3D%22xMidYMid%22%3E%0A%20%20%20%20%3Cg%3E%0A%20%20%20%20%20%20%20%20%3Cpath%20d%3D%22M17%2C0%20L57.2314519%2C0%20L57.2314519%2C162%20L17%2C162%20L17%2C0%20Z%20M104.768548%2C0%20L145%2C0%20L145%2C162%20L104.768548%2C162%20L104.768548%2C0%20Z%22%20fill%3D%22%23110000%22%3E%3C%2Fpath%3E%0A%20%20%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E'; 5 | export const markup = 6 | ''; 7 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "isolatedModules": true, 5 | "jsx": "react-jsx", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "target": "es5" 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-inlinesvg", 3 | "version": "4.2.0", 4 | "description": "An SVG loader for React", 5 | "author": "Gil Barbara ", 6 | "contributors": [ 7 | { 8 | "name": "Matthew Dapena-Tretter", 9 | "email": "m@tthewwithanm.com" 10 | } 11 | ], 12 | "keywords": [ 13 | "react", 14 | "svg" 15 | ], 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "git://github.com/gilbarbara/react-inlinesvg.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/gilbarbara/react-inlinesvg/issues" 23 | }, 24 | "homepage": "https://github.com/gilbarbara/react-inlinesvg#readme", 25 | "main": "./dist/index.js", 26 | "module": "./dist/index.mjs", 27 | "exports": { 28 | ".": { 29 | "import": "./dist/index.mjs", 30 | "require": "./dist/index.js" 31 | }, 32 | "./provider": { 33 | "import": "./dist/provider.mjs", 34 | "default": "./dist/provider.js" 35 | } 36 | }, 37 | "files": [ 38 | "dist", 39 | "src" 40 | ], 41 | "typesVersions": { 42 | "*": { 43 | "provider": [ 44 | "dist/provider.d.ts" 45 | ] 46 | } 47 | }, 48 | "types": "dist/index.d.ts", 49 | "sideEffects": false, 50 | "peerDependencies": { 51 | "react": "16.8 - 19" 52 | }, 53 | "dependencies": { 54 | "react-from-dom": "^0.7.5" 55 | }, 56 | "devDependencies": { 57 | "@arethetypeswrong/cli": "^0.17.3", 58 | "@gilbarbara/eslint-config": "^0.8.4", 59 | "@gilbarbara/prettier-config": "^1.0.0", 60 | "@gilbarbara/tsconfig": "^0.2.3", 61 | "@size-limit/preset-small-lib": "^11.1.6", 62 | "@testing-library/jest-dom": "^6.6.3", 63 | "@testing-library/react": "^16.2.0", 64 | "@types/node": "^22.13.4", 65 | "@types/react": "^18.3.18", 66 | "@types/react-dom": "^18.3.5", 67 | "@vitejs/plugin-react": "^4.3.4", 68 | "@vitest/coverage-v8": "^3.0.6", 69 | "browser-cache-mock": "^0.1.7", 70 | "del-cli": "^6.0.0", 71 | "fix-tsup-cjs": "^1.2.0", 72 | "http-server": "^14.1.1", 73 | "husky": "^9.1.7", 74 | "jest-extended": "^4.0.2", 75 | "jsdom": "^26.0.0", 76 | "react": "^18.3.1", 77 | "react-dom": "^18.3.1", 78 | "repo-tools": "^0.3.1", 79 | "size-limit": "^11.1.6", 80 | "start-server-and-test": "^2.0.10", 81 | "ts-node": "^10.9.2", 82 | "tsup": "^8.3.6", 83 | "typescript": "^5.7.3", 84 | "vitest": "^3.0.6", 85 | "vitest-fetch-mock": "^0.4.3" 86 | }, 87 | "scripts": { 88 | "build": "pnpm run clean && tsup && fix-tsup-cjs", 89 | "watch": "tsup --watch", 90 | "clean": "del dist/*", 91 | "lint": "eslint --fix src test", 92 | "start": "http-server test/__fixtures__ -p 1337 --cors", 93 | "test": "start-server-and-test start http://127.0.0.1:1337 test:coverage", 94 | "test:coverage": "vitest run --coverage", 95 | "test:watch": "vitest watch", 96 | "typecheck": "tsc -p test/tsconfig.json", 97 | "typevalidation": "attw -P", 98 | "size": "size-limit", 99 | "validate": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && pnpm run size && npm run typevalidation", 100 | "format": "prettier \"**/*.{js,jsx,json,yml,yaml,css,less,scss,ts,tsx,md,graphql,mdx}\" --write", 101 | "prepublishOnly": "pnpm run validate", 102 | "prepare": "husky" 103 | }, 104 | "tsup": { 105 | "banner": { 106 | "js": "\"use client\";" 107 | }, 108 | "dts": true, 109 | "entry": [ 110 | "src/index.tsx", 111 | "src/provider.tsx" 112 | ], 113 | "format": [ 114 | "cjs", 115 | "esm" 116 | ], 117 | "sourcemap": true, 118 | "splitting": false 119 | }, 120 | "eslintConfig": { 121 | "extends": [ 122 | "@gilbarbara/eslint-config" 123 | ], 124 | "overrides": [ 125 | { 126 | "files": [ 127 | "test/**/*.ts?(x)" 128 | ], 129 | "rules": { 130 | "@typescript-eslint/ban-ts-comment": "off", 131 | "no-console": "off", 132 | "testing-library/no-container": "off", 133 | "testing-library/no-node-access": "off" 134 | } 135 | } 136 | ] 137 | }, 138 | "prettier": "@gilbarbara/prettier-config", 139 | "size-limit": [ 140 | { 141 | "name": "commonjs", 142 | "path": "./dist/index.js", 143 | "limit": "10 KB" 144 | }, 145 | { 146 | "name": "esm", 147 | "path": "./dist/index.mjs", 148 | "limit": "10 KB" 149 | } 150 | ] 151 | } 152 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=gilbarbara_react-inlinesvg 2 | sonar.organization=gilbarbara-github 3 | sonar.source=./src 4 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 5 | sonar.exclusions=**/demo/**/*.*,**/test/**/*.* 6 | sonar.coverage.exclusions=**/test/**/*.*,**/vitest.config.mts 7 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const CACHE_NAME = 'react-inlinesvg'; 2 | export const CACHE_MAX_RETRIES = 10; 3 | 4 | export const STATUS = { 5 | IDLE: 'idle', 6 | LOADING: 'loading', 7 | LOADED: 'loaded', 8 | FAILED: 'failed', 9 | READY: 'ready', 10 | UNSUPPORTED: 'unsupported', 11 | } as const; 12 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | interface Window { 3 | REACT_INLINESVG_CACHE_NAME?: string; 4 | REACT_INLINESVG_PERSISTENT_CACHE?: boolean; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | cloneElement, 3 | isValidElement, 4 | ReactElement, 5 | useCallback, 6 | useEffect, 7 | useReducer, 8 | useRef, 9 | useState, 10 | } from 'react'; 11 | import convert from 'react-from-dom'; 12 | 13 | import { STATUS } from './config'; 14 | import CacheStore from './modules/cache'; 15 | import { canUseDOM, isSupportedEnvironment, omit, randomString, request } from './modules/helpers'; 16 | import { usePrevious } from './modules/hooks'; 17 | import { getNode } from './modules/utils'; 18 | import { FetchError, Props, State, Status } from './types'; 19 | 20 | // eslint-disable-next-line import/no-mutable-exports 21 | export let cacheStore: CacheStore; 22 | 23 | function ReactInlineSVG(props: Props) { 24 | const { 25 | cacheRequests = true, 26 | children = null, 27 | description, 28 | fetchOptions, 29 | innerRef, 30 | loader = null, 31 | onError, 32 | onLoad, 33 | src, 34 | title, 35 | uniqueHash, 36 | } = props; 37 | const [state, setState] = useReducer( 38 | (previousState: State, nextState: Partial) => ({ 39 | ...previousState, 40 | ...nextState, 41 | }), 42 | { 43 | content: '', 44 | element: null, 45 | 46 | isCached: cacheRequests && cacheStore.isCached(props.src), 47 | status: STATUS.IDLE, 48 | }, 49 | ); 50 | const { content, element, isCached, status } = state; 51 | const previousProps = usePrevious(props); 52 | const previousState = usePrevious(state); 53 | 54 | const hash = useRef(uniqueHash ?? randomString(8)); 55 | const isActive = useRef(false); 56 | const isInitialized = useRef(false); 57 | 58 | const handleError = useCallback( 59 | (error: Error | FetchError) => { 60 | if (isActive.current) { 61 | setState({ 62 | status: 63 | error.message === 'Browser does not support SVG' ? STATUS.UNSUPPORTED : STATUS.FAILED, 64 | }); 65 | 66 | onError?.(error); 67 | } 68 | }, 69 | [onError], 70 | ); 71 | 72 | const handleLoad = useCallback((loadedContent: string, hasCache = false) => { 73 | if (isActive.current) { 74 | setState({ 75 | content: loadedContent, 76 | isCached: hasCache, 77 | status: STATUS.LOADED, 78 | }); 79 | } 80 | }, []); 81 | 82 | const fetchContent = useCallback(async () => { 83 | const responseContent: string = await request(src, fetchOptions); 84 | 85 | handleLoad(responseContent); 86 | }, [fetchOptions, handleLoad, src]); 87 | 88 | const getElement = useCallback(() => { 89 | try { 90 | const node = getNode({ ...props, handleError, hash: hash.current, content }) as Node; 91 | const convertedElement = convert(node); 92 | 93 | if (!convertedElement || !isValidElement(convertedElement)) { 94 | throw new Error('Could not convert the src to a React element'); 95 | } 96 | 97 | setState({ 98 | element: convertedElement, 99 | status: STATUS.READY, 100 | }); 101 | } catch (error: any) { 102 | handleError(error); 103 | } 104 | }, [content, handleError, props]); 105 | 106 | const getContent = useCallback(async () => { 107 | const dataURI = /^data:image\/svg[^,]*?(;base64)?,(.*)/u.exec(src); 108 | let inlineSrc; 109 | 110 | if (dataURI) { 111 | inlineSrc = dataURI[1] ? window.atob(dataURI[2]) : decodeURIComponent(dataURI[2]); 112 | } else if (src.includes(' { 136 | if (isActive.current) { 137 | setState({ 138 | content: '', 139 | element: null, 140 | isCached: false, 141 | status: STATUS.LOADING, 142 | }); 143 | } 144 | }, []); 145 | 146 | // Run on mount 147 | useEffect( 148 | () => { 149 | isActive.current = true; 150 | 151 | if (!canUseDOM() || isInitialized.current) { 152 | return undefined; 153 | } 154 | 155 | try { 156 | if (status === STATUS.IDLE) { 157 | if (!isSupportedEnvironment()) { 158 | throw new Error('Browser does not support SVG'); 159 | } 160 | 161 | if (!src) { 162 | throw new Error('Missing src'); 163 | } 164 | 165 | load(); 166 | } 167 | } catch (error: any) { 168 | handleError(error); 169 | } 170 | 171 | isInitialized.current = true; 172 | 173 | return () => { 174 | isActive.current = false; 175 | }; 176 | }, 177 | // eslint-disable-next-line react-hooks/exhaustive-deps 178 | [], 179 | ); 180 | 181 | // Handles `src` changes 182 | useEffect(() => { 183 | if (!canUseDOM() || !previousProps) { 184 | return; 185 | } 186 | 187 | if (previousProps.src !== src) { 188 | if (!src) { 189 | handleError(new Error('Missing src')); 190 | 191 | return; 192 | } 193 | 194 | load(); 195 | } 196 | }, [handleError, load, previousProps, src]); 197 | 198 | // Handles content loading 199 | useEffect(() => { 200 | if (status === STATUS.LOADED) { 201 | getElement(); 202 | } 203 | }, [status, getElement]); 204 | 205 | // Handles `title` and `description` changes 206 | useEffect(() => { 207 | if (!canUseDOM() || !previousProps || previousProps.src !== src) { 208 | return; 209 | } 210 | 211 | if (previousProps.title !== title || previousProps.description !== description) { 212 | getElement(); 213 | } 214 | }, [description, getElement, previousProps, src, title]); 215 | 216 | // handle state 217 | useEffect(() => { 218 | if (!previousState) { 219 | return; 220 | } 221 | 222 | switch (status) { 223 | case STATUS.LOADING: { 224 | if (previousState.status !== STATUS.LOADING) { 225 | getContent(); 226 | } 227 | 228 | break; 229 | } 230 | case STATUS.LOADED: { 231 | if (previousState.status !== STATUS.LOADED) { 232 | getElement(); 233 | } 234 | 235 | break; 236 | } 237 | case STATUS.READY: { 238 | if (previousState.status !== STATUS.READY) { 239 | onLoad?.(src, isCached); 240 | } 241 | 242 | break; 243 | } 244 | } 245 | }, [getContent, getElement, isCached, onLoad, previousState, src, status]); 246 | 247 | const elementProps = omit( 248 | props, 249 | 'baseURL', 250 | 'cacheRequests', 251 | 'children', 252 | 'description', 253 | 'fetchOptions', 254 | 'innerRef', 255 | 'loader', 256 | 'onError', 257 | 'onLoad', 258 | 'preProcessor', 259 | 'src', 260 | 'title', 261 | 'uniqueHash', 262 | 'uniquifyIDs', 263 | ); 264 | 265 | if (!canUseDOM()) { 266 | return loader; 267 | } 268 | 269 | if (element) { 270 | return cloneElement(element as ReactElement, { 271 | ref: innerRef, 272 | ...elementProps, 273 | }); 274 | } 275 | 276 | if (([STATUS.UNSUPPORTED, STATUS.FAILED] as Status[]).includes(status)) { 277 | return children; 278 | } 279 | 280 | return loader; 281 | } 282 | 283 | export default function InlineSVG(props: Props) { 284 | if (!cacheStore) { 285 | cacheStore = new CacheStore(); 286 | } 287 | 288 | const { loader } = props; 289 | const [isReady, setReady] = useState(cacheStore.isReady); 290 | 291 | useEffect(() => { 292 | if (isReady) { 293 | return; 294 | } 295 | 296 | cacheStore.onReady(() => { 297 | setReady(true); 298 | }); 299 | }, [isReady]); 300 | 301 | if (!isReady) { 302 | return loader; 303 | } 304 | 305 | return ; 306 | } 307 | 308 | export * from './types'; 309 | -------------------------------------------------------------------------------- /src/modules/cache.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_MAX_RETRIES, CACHE_NAME, STATUS } from '../config'; 2 | import { StorageItem } from '../types'; 3 | 4 | import { canUseDOM, request, sleep } from './helpers'; 5 | 6 | export default class CacheStore { 7 | private cacheApi: Cache | undefined; 8 | private readonly cacheStore: Map; 9 | private readonly subscribers: Array<() => void> = []; 10 | public isReady = false; 11 | 12 | constructor() { 13 | this.cacheStore = new Map(); 14 | 15 | let cacheName = CACHE_NAME; 16 | let usePersistentCache = false; 17 | 18 | if (canUseDOM()) { 19 | cacheName = window.REACT_INLINESVG_CACHE_NAME ?? CACHE_NAME; 20 | usePersistentCache = !!window.REACT_INLINESVG_PERSISTENT_CACHE && 'caches' in window; 21 | } 22 | 23 | if (usePersistentCache) { 24 | caches 25 | .open(cacheName) 26 | .then(cache => { 27 | this.cacheApi = cache; 28 | }) 29 | .catch(error => { 30 | // eslint-disable-next-line no-console 31 | console.error(`Failed to open cache: ${error.message}`); 32 | this.cacheApi = undefined; 33 | }) 34 | .finally(() => { 35 | this.isReady = true; 36 | // Copy to avoid mutation issues 37 | const callbacks = [...this.subscribers]; 38 | 39 | // Clear array efficiently 40 | this.subscribers.length = 0; 41 | 42 | callbacks.forEach(callback => { 43 | try { 44 | callback(); 45 | } catch (error: any) { 46 | // eslint-disable-next-line no-console 47 | console.error(`Error in CacheStore subscriber callback: ${error.message}`); 48 | } 49 | }); 50 | }); 51 | } else { 52 | this.isReady = true; 53 | } 54 | } 55 | 56 | public onReady(callback: () => void) { 57 | if (this.isReady) { 58 | callback(); 59 | } else { 60 | this.subscribers.push(callback); 61 | } 62 | } 63 | 64 | public async get(url: string, fetchOptions?: RequestInit) { 65 | await (this.cacheApi 66 | ? this.fetchAndAddToPersistentCache(url, fetchOptions) 67 | : this.fetchAndAddToInternalCache(url, fetchOptions)); 68 | 69 | return this.cacheStore.get(url)?.content ?? ''; 70 | } 71 | 72 | public set(url: string, data: StorageItem) { 73 | this.cacheStore.set(url, data); 74 | } 75 | 76 | public isCached(url: string) { 77 | return this.cacheStore.get(url)?.status === STATUS.LOADED; 78 | } 79 | 80 | private async fetchAndAddToInternalCache(url: string, fetchOptions?: RequestInit) { 81 | const cache = this.cacheStore.get(url); 82 | 83 | if (cache?.status === STATUS.LOADING) { 84 | await this.handleLoading(url, async () => { 85 | this.cacheStore.set(url, { content: '', status: STATUS.IDLE }); 86 | await this.fetchAndAddToInternalCache(url, fetchOptions); 87 | }); 88 | 89 | return; 90 | } 91 | 92 | if (!cache?.content) { 93 | this.cacheStore.set(url, { content: '', status: STATUS.LOADING }); 94 | 95 | try { 96 | const content = await request(url, fetchOptions); 97 | 98 | this.cacheStore.set(url, { content, status: STATUS.LOADED }); 99 | } catch (error: any) { 100 | this.cacheStore.set(url, { content: '', status: STATUS.FAILED }); 101 | throw error; 102 | } 103 | } 104 | } 105 | 106 | private async fetchAndAddToPersistentCache(url: string, fetchOptions?: RequestInit) { 107 | const cache = this.cacheStore.get(url); 108 | 109 | if (cache?.status === STATUS.LOADED) { 110 | return; 111 | } 112 | 113 | if (cache?.status === STATUS.LOADING) { 114 | await this.handleLoading(url, async () => { 115 | this.cacheStore.set(url, { content: '', status: STATUS.IDLE }); 116 | await this.fetchAndAddToPersistentCache(url, fetchOptions); 117 | }); 118 | 119 | return; 120 | } 121 | 122 | this.cacheStore.set(url, { content: '', status: STATUS.LOADING }); 123 | 124 | const data = await this.cacheApi?.match(url); 125 | 126 | if (data) { 127 | const content = await data.text(); 128 | 129 | this.cacheStore.set(url, { content, status: STATUS.LOADED }); 130 | 131 | return; 132 | } 133 | 134 | try { 135 | await this.cacheApi?.add(new Request(url, fetchOptions)); 136 | 137 | const response = await this.cacheApi?.match(url); 138 | const content = (await response?.text()) ?? ''; 139 | 140 | this.cacheStore.set(url, { content, status: STATUS.LOADED }); 141 | } catch (error: any) { 142 | this.cacheStore.set(url, { content: '', status: STATUS.FAILED }); 143 | throw error; 144 | } 145 | } 146 | 147 | private async handleLoading(url: string, callback: () => Promise) { 148 | for (let retryCount = 0; retryCount < CACHE_MAX_RETRIES; retryCount++) { 149 | if (this.cacheStore.get(url)?.status !== STATUS.LOADING) { 150 | return; 151 | } 152 | 153 | // eslint-disable-next-line no-await-in-loop 154 | await sleep(0.1); 155 | } 156 | 157 | await callback(); 158 | } 159 | 160 | public keys(): Array { 161 | return [...this.cacheStore.keys()]; 162 | } 163 | 164 | public data(): Array> { 165 | return [...this.cacheStore.entries()].map(([key, value]) => ({ [key]: value })); 166 | } 167 | 168 | public async delete(url: string) { 169 | if (this.cacheApi) { 170 | await this.cacheApi.delete(url); 171 | } 172 | 173 | this.cacheStore.delete(url); 174 | } 175 | 176 | public async clear() { 177 | if (this.cacheApi) { 178 | const keys = await this.cacheApi.keys(); 179 | 180 | await Promise.allSettled(keys.map(key => this.cacheApi!.delete(key))); 181 | } 182 | 183 | this.cacheStore.clear(); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/modules/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { PlainObject } from '../types'; 2 | 3 | function randomCharacter(character: string) { 4 | return character[Math.floor(Math.random() * character.length)]; 5 | } 6 | 7 | export function canUseDOM(): boolean { 8 | return !!(typeof window !== 'undefined' && window.document?.createElement); 9 | } 10 | 11 | export function isSupportedEnvironment(): boolean { 12 | return supportsInlineSVG() && typeof window !== 'undefined' && window !== null; 13 | } 14 | 15 | /** 16 | * Remove properties from an object 17 | */ 18 | export function omit( 19 | input: T, 20 | ...filter: K[] 21 | ): Omit { 22 | const output: any = {}; 23 | 24 | for (const key in input) { 25 | if ({}.hasOwnProperty.call(input, key)) { 26 | if (!filter.includes(key as unknown as K)) { 27 | output[key] = input[key]; 28 | } 29 | } 30 | } 31 | 32 | return output as Omit; 33 | } 34 | 35 | export function randomString(length: number): string { 36 | const letters = 'abcdefghijklmnopqrstuvwxyz'; 37 | const numbers = '1234567890'; 38 | const charset = `${letters}${letters.toUpperCase()}${numbers}`; 39 | 40 | let R = ''; 41 | 42 | for (let index = 0; index < length; index++) { 43 | R += randomCharacter(charset); 44 | } 45 | 46 | return R; 47 | } 48 | 49 | export async function request(url: string, options?: RequestInit) { 50 | const response = await fetch(url, options); 51 | const contentType = response.headers.get('content-type'); 52 | const [fileType] = (contentType ?? '').split(/ ?; ?/); 53 | 54 | if (response.status > 299) { 55 | throw new Error('Not found'); 56 | } 57 | 58 | if (!['image/svg+xml', 'text/plain'].some(d => fileType.includes(d))) { 59 | throw new Error(`Content type isn't valid: ${fileType}`); 60 | } 61 | 62 | return response.text(); 63 | } 64 | 65 | export function sleep(seconds = 1) { 66 | return new Promise(resolve => { 67 | setTimeout(resolve, seconds * 1000); 68 | }); 69 | } 70 | 71 | export function supportsInlineSVG(): boolean { 72 | /* c8 ignore next 3 */ 73 | if (!document) { 74 | return false; 75 | } 76 | 77 | const div = document.createElement('div'); 78 | 79 | div.innerHTML = ''; 80 | const svg = div.firstChild as SVGSVGElement; 81 | 82 | return !!svg && svg.namespaceURI === 'http://www.w3.org/2000/svg'; 83 | } 84 | -------------------------------------------------------------------------------- /src/modules/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function usePrevious(state: T): T | undefined { 4 | const ref = useRef(undefined); 5 | 6 | useEffect(() => { 7 | ref.current = state; 8 | }); 9 | 10 | return ref.current; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/utils.ts: -------------------------------------------------------------------------------- 1 | import convert from 'react-from-dom'; 2 | 3 | import { Props, State } from '../types'; 4 | 5 | interface GetNodeOptions extends Props, Pick { 6 | handleError: (error: Error) => void; 7 | hash: string; 8 | } 9 | 10 | interface UpdateSVGAttributesOptions extends Pick { 11 | hash: string; 12 | } 13 | 14 | export function getNode(options: GetNodeOptions) { 15 | const { 16 | baseURL, 17 | content, 18 | description, 19 | handleError, 20 | hash, 21 | preProcessor, 22 | title, 23 | uniquifyIDs = false, 24 | } = options; 25 | 26 | try { 27 | const svgText = processSVG(content, preProcessor); 28 | const node = convert(svgText, { nodeOnly: true }); 29 | 30 | if (!node || !(node instanceof SVGSVGElement)) { 31 | throw new Error('Could not convert the src to a DOM Node'); 32 | } 33 | 34 | const svg = updateSVGAttributes(node, { baseURL, hash, uniquifyIDs }); 35 | 36 | if (description) { 37 | const originalDesc = svg.querySelector('desc'); 38 | 39 | if (originalDesc?.parentNode) { 40 | originalDesc.parentNode.removeChild(originalDesc); 41 | } 42 | 43 | const descElement = document.createElementNS('http://www.w3.org/2000/svg', 'desc'); 44 | 45 | descElement.innerHTML = description; 46 | svg.prepend(descElement); 47 | } 48 | 49 | if (typeof title !== 'undefined') { 50 | const originalTitle = svg.querySelector('title'); 51 | 52 | if (originalTitle?.parentNode) { 53 | originalTitle.parentNode.removeChild(originalTitle); 54 | } 55 | 56 | if (title) { 57 | const titleElement = document.createElementNS('http://www.w3.org/2000/svg', 'title'); 58 | 59 | titleElement.innerHTML = title; 60 | svg.prepend(titleElement); 61 | } 62 | } 63 | 64 | return svg; 65 | } catch (error: any) { 66 | return handleError(error); 67 | } 68 | } 69 | 70 | export function processSVG(content: string, preProcessor?: Props['preProcessor']) { 71 | if (preProcessor) { 72 | return preProcessor(content); 73 | } 74 | 75 | return content; 76 | } 77 | 78 | export function updateSVGAttributes( 79 | node: SVGSVGElement, 80 | options: UpdateSVGAttributesOptions, 81 | ): SVGSVGElement { 82 | const { baseURL = '', hash, uniquifyIDs } = options; 83 | const replaceableAttributes = ['id', 'href', 'xlink:href', 'xlink:role', 'xlink:arcrole']; 84 | const linkAttributes = ['href', 'xlink:href']; 85 | const isDataValue = (name: string, value: string) => 86 | linkAttributes.includes(name) && (value ? !value.includes('#') : false); 87 | 88 | if (!uniquifyIDs) { 89 | return node; 90 | } 91 | 92 | [...node.children].forEach(d => { 93 | if (d.attributes?.length) { 94 | const attributes = Object.values(d.attributes).map(a => { 95 | const attribute = a; 96 | const match = /url\((.*?)\)/.exec(a.value); 97 | 98 | if (match?.[1]) { 99 | attribute.value = a.value.replace(match[0], `url(${baseURL}${match[1]}__${hash})`); 100 | } 101 | 102 | return attribute; 103 | }); 104 | 105 | replaceableAttributes.forEach(r => { 106 | const attribute = attributes.find(a => a.name === r); 107 | 108 | if (attribute && !isDataValue(r, attribute.value)) { 109 | attribute.value = `${attribute.value}__${hash}`; 110 | } 111 | }); 112 | } 113 | 114 | if (d.children.length) { 115 | return updateSVGAttributes(d as SVGSVGElement, options); 116 | } 117 | 118 | return d; 119 | }); 120 | 121 | return node; 122 | } 123 | -------------------------------------------------------------------------------- /src/provider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { canUseDOM } from './modules/helpers'; 4 | 5 | interface Props { 6 | children: ReactNode; 7 | name?: string; 8 | } 9 | 10 | export default function CacheProvider({ children, name }: Props) { 11 | if (canUseDOM()) { 12 | window.REACT_INLINESVG_CACHE_NAME = name; 13 | window.REACT_INLINESVG_PERSISTENT_CACHE = true; 14 | } 15 | 16 | return children; 17 | } 18 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode, Ref, SVGProps } from 'react'; 2 | 3 | import { STATUS } from './config'; 4 | 5 | export type ErrorCallback = (error: Error | FetchError) => void; 6 | export type LoadCallback = (src: string, isCached: boolean) => void; 7 | export type PlainObject = Record; 8 | export type PreProcessorCallback = (code: string) => string; 9 | 10 | export type Props = Simplify< 11 | Omit, 'onLoad' | 'onError' | 'ref'> & { 12 | baseURL?: string; 13 | cacheRequests?: boolean; 14 | children?: ReactNode; 15 | description?: string; 16 | fetchOptions?: RequestInit; 17 | innerRef?: Ref; 18 | loader?: ReactNode; 19 | onError?: ErrorCallback; 20 | onLoad?: LoadCallback; 21 | preProcessor?: PreProcessorCallback; 22 | src: string; 23 | title?: string | null; 24 | uniqueHash?: string; 25 | uniquifyIDs?: boolean; 26 | } 27 | >; 28 | 29 | export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; 30 | 31 | export type Status = (typeof STATUS)[keyof typeof STATUS]; 32 | 33 | export interface FetchError extends Error { 34 | code: string; 35 | errno: string; 36 | message: string; 37 | type: string; 38 | } 39 | 40 | export interface State { 41 | content: string; 42 | element: ReactNode; 43 | isCached: boolean; 44 | status: Status; 45 | } 46 | 47 | export interface StorageItem { 48 | content: string; 49 | status: Status; 50 | } 51 | -------------------------------------------------------------------------------- /test/__fixtures__/buttons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/__fixtures__/circles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 11 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /test/__fixtures__/datahref.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/__fixtures__/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/__fixtures__/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test/__fixtures__/main.css: -------------------------------------------------------------------------------- 1 | circle:first-child { 2 | fill: red; 3 | } 4 | -------------------------------------------------------------------------------- /test/__fixtures__/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/__fixtures__/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gilbarbara/react-inlinesvg/c4db6b26c8d58805714be980a7d1ca98db27f60c/test/__fixtures__/react.png -------------------------------------------------------------------------------- /test/__fixtures__/react.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | React 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/__fixtures__/styles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 25 | sample 26 | 27 | 28 | 30 | 31 | -------------------------------------------------------------------------------- /test/__fixtures__/styles_with_css_variables.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | 21 | 22 | 39 | 40 | 41 | 42 | 43 | 44 | 61 | 62 | 63 | 64 | 81 | 82 | 83 | 100 | 101 | 102 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 142 | 143 | 144 | 145 | 146 | 163 | 164 | 165 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 206 | 207 | 208 | 225 | 226 | 227 | 228 | 245 | 246 | 247 | 264 | 265 | 266 | 283 | 284 | 285 | 302 | 303 | 304 | 321 | 322 | 323 | 340 | 341 | 342 | 359 | 360 | 361 | 378 | 379 | 380 | 397 | 398 | 399 | 416 | 417 | 418 | 435 | 436 | 437 | 454 | 455 | 456 | 473 | 474 | 475 | 492 | 493 | 494 | 511 | 512 | 513 | 530 | 531 | 532 | 549 | 550 | 551 | 568 | 569 | 570 | 587 | 588 | 589 | 606 | 607 | 608 | 625 | 626 | 627 | 644 | 645 | 646 | 663 | 664 | 665 | 682 | 683 | 684 | 701 | 702 | 703 | 720 | 721 | 722 | 739 | 740 | 741 | 758 | 759 | 760 | 777 | 778 | 779 | 796 | 797 | 798 | 815 | 816 | 817 | 834 | 835 | 836 | 853 | 854 | 855 | 872 | 873 | 874 | 891 | 892 | 893 | 910 | 911 | 912 | 929 | 930 | 931 | 948 | 949 | 950 | 967 | 968 | 969 | 986 | 987 | 988 | 1005 | 1006 | 1007 | 1024 | 1025 | 1026 | 1043 | 1044 | 1045 | 1062 | 1063 | 1064 | 1081 | 1082 | 1083 | 1100 | 1101 | 1102 | 1119 | 1120 | 1121 | 1138 | 1139 | 1140 | 1157 | 1158 | 1159 | 1176 | 1177 | 1178 | 1195 | 1196 | 1197 | 1214 | 1215 | 1216 | 1233 | 1234 | 1235 | 1252 | 1253 | 1254 | 1271 | 1272 | 1273 | 1290 | 1291 | 1292 | 1309 | 1310 | 1311 | 1328 | 1329 | 1330 | 1347 | 1348 | 1349 | 1350 | -------------------------------------------------------------------------------- /test/__fixtures__/utf8.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 二、故障现象确认 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 1. 50 | 启动车辆,观察故障现象。 52 | 54 | 56 | 1 58 | )眼观:仪表盘、车辆 60 | / 62 | 发动机运行状况。 64 | 66 | 68 | 2 70 | )耳听:车辆 72 | / 74 | 发动机异响 76 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 正常 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 异常 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 正常 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 异常 181 | 182 | 183 | 184 | 185 | 186 | -------------------------------------------------------------------------------- /test/__setup__/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | import 'vitest/globals'; 3 | -------------------------------------------------------------------------------- /test/__setup__/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | import * as matchers from 'jest-extended'; 4 | 5 | expect.extend(matchers); 6 | -------------------------------------------------------------------------------- /test/__snapshots__/unsupported.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`unsupported environments > should warn the user if fetch is missing 1`] = `null`; 4 | 5 | exports[`unsupported environments > shouldn't break without DOM 1`] = ` 6 |
9 | `; 10 | 11 | exports[`unsupported environments > shouldn't not render anything if is an unsupported browser 1`] = `null`; 12 | -------------------------------------------------------------------------------- /test/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, render } from '@testing-library/react'; 3 | import createFetchMock from 'vitest-fetch-mock'; 4 | 5 | import ReactInlineSVG, { cacheStore, Props } from '../src/index'; 6 | 7 | const fetchMock = createFetchMock(vi); 8 | 9 | vi.useFakeTimers(); 10 | 11 | const fixtures = { 12 | circles: 'http://127.0.0.1:1337/circles.svg', 13 | dots: 'http://127.0.0.1:1337/dots.svg', 14 | icons: 'http://127.0.0.1:1337/icons.svg', 15 | play: 'http://127.0.0.1:1337/play.svg', 16 | react: 'http://127.0.0.1:1337/react.svg', 17 | react_png: 'http://127.0.0.1:1337/react.png', 18 | tiger: 'http://127.0.0.1:1337/tiger.svg', 19 | datahref: 'http://127.0.0.1:1337/datahref.svg', 20 | styles: 'http://127.0.0.1:1337/styles.svg', 21 | styles_with_css_variables: 'http://127.0.0.1:1337/styles_with_css_variables.svg', 22 | utf8: 'http://127.0.0.1:1337/utf8.svg', 23 | url: 'https://cdn.svgporn.com/logos/react.svg', 24 | url2: 'https://cdn.svgporn.com/logos/javascript.svg', 25 | base64: 26 | 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICAgIDxwYXRoIGQ9Ik04IDV2MTRsMTEtN3oiLz4KICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4KPC9zdmc+Cg==', 27 | urlEncoded: 28 | 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%20%20%3Cpath%20d%3D%22M8%205v14l11-7z%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%0A%3C%2Fsvg%3E%0A', 29 | urlEncodedWithBase64: 30 | 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1098px" height="768px" viewBox="-0.5 -0.5 1098 768"%3E%3Cdefs /%3E%3Cg%3E%3Ca xlink:href="/dashboard/dash-l1MVuUCUAQ67LmpaHyH6"%3E%3Cimage x="428.5" y="617.5" width="86.25" height="80" xlink:href="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaWQ9ImZmZDY1ZWYwLTQ2ZjEtNDEyMy1hYTg2LTZjMjI0MzI5MmY1ZiIKICAgd2lkdGg9IjE4IgogICBoZWlnaHQ9IjE2Ljc2MDA3OCIKICAgdmlld0JveD0iMCAwIDE4IDE2Ljc2MDA3OCIKICAgdmVyc2lvbj0iMS4xIgogICBzb2RpcG9kaTpkb2NuYW1lPSJBenVyZV9BRF9CMkMuc3ZnIgogICBpbmtzY2FwZTp2ZXJzaW9uPSIwLjkyLjMgKDI0MDU1NDYsIDIwMTgtMDMtMTEpIj4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIgogICAgIGJvcmRlcmNvbG9yPSIjNjY2NjY2IgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgb2JqZWN0dG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBndWlkZXRvbGVyYW5jZT0iMTAiCiAgICAgaW5rc2NhcGU6cGFnZW9wYWNpdHk9IjAiCiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIKICAgICBpbmtzY2FwZTp3aW5kb3ctd2lkdGg9IjE5MjAiCiAgICAgaW5rc2NhcGU6d2luZG93LWhlaWdodD0iMTAxNyIKICAgICBpZD0ibmFtZWR2aWV3NTMwNjYiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGZpdC1tYXJnaW4tdG9wPSIwIgogICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIKICAgICBmaXQtbWFyZ2luLXJpZ2h0PSIwIgogICAgIGZpdC1tYXJnaW4tYm90dG9tPSIwIgogICAgIGlua3NjYXBlOnpvb209IjQ4LjExMTExMSIKICAgICBpbmtzY2FwZTpjeD0iOSIKICAgICBpbmtzY2FwZTpjeT0iOC4zODAwNzgxIgogICAgIGlua3NjYXBlOndpbmRvdy14PSItOCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJmZmQ2NWVmMC00NmYxLTQxMjMtYWE4Ni02YzIyNDMyOTJmNWYiIC8+CiAgPGRlZnMKICAgICBpZD0iZGVmczUzMDMzIj4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9ImFlMGM0ZTA4LTI0NDAtNDg4Yy1iYWUxLTY1MDljN2E0ZWYwNyIKICAgICAgIHgxPSIxMy4yNSIKICAgICAgIHkxPSIxMi44MyIKICAgICAgIHgyPSI4LjYxOTk5OTkiCiAgICAgICB5Mj0iNC4wNTk5OTk5IgogICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiCiAgICAgICBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKDAsMC42MikiPgogICAgICA8c3RvcAogICAgICAgICBvZmZzZXQ9IjAiCiAgICAgICAgIHN0b3AtY29sb3I9IiMxOTg4ZDkiCiAgICAgICAgIGlkPSJzdG9wNTMwMjgiIC8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC45IgogICAgICAgICBzdG9wLWNvbG9yPSIjNTRhZWYwIgogICAgICAgICBpZD0ic3RvcDUzMDMwIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPGcKICAgICBpZD0iZzUzMDYxIgogICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsLTAuNjIpIj4KICAgIDxwb2x5Z29uCiAgICAgICBwb2ludHM9IjE2Ljk5LDkuOTkgMTgsMTEuMTYgOC45MywxNy4wMSAwLDExLjE3IDEuMDEsMTAuMDEgOC45MywxNS4xNCAiCiAgICAgICBpZD0icG9seWdvbjUzMDM1IgogICAgICAgc3R5bGU9ImZpbGw6IzUwZTZmZiIgLz4KICAgIDxwb2x5Z29uCiAgICAgICBwb2ludHM9IjE2LjQsOS4zNiA4LjkzLDE0LjA4IDEuNjEsOS4zNSA4LjkzLDAuNjIgIgogICAgICAgaWQ9InBvbHlnb241MzAzNyIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmYiIC8+CiAgICA8cG9seWdvbgogICAgICAgcG9pbnRzPSIxLjYxLDkuMzUgOC45MywwLjYyIDguOTMsMTQuMDggIgogICAgICAgaWQ9InBvbHlnb241MzAzOSIKICAgICAgIHN0eWxlPSJmaWxsOiM1MGU2ZmYiIC8+CiAgICA8cG9seWdvbgogICAgICAgcG9pbnRzPSIxNi40LDkuMzYgOC45MywwLjYyIDguOTMsMTQuMDggIgogICAgICAgaWQ9InBvbHlnb241MzA0MSIKICAgICAgIHN0eWxlPSJmaWxsOnVybCgjYWUwYzRlMDgtMjQ0MC00ODhjLWJhZTEtNjUwOWM3YTRlZjA3KSIgLz4KICAgIDxwb2x5Z29uCiAgICAgICBwb2ludHM9IjguOTMsMTQuMDggOC45Myw3LjU3IDE2LjQsOS4zNiAiCiAgICAgICBpZD0icG9seWdvbjUzMDQzIgogICAgICAgc3R5bGU9ImZpbGw6IzUzYjFlMCIgLz4KICAgIDxwb2x5Z29uCiAgICAgICBwb2ludHM9IjguOTMsNy41NyA4LjkzLDE0LjA4IDEuNjEsOS4zNSAiCiAgICAgICBpZD0icG9seWdvbjUzMDQ1IgogICAgICAgc3R5bGU9ImZpbGw6IzljZWJmZiIgLz4KICAgIDxwb2x5Z29uCiAgICAgICBwb2ludHM9IjE2Ljk5LDkuOTkgOC45MywxNS4xNCA4LjkzLDE3LjAxIDE4LDExLjE2ICIKICAgICAgIGlkPSJwb2x5Z29uNTMwNDciCiAgICAgICBzdHlsZT0iZmlsbDojNWVhMGVmIiAvPgogICAgPHBhdGgKICAgICAgIGQ9Im0gMTcuNDUsMTYgYSAwLjQ0LDAuNDQgMCAwIDAgMC40NCwtMC40NCB2IDAgYSAyLjUsMi41IDAgMCAwIC0yLjQ1LC0yLjUgYyAtMS41MywwIC0yLjMyLDAuOTUgLTIuNDcsMi41IGEgMC40NSwwLjQ1IDAgMCAwIDAuMzksMC40OSBoIDQuMDkgeiIKICAgICAgIGlkPSJwYXRoNTMwNDkiCiAgICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgICAgc3R5bGU9ImZpbGw6Izc3M2FkYyIgLz4KICAgIDxwYXRoCiAgICAgICBkPSJtIDE1LjQ0LDEzLjM4IGEgMS4zNSwxLjM1IDAgMCAxIC0wLjc1LC0wLjIyIGwgMC43NCwxLjkzIDAuNzMsLTEuOTIgYSAxLjM4LDEuMzggMCAwIDEgLTAuNzIsMC4yMSB6IgogICAgICAgaWQ9InBhdGg1MzA1MSIKICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgICBzdHlsZT0ib3BhY2l0eTowLjg7ZmlsbDojZmZmZmZmIiAvPgogICAgPGNpcmNsZQogICAgICAgY3g9IjE1LjQ0IgogICAgICAgY3k9IjEyIgogICAgICAgcj0iMS4zOCIKICAgICAgIGlkPSJjaXJjbGU1MzA1MyIKICAgICAgIHN0eWxlPSJmaWxsOiM3NzNhZGMiIC8+CiAgICA8cGF0aAogICAgICAgZD0ibSAxNC42OCwxNy4zOCBhIDAuNjUsMC42NSAwIDAgMCAwLjY2LC0wLjY2IHYgLTAuMDggYyAtMC4yNiwtMi4wNSAtMS40MywtMy43MyAtMy42NywtMy43MyAtMi4yNCwwIC0zLjQ1LDEuNDIgLTMuNjcsMy43NCBhIDAuNjUsMC42NSAwIDAgMCAwLjU4LDAuNzIgaCA2LjExIHoiCiAgICAgICBpZD0icGF0aDUzMDU1IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHN0eWxlPSJmaWxsOiNhNjdhZjQiIC8+CiAgICA8cGF0aAogICAgICAgZD0ibSAxMS43LDEzLjQgYSAyLDIgMCAwIDEgLTEuMTEsLTAuMzMgbCAxLjEsMi45MyAxLjEsLTIuODcgQSAyLDIgMCAwIDEgMTEuNywxMy40IFoiCiAgICAgICBpZD0icGF0aDUzMDU3IgogICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIKICAgICAgIHN0eWxlPSJvcGFjaXR5OjAuODtmaWxsOiNmZmZmZmYiIC8+CiAgICA8Y2lyY2xlCiAgICAgICBjeD0iMTEuNjkiCiAgICAgICBjeT0iMTEuMzQiCiAgICAgICByPSIyLjA1OTk5OTkiCiAgICAgICBpZD0iY2lyY2xlNTMwNTkiCiAgICAgICBzdHlsZT0iZmlsbDojYTY3YWY0IiAvPgogIDwvZz4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGE1MzA2MyI+CiAgICA8cmRmOlJERj4KICAgICAgPHJkZjpsaT5wdWJsaWM6dHJ1ZWU8L3JkZjpsaT4KICAgICAgPHJkZjpsaT5zZGs6ZmFsc2U8L3JkZjpsaT4KICAgICAgPHJkZjpsaT5jYXRlZ29yeTogSWRlbnRpdHk8L3JkZjpsaT4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgICA8ZGM6dGl0bGU+PC9kYzp0aXRsZT4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+Cjwvc3ZnPgo=" /%3E%3C/a%3E%3C/g%3E%3C/svg%3E%0A', 31 | html: 'data:image/svg+xml,%3Chtml%20lang%3D%22en%22%3E%3Cbody%3EText%3C%2Fbody%3E%3C%2Fhtml%3E', 32 | string: 33 | ' ', 34 | } as const; 35 | 36 | const mockOnError = vi.fn(); 37 | const mockOnLoad = vi.fn(); 38 | 39 | // async function waitFor(callback: () => void) { 40 | // await waitForBase(callback, { 41 | // onTimeout: error => { 42 | // console.log('waitFor timeout', error); 43 | // 44 | // return error; 45 | // }, 46 | // timeout: 2000, 47 | // }); 48 | // } 49 | 50 | const { waitFor } = vi; 51 | 52 | function Loader() { 53 | return
; 54 | } 55 | 56 | function setup({ onLoad, ...rest }: Props) { 57 | return render( 58 | } onError={mockOnError} onLoad={mockOnLoad} {...rest} />, 59 | ); 60 | } 61 | 62 | describe('react-inlinesvg', () => { 63 | beforeAll(() => { 64 | vi.spyOn(console, 'error').mockImplementation(() => {}); 65 | }); 66 | 67 | afterEach(() => { 68 | vi.clearAllMocks(); 69 | 70 | cacheStore.clear(); 71 | }); 72 | 73 | describe('basic functionality', () => { 74 | it('should render a base64 src', async () => { 75 | const { container } = setup({ 76 | src: fixtures.base64, 77 | title: 'base64', 78 | }); 79 | 80 | await waitFor(() => { 81 | expect(mockOnLoad).toHaveBeenCalled(); 82 | }); 83 | 84 | expect(container.querySelector('svg')).toMatchSnapshot(); 85 | }); 86 | 87 | it('should render an urlEncoded src', async () => { 88 | const { container } = setup({ 89 | src: fixtures.urlEncoded, 90 | title: 'URL Encoded', 91 | }); 92 | 93 | await waitFor(() => { 94 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 95 | }); 96 | 97 | expect(container.querySelector('svg')).toMatchSnapshot(); 98 | }); 99 | 100 | it('should render an urlEncodedWithBase64 src', async () => { 101 | const { container } = setup({ 102 | src: fixtures.urlEncodedWithBase64, 103 | title: 'URL Encoded With Base 64', 104 | }); 105 | 106 | await waitFor(() => { 107 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 108 | }); 109 | 110 | expect(container.querySelector('svg')).toMatchSnapshot(); 111 | }); 112 | 113 | it('should render a svg string src', async () => { 114 | const { container } = setup({ 115 | src: fixtures.string, 116 | title: 'String', 117 | }); 118 | 119 | await waitFor(() => { 120 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 121 | }); 122 | 123 | expect(container.querySelector('svg')).toMatchSnapshot(); 124 | }); 125 | 126 | it('should render an svg url and add title and description', async () => { 127 | const { container } = setup({ 128 | src: fixtures.react, 129 | title: 'React FTW', 130 | description: 'React is a view library', 131 | }); 132 | 133 | await waitFor(() => { 134 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 135 | }); 136 | 137 | expect(container.querySelector('svg')).toMatchSnapshot(); 138 | }); 139 | 140 | it('should render a svg url with mask, gradient and classes', async () => { 141 | const { container } = setup({ 142 | src: fixtures.dots, 143 | title: 'Dots', 144 | }); 145 | 146 | await waitFor(() => { 147 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 148 | }); 149 | 150 | expect(container.querySelector('svg')).toMatchSnapshot(); 151 | }); 152 | 153 | it('should render a svg url with external css, style and script', async () => { 154 | const { container } = setup({ 155 | src: fixtures.circles, 156 | title: 'Circles', 157 | }); 158 | 159 | await waitFor(() => { 160 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 161 | }); 162 | 163 | expect(container.querySelector('svg')).toMatchSnapshot(); 164 | }); 165 | 166 | it('should render a svg url with inline styles', async () => { 167 | const { container } = setup({ 168 | src: fixtures.styles, 169 | uniquifyIDs: true, 170 | uniqueHash: 'test', 171 | }); 172 | 173 | await waitFor(() => { 174 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 175 | }); 176 | 177 | expect(container.querySelector('svg')).toMatchSnapshot(); 178 | }); 179 | 180 | it('should render a svg with css variables', async () => { 181 | const { container } = setup({ 182 | src: fixtures.styles_with_css_variables, 183 | uniquifyIDs: true, 184 | uniqueHash: 'test', 185 | }); 186 | 187 | await waitFor(() => { 188 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 189 | }); 190 | 191 | expect(container.querySelector('svg')).toMatchSnapshot(); 192 | }); 193 | 194 | it('should render a svg url with symbols', async () => { 195 | const { container } = setup({ src: fixtures.icons }); 196 | 197 | await waitFor(() => { 198 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 199 | }); 200 | 201 | expect(container.querySelector('svg')).toMatchSnapshot(); 202 | }); 203 | 204 | it('should render a svg url with utf-8 characters', async () => { 205 | const { container } = setup({ src: fixtures.utf8 }); 206 | 207 | await waitFor(() => { 208 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 209 | }); 210 | 211 | expect(container.querySelector('svg')).toMatchSnapshot(); 212 | }); 213 | 214 | it('should render an svg url and replace existing title and description', async () => { 215 | const { container } = setup({ 216 | src: fixtures.tiger, 217 | title: 'The Tiger', 218 | description: 'Is this a tiger?', 219 | }); 220 | 221 | await waitFor(() => { 222 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 223 | }); 224 | 225 | expect(container.querySelector('svg')).toMatchSnapshot(); 226 | }); 227 | 228 | it('should render a loader', async () => { 229 | const { container } = setup({ 230 | src: fixtures.play, 231 | loader: , 232 | }); 233 | 234 | expect(container).toMatchSnapshot(); 235 | 236 | await waitFor(() => { 237 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 238 | }); 239 | 240 | expect(container.querySelector('svg')).toMatchSnapshot(); 241 | }); 242 | 243 | it('should handle empty src changes', async () => { 244 | const { container, rerender } = setup({ src: '' }); 245 | 246 | expect(container.firstChild).toMatchSnapshot(); 247 | 248 | rerender( 249 | } 251 | onError={mockOnError} 252 | onLoad={mockOnLoad} 253 | src={fixtures.react} 254 | />, 255 | ); 256 | 257 | await waitFor(() => { 258 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 259 | }); 260 | 261 | expect(container.querySelector('svg')).toMatchSnapshot(); 262 | }); 263 | 264 | it('should handle src changes to empty', async () => { 265 | const { container, rerender } = setup({ src: fixtures.react }); 266 | 267 | await waitFor(() => { 268 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 269 | }); 270 | 271 | expect(container.querySelector('svg')).toMatchSnapshot(); 272 | 273 | rerender( 274 | } onError={mockOnError} onLoad={mockOnLoad} src="" />, 275 | ); 276 | 277 | await waitFor(() => { 278 | expect(mockOnError).toHaveBeenCalledWith(new Error('Missing src')); 279 | }); 280 | }); 281 | 282 | it('should uniquify ids with the random uniqueHash', async () => { 283 | const { container } = setup({ 284 | src: fixtures.play, 285 | uniquifyIDs: true, 286 | }); 287 | 288 | await waitFor(() => { 289 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 290 | }); 291 | 292 | expect(container.querySelector('radialGradient')?.outerHTML).toEqual( 293 | expect.stringMatching(/radialGradient-1__.*?/), 294 | ); 295 | }); 296 | 297 | it('should uniquify ids with a custom uniqueHash', async () => { 298 | const { container } = setup({ 299 | src: fixtures.play, 300 | uniqueHash: 'test', 301 | uniquifyIDs: true, 302 | }); 303 | 304 | await waitFor(() => { 305 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 306 | }); 307 | 308 | expect(container.querySelector('svg')).toMatchSnapshot(); 309 | }); 310 | 311 | it('should prefix the ids with the baseURL', async () => { 312 | const { container } = setup({ 313 | src: fixtures.play, 314 | baseURL: 'https://example.com/', 315 | uniqueHash: 'test', 316 | uniquifyIDs: true, 317 | }); 318 | 319 | await waitFor(() => { 320 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 321 | }); 322 | 323 | expect(container.querySelector('svg')).toMatchSnapshot(); 324 | }); 325 | 326 | it('should not uniquify non-id hrefs', async () => { 327 | const { container } = setup({ 328 | src: fixtures.datahref, 329 | uniquifyIDs: true, 330 | }); 331 | 332 | await waitFor(() => { 333 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 334 | }); 335 | 336 | expect(container.querySelector('svg')).toMatchSnapshot(); 337 | }); 338 | 339 | it('should transform the SVG text with the preProcessor prop', async () => { 340 | const extraProp = 'data-isvg="test"'; 341 | const { container } = setup({ 342 | src: fixtures.play, 343 | preProcessor: svgText => svgText.replace(' { 347 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 348 | }); 349 | 350 | expect(container.querySelector('svg')).toMatchSnapshot(); 351 | }); 352 | 353 | it('should handle innerRef', async () => { 354 | const innerRef = React.createRef(); 355 | 356 | const { container } = setup({ 357 | src: fixtures.play, 358 | innerRef, 359 | }); 360 | 361 | await waitFor(() => { 362 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 363 | }); 364 | 365 | expect(container.querySelector('svg')).toMatchSnapshot(); 366 | expect(innerRef.current).toMatchSnapshot(); 367 | }); 368 | 369 | it('should handle fetchOptions', async () => { 370 | fetchMock.enableMocks(); 371 | 372 | setup({ 373 | cacheRequests: false, 374 | src: fixtures.react, 375 | fetchOptions: { 376 | headers: { 377 | Authorization: 'Bearer ad99d8d5-419d-434e-97c2-3ce52e116d52', 378 | }, 379 | }, 380 | }); 381 | 382 | await waitFor(() => { 383 | expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:1337/react.svg', { 384 | headers: { 385 | Authorization: 'Bearer ad99d8d5-419d-434e-97c2-3ce52e116d52', 386 | }, 387 | }); 388 | }); 389 | 390 | fetchMock.disableMocks(); 391 | }); 392 | 393 | it('should handle custom props', async () => { 394 | const { container } = setup({ 395 | src: fixtures.react, 396 | style: { width: 100 }, 397 | }); 398 | 399 | await waitFor(() => { 400 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 401 | }); 402 | 403 | expect(container.querySelector('svg')).toMatchSnapshot(); 404 | }); 405 | 406 | it('should remove the title', async () => { 407 | const { container } = setup({ 408 | src: fixtures.react, 409 | title: null, 410 | }); 411 | 412 | await waitFor(() => { 413 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 414 | }); 415 | 416 | expect(container.querySelector('svg')).toMatchSnapshot(); 417 | }); 418 | 419 | it('should update the title and description after the initial render', async () => { 420 | const { container, rerender } = setup({ 421 | src: fixtures.react, 422 | }); 423 | 424 | await waitFor(() => { 425 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 426 | }); 427 | expect(container.querySelector('title')).toHaveTextContent('React'); 428 | expect(container.querySelector('desc')).not.toBeInTheDocument(); 429 | 430 | rerender( 431 | } 434 | onError={mockOnError} 435 | onLoad={mockOnLoad} 436 | src={fixtures.react} 437 | title="React Rocks" 438 | />, 439 | ); 440 | 441 | expect(container.querySelector('title')).toHaveTextContent('React Rocks'); 442 | expect(container.querySelector('desc')).toHaveTextContent('A view library'); 443 | }); 444 | }); 445 | 446 | describe('cached requests', () => { 447 | beforeAll(() => { 448 | fetchMock.enableMocks(); 449 | }); 450 | 451 | afterAll(() => { 452 | fetchMock.disableMocks(); 453 | }); 454 | 455 | it('should request an SVG only once', async () => { 456 | fetchMock.mockResponseOnce( 457 | () => 458 | new Promise(resolve => { 459 | setTimeout( 460 | () => 461 | resolve({ 462 | body: 'React', 463 | headers: { 'Content-Type': 'image/svg+xml' }, 464 | }), 465 | 500, 466 | ); 467 | }), 468 | ); 469 | 470 | setup({ src: fixtures.url }); 471 | 472 | await waitFor(() => { 473 | expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.url, true); 474 | }); 475 | 476 | setup({ src: fixtures.url }); 477 | 478 | await waitFor(() => { 479 | expect(mockOnLoad).toHaveBeenNthCalledWith(2, fixtures.url, true); 480 | }); 481 | 482 | expect(fetchMock).toHaveBeenNthCalledWith(1, fixtures.url, undefined); 483 | 484 | expect(cacheStore.isCached(fixtures.url)).toBeTrue(); 485 | }); 486 | 487 | it('should handle multiple simultaneous instances with the same url', async () => { 488 | fetchMock.mockResponseOnce(() => 489 | Promise.resolve({ 490 | body: 'React', 491 | headers: { 'Content-Type': 'image/svg+xml' }, 492 | }), 493 | ); 494 | 495 | render( 496 | <> 497 | 498 | 499 | 500 | , 501 | ); 502 | 503 | expect(fetchMock).toHaveBeenCalledTimes(1); 504 | 505 | await waitFor(() => { 506 | expect(mockOnLoad).toHaveBeenNthCalledWith(3, fixtures.url, true); 507 | }); 508 | }); 509 | 510 | it('should handle request fail with multiple instances', async () => { 511 | fetchMock.mockRejectOnce(new Error('500')).mockRejectOnce(new Error('500')); 512 | 513 | setup({ 514 | src: fixtures.url2, 515 | }); 516 | 517 | await waitFor(() => { 518 | expect(mockOnError).toHaveBeenCalledTimes(1); 519 | }); 520 | 521 | setup({ 522 | src: fixtures.url2, 523 | }); 524 | 525 | await waitFor(() => { 526 | expect(mockOnError).toHaveBeenCalledTimes(2); 527 | }); 528 | }); 529 | 530 | it('should handle cached entries with loading status', async () => { 531 | fetchMock.mockResponseOnce(() => 532 | Promise.resolve({ 533 | body: 'React', 534 | headers: { 'Content-Type': 'image/svg+xml' }, 535 | }), 536 | ); 537 | 538 | cacheStore.set(fixtures.react, { 539 | content: '', 540 | status: 'loading', 541 | }); 542 | 543 | setup({ src: fixtures.react }); 544 | 545 | await act(async () => { 546 | vi.runAllTimers(); 547 | }); 548 | 549 | await waitFor(() => { 550 | expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.react, true); 551 | }); 552 | 553 | expect(fetchMock).toHaveBeenCalledTimes(1); 554 | 555 | expect(cacheStore.keys()).toEqual([fixtures.react]); 556 | }); 557 | 558 | it('should handle cached entries with loading status on error', async () => { 559 | const error = new Error('Failed to fetch'); 560 | 561 | fetchMock.mockResponseOnce(() => Promise.reject(error)); 562 | 563 | cacheStore.set(fixtures.react, { 564 | content: '', 565 | status: 'loading', 566 | }); 567 | 568 | setup({ src: fixtures.react }); 569 | 570 | await act(async () => { 571 | vi.runAllTimers(); 572 | }); 573 | 574 | await waitFor(() => { 575 | expect(mockOnError).toHaveBeenNthCalledWith(1, error); 576 | }); 577 | 578 | expect(fetchMock).toHaveBeenCalledTimes(1); 579 | }); 580 | 581 | it('should skip the cache if `cacheRequest` is false', async () => { 582 | fetchMock.mockResponseOnce(() => 583 | Promise.resolve({ 584 | body: '', 585 | headers: { 'Content-Type': 'image/svg+xml' }, 586 | }), 587 | ); 588 | 589 | setup({ 590 | cacheRequests: false, 591 | src: fixtures.url, 592 | }); 593 | 594 | await waitFor(() => { 595 | expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.url, false); 596 | }); 597 | 598 | expect(fetchMock.mock.calls).toHaveLength(1); 599 | 600 | expect(cacheStore.keys()).toHaveLength(0); 601 | }); 602 | }); 603 | 604 | describe('integration', () => { 605 | beforeAll(() => { 606 | fetchMock.resetMocks(); 607 | }); 608 | 609 | it('should handle race condition with fast src changes', async () => { 610 | fetchMock.enableMocks(); 611 | fetchMock 612 | .mockResponseOnce( 613 | () => 614 | new Promise(resolve => { 615 | setTimeout( 616 | () => 617 | resolve({ 618 | body: 'React', 619 | headers: { 'Content-Type': 'image/svg+xml' }, 620 | }), 621 | 0, 622 | ); 623 | }), 624 | ) 625 | .mockResponseOnce( 626 | () => 627 | new Promise(resolve => { 628 | setTimeout( 629 | () => 630 | resolve({ 631 | body: 'React', 632 | headers: { 'Content-Type': 'image/svg+xml' }, 633 | }), 634 | 0, 635 | ); 636 | }), 637 | ); 638 | 639 | const { container, rerender } = setup({ src: fixtures.react, title: 'React' }); 640 | 641 | await waitFor(() => { 642 | expect(fetchMock).toHaveBeenNthCalledWith(1, fixtures.react, undefined); 643 | }); 644 | 645 | await waitFor(() => { 646 | expect(mockOnLoad).toHaveBeenNthCalledWith(1, fixtures.react, true); 647 | }); 648 | 649 | rerender( 650 | } 652 | onError={mockOnError} 653 | onLoad={mockOnLoad} 654 | src={fixtures.url2} 655 | title="Javascript" 656 | />, 657 | ); 658 | 659 | await waitFor(() => { 660 | expect(fetchMock).toHaveBeenNthCalledWith(2, fixtures.url2, undefined); 661 | }); 662 | 663 | await waitFor(() => { 664 | expect(mockOnLoad).toHaveBeenNthCalledWith(2, fixtures.url2, true); 665 | }); 666 | 667 | rerender( 668 | } 670 | onError={mockOnError} 671 | onLoad={mockOnLoad} 672 | src={fixtures.react} 673 | />, 674 | ); 675 | 676 | expect(fetchMock).toHaveBeenCalledTimes(2); 677 | 678 | await waitFor(() => { 679 | expect(mockOnLoad).toHaveBeenNthCalledWith(3, fixtures.react, true); 680 | }); 681 | 682 | expect(container.querySelector('svg')).toMatchSnapshot('svg'); 683 | 684 | expect(cacheStore.keys()).toMatchSnapshot('cacheStore'); 685 | 686 | fetchMock.disableMocks(); 687 | }); 688 | 689 | it('should render multiple SVGs', async () => { 690 | const { container } = render( 691 |
692 | 693 | 694 | 695 | 696 |
, 697 | ); 698 | 699 | await waitFor( 700 | () => { 701 | expect(mockOnLoad).toHaveBeenCalledTimes(4); 702 | }, 703 | { timeout: 2000 }, 704 | ); 705 | 706 | expect(container.querySelectorAll('svg')).toHaveLength(4); 707 | expect(container.firstChild).toMatchSnapshot(); 708 | }); 709 | 710 | it('should handle pre-cached entries in the cacheStore', async () => { 711 | fetchMock.enableMocks(); 712 | 713 | cacheStore.set(fixtures.react, { 714 | content: '', 715 | status: 'loaded', 716 | }); 717 | 718 | const { container } = render(); 719 | 720 | await waitFor(() => { 721 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 722 | }); 723 | 724 | expect(fetchMock).toHaveBeenCalledTimes(0); 725 | 726 | expect(container.querySelector('svg')).toMatchSnapshot(); 727 | 728 | // clean up 729 | fetchMock.disableMocks(); 730 | }); 731 | }); 732 | 733 | describe('with errors', () => { 734 | beforeEach(() => { 735 | mockOnError.mockClear(); 736 | }); 737 | 738 | it('should trigger an error if empty', async () => { 739 | // @ts-ignore 740 | const { container } = setup({}); 741 | 742 | await waitFor(() => { 743 | expect(mockOnError).toHaveBeenCalledWith(new Error('Missing src')); 744 | }); 745 | 746 | expect(container.querySelector('svg')).toBeNull(); 747 | }); 748 | 749 | it('should trigger an error on empty `src` prop changes', async () => { 750 | const { container, rerender } = setup({ 751 | src: fixtures.urlEncoded, 752 | }); 753 | 754 | await waitFor(() => { 755 | expect(mockOnLoad).toHaveBeenCalledTimes(1); 756 | }); 757 | 758 | expect(container.querySelector('svg')).toMatchSnapshot(); 759 | 760 | rerender( 761 | } onError={mockOnError} onLoad={mockOnLoad} src="" />, 762 | ); 763 | 764 | expect(mockOnError).toHaveBeenCalledWith(new Error('Missing src')); 765 | }); 766 | 767 | it('should trigger an error and render the fallback children if src is not found', async () => { 768 | const { container } = setup({ 769 | src: 'http://127.0.0.1:1337/DOESNOTEXIST.svg', 770 | children: ( 771 |
772 | MISSING 773 |
774 | ), 775 | }); 776 | 777 | await waitFor(() => { 778 | expect(mockOnError).toHaveBeenCalledWith(new Error('Not found')); 779 | }); 780 | 781 | expect(container.firstChild).toMatchSnapshot(); 782 | }); 783 | 784 | it('should trigger an error if the request content-type is not valid', async () => { 785 | setup({ src: fixtures.react_png }); 786 | 787 | await waitFor(() => { 788 | expect(mockOnError).toHaveBeenCalledWith(new Error("Content type isn't valid: image/png")); 789 | }); 790 | }); 791 | 792 | it('should trigger an error if the content is not valid', async () => { 793 | setup({ src: fixtures.html }); 794 | 795 | await waitFor(() => { 796 | expect(mockOnError).toHaveBeenCalledWith( 797 | new Error('Could not convert the src to a React element'), 798 | ); 799 | }); 800 | }); 801 | }); 802 | }); 803 | -------------------------------------------------------------------------------- /test/indexWithPersistentCache.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first */ 2 | import * as React from 'react'; 3 | import { render, waitFor } from '@testing-library/react'; 4 | import CacheMock from 'browser-cache-mock'; 5 | import createFetchMock from 'vitest-fetch-mock'; 6 | 7 | const fetchMock = createFetchMock(vi); 8 | 9 | const cacheMock = new CacheMock(); 10 | 11 | Object.defineProperty(window, 'caches', { 12 | value: { 13 | ...window.caches, 14 | open: async () => 15 | new Promise(resolve => { 16 | setTimeout(() => { 17 | resolve(cacheMock); 18 | }, 500); 19 | }), 20 | ...cacheMock, 21 | }, 22 | }); 23 | 24 | import ReactInlineSVG, { cacheStore, Props } from '../src/index'; 25 | import CacheProvider from '../src/provider'; 26 | 27 | function Loader() { 28 | return
; 29 | } 30 | 31 | const mockOnError = vi.fn(); 32 | const mockOnLoad = vi.fn(); 33 | 34 | const url = 'https://cdn.svgporn.com/logos/react.svg'; 35 | 36 | fetchMock.enableMocks(); 37 | 38 | fetchMock.mockResponse(() => 39 | Promise.resolve({ 40 | body: 'React', 41 | headers: { 'Content-Type': 'image/svg+xml' }, 42 | }), 43 | ); 44 | 45 | function setup({ cacheName, onLoad, ...rest }: Props & { cacheName?: string }) { 46 | return render( 47 | } onError={mockOnError} onLoad={mockOnLoad} {...rest} />, 48 | { wrapper: ({ children }) => {children} }, 49 | ); 50 | } 51 | 52 | describe('react-inlinesvg (with persistent cache)', () => { 53 | afterEach(async () => { 54 | fetchMock.mockClear(); 55 | await cacheStore.clear(); 56 | }); 57 | 58 | it('should set the default global variables', () => { 59 | setup({ src: url }); 60 | 61 | expect(window.REACT_INLINESVG_PERSISTENT_CACHE).toBeTrue(); 62 | expect(window.REACT_INLINESVG_CACHE_NAME).toBeUndefined(); 63 | }); 64 | 65 | it('should set the cache name global variable', () => { 66 | setup({ cacheName: 'test-cache', src: url }); 67 | 68 | expect(window.REACT_INLINESVG_CACHE_NAME).toBe('test-cache'); 69 | }); 70 | 71 | it('should request an SVG only once', async () => { 72 | setup({ src: url }); 73 | 74 | await waitFor(() => { 75 | expect(mockOnLoad).toHaveBeenNthCalledWith(1, url, true); 76 | }); 77 | 78 | setup({ src: url }); 79 | 80 | await waitFor(() => { 81 | expect(mockOnLoad).toHaveBeenNthCalledWith(2, url, true); 82 | }); 83 | 84 | expect(fetchMock).toHaveBeenCalledTimes(1); 85 | expect(cacheStore.isCached(url)).toBeTrue(); 86 | }); 87 | 88 | it('should handle multiple simultaneous instances with the same url', async () => { 89 | render( 90 | <> 91 | 92 | 93 | 94 | , 95 | ); 96 | 97 | await waitFor(() => { 98 | expect(mockOnLoad).toHaveBeenNthCalledWith(3, url, true); 99 | }); 100 | expect(fetchMock).toHaveBeenCalledTimes(1); 101 | 102 | setup({ src: url }); 103 | 104 | await waitFor(() => { 105 | expect(mockOnLoad).toHaveBeenNthCalledWith(4, url, true); 106 | }); 107 | expect(fetchMock).toHaveBeenCalledTimes(1); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /test/modules/cache.spec.ts: -------------------------------------------------------------------------------- 1 | import { waitFor } from '@testing-library/react'; 2 | import CacheMock from 'browser-cache-mock'; 3 | import createFetchMock from 'vitest-fetch-mock'; 4 | 5 | import { STATUS } from '../../src/config'; 6 | import CacheStore from '../../src/modules/cache'; 7 | 8 | const fetchMock = createFetchMock(vi); 9 | 10 | fetchMock.enableMocks(); 11 | 12 | const cacheMock = new CacheMock(); 13 | 14 | let cachesOpenPromise = new Promise(resolve => { 15 | setTimeout(() => { 16 | resolve(cacheMock); 17 | }, 500); 18 | }); 19 | 20 | Object.defineProperty(window, 'caches', { 21 | value: { 22 | ...window.caches, 23 | open: async () => cachesOpenPromise, 24 | ...cacheMock, 25 | }, 26 | }); 27 | 28 | const reactUrl = 'https://cdn.svgporn.com/logos/react.svg'; 29 | const reactContent = 'React'; 30 | const jsUrl = 'https://cdn.svgporn.com/logos/javascript.svg'; 31 | const jsContent = 'JS'; 32 | 33 | describe('CacheStore (internal)', () => { 34 | const cacheStore = new CacheStore(); 35 | 36 | afterEach(async () => { 37 | fetchMock.mockClear(); 38 | await cacheStore.clear(); 39 | }); 40 | 41 | it('should fetch the remote url and add to the cache', async () => { 42 | fetchMock.mockResponseOnce(() => Promise.resolve(reactContent)); 43 | 44 | await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); 45 | expect(fetchMock).toHaveBeenCalledTimes(1); 46 | 47 | await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); 48 | expect(fetchMock).toHaveBeenCalledTimes(1); 49 | expect(cacheStore.isCached(reactUrl)).toBeTrue(); 50 | 51 | await cacheStore.clear(); 52 | expect(cacheStore.isCached(reactUrl)).toBeFalse(); 53 | }); 54 | 55 | it('should handle multiple simultaneous requests', async () => { 56 | fetchMock.mockResponse(() => { 57 | return new Promise(resolve => { 58 | setTimeout(() => { 59 | resolve(reactContent); 60 | }, 300); 61 | }); 62 | }); 63 | 64 | expect(cacheStore.get(reactUrl)).toEqual(expect.any(Promise)); 65 | 66 | await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); 67 | await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); 68 | 69 | expect(fetchMock).toHaveBeenCalledTimes(1); 70 | expect(cacheStore.isCached(reactUrl)).toBeTrue(); 71 | }); 72 | 73 | it('should handle adding to cache manually', async () => { 74 | cacheStore.set(jsUrl, { content: jsContent, status: STATUS.LOADED }); 75 | 76 | await expect(cacheStore.get(jsUrl)).resolves.toBe(jsContent); 77 | expect(fetchMock).toHaveBeenCalledTimes(0); 78 | 79 | expect(cacheStore.isCached(jsUrl)).toBeTrue(); 80 | expect(cacheStore.keys()).toEqual([jsUrl]); 81 | }); 82 | 83 | it(`should handle stalled entries with ${STATUS.LOADING}`, async () => { 84 | fetchMock.mockResponseOnce(() => Promise.resolve(jsContent)); 85 | 86 | cacheStore.set(jsUrl, { content: jsContent, status: STATUS.LOADING }); 87 | expect(fetchMock).toHaveBeenCalledTimes(0); 88 | 89 | await expect(cacheStore.get(jsUrl)).resolves.toBe(jsContent); 90 | expect(fetchMock).toHaveBeenCalledTimes(1); 91 | }); 92 | 93 | it('should handle fetch errors', async () => { 94 | fetchMock.mockRejectOnce(new Error('Failed to fetch')); 95 | 96 | await expect(cacheStore.get(jsUrl)).rejects.toThrow('Failed to fetch'); 97 | expect(cacheStore.isCached(jsUrl)).toBeFalse(); 98 | expect(fetchMock).toHaveBeenCalledTimes(1); 99 | }); 100 | 101 | it('should return the cached keys', async () => { 102 | fetchMock.mockResponseOnce(() => Promise.resolve(reactContent)); 103 | 104 | await cacheStore.get(reactUrl); 105 | 106 | expect(cacheStore.keys()).toEqual([reactUrl]); 107 | }); 108 | 109 | it('should return the cached data', async () => { 110 | fetchMock.mockResponseOnce(() => Promise.resolve(reactContent)); 111 | 112 | await cacheStore.get(reactUrl); 113 | 114 | expect(cacheStore.data()).toEqual([ 115 | { 116 | [reactUrl]: { content: reactContent, status: STATUS.LOADED }, 117 | }, 118 | ]); 119 | }); 120 | 121 | it('should delete an item from the cache', async () => { 122 | await cacheStore.get(reactUrl); 123 | expect(cacheStore.keys()).toHaveLength(1); 124 | 125 | await cacheStore.delete(reactUrl); 126 | expect(cacheStore.keys()).toHaveLength(0); 127 | }); 128 | 129 | it('should clear the cache items', async () => { 130 | await cacheStore.get(reactUrl); 131 | expect(cacheStore.keys()).toHaveLength(1); 132 | 133 | await cacheStore.clear(); 134 | expect(cacheStore.keys()).toHaveLength(0); 135 | }); 136 | }); 137 | 138 | describe('CacheStore (external)', () => { 139 | Object.defineProperty(window, 'REACT_INLINESVG_PERSISTENT_CACHE', { 140 | value: true, 141 | }); 142 | const mockReady = vi.fn(); 143 | let cacheStore: CacheStore; 144 | 145 | beforeAll(async () => { 146 | // wait for the cache to be ready 147 | cacheStore = new CacheStore(); 148 | cacheStore.onReady(mockReady); 149 | await waitFor(() => expect(mockReady).toHaveBeenCalledTimes(1)); 150 | }); 151 | 152 | beforeEach(() => { 153 | fetchMock.mockResponse(() => Promise.resolve(reactContent)); 154 | }); 155 | 156 | afterEach(async () => { 157 | fetchMock.mockClear(); 158 | await cacheStore.clear(); 159 | }); 160 | 161 | it('should handle initialization', async () => { 162 | await waitFor(() => { 163 | expect(mockReady).toHaveBeenCalledTimes(1); 164 | }); 165 | 166 | cacheStore.onReady(mockReady); 167 | expect(mockReady).toHaveBeenCalledTimes(2); 168 | }); 169 | 170 | it('should fetch the remote url and add to the cache', async () => { 171 | await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); 172 | expect(fetchMock).toHaveBeenCalledTimes(1); 173 | 174 | await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); 175 | expect(fetchMock).toHaveBeenCalledTimes(1); 176 | 177 | expect(cacheStore.isCached(reactUrl)).toBeTrue(); 178 | expect(cacheStore.keys()).toEqual([reactUrl]); 179 | }); 180 | 181 | it('should handle multiple simultaneous requests', async () => { 182 | fetchMock.mockResponse(() => { 183 | return new Promise(resolve => { 184 | setTimeout(() => { 185 | resolve(reactContent); 186 | }, 300); 187 | }); 188 | }); 189 | 190 | expect(cacheStore.get(reactUrl)).toEqual(expect.any(Promise)); 191 | 192 | await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); 193 | expect(fetchMock).toHaveBeenCalledTimes(1); 194 | }); 195 | 196 | it('should read from the persistent cache if the store is empty', async () => { 197 | // add to the persistent cache directly 198 | await cacheMock.add(reactUrl); 199 | fetchMock.mockClear(); 200 | 201 | await expect(cacheStore.get(reactUrl)).resolves.toBe(reactContent); 202 | expect(fetchMock).toHaveBeenCalledTimes(0); 203 | }); 204 | 205 | it('should correctly cache and return empty responses', async () => { 206 | fetchMock.mockResponseOnce(() => Promise.resolve('')); 207 | 208 | // First fetch: Expect it to store the empty response 209 | await expect(cacheStore.get(jsUrl)).resolves.toBe(''); 210 | expect(fetchMock).toHaveBeenCalledTimes(1); 211 | expect(cacheStore.isCached(jsUrl)).toBeTrue(); 212 | 213 | expect(cacheStore.data()).toEqual([{ [jsUrl]: { content: '', status: STATUS.LOADED } }]); 214 | 215 | // Second fetch: Should return cached empty response without calling fetch 216 | await expect(cacheStore.get(jsUrl)).resolves.toBe(''); 217 | expect(fetchMock).toHaveBeenCalledTimes(1); // Should still be 1 if properly cached 218 | 219 | // Delete and refetch: Should trigger another fetch 220 | await cacheStore.delete(jsUrl); 221 | 222 | expect(cacheStore.isCached(jsUrl)).toBeFalse(); 223 | expect(cacheStore.keys()).toHaveLength(0); 224 | 225 | fetchMock.mockResponseOnce(() => Promise.resolve(jsContent)); 226 | 227 | await expect(cacheStore.get(jsUrl)).resolves.toBe(jsContent); 228 | 229 | expect(fetchMock).toHaveBeenCalledTimes(2); // Fetch count should increase 230 | }); 231 | 232 | it('should handle delete', async () => { 233 | await cacheStore.get(reactUrl); 234 | 235 | await cacheStore.delete(reactUrl); 236 | 237 | expect(cacheStore.keys()).toHaveLength(0); 238 | await expect(cacheMock.keys()).resolves.toHaveLength(0); 239 | }); 240 | 241 | it('should handle clear', async () => { 242 | fetchMock.mockResponseOnce(() => Promise.resolve(reactContent)); 243 | 244 | await cacheStore.get(reactUrl); 245 | 246 | await cacheStore.clear(); 247 | 248 | expect(cacheStore.keys()).toHaveLength(0); 249 | await expect(cacheMock.keys()).resolves.toHaveLength(0); 250 | }); 251 | 252 | it(`should handle stalled entries with ${STATUS.LOADING}`, async () => { 253 | fetchMock.mockResponseOnce(() => Promise.resolve(jsContent)); 254 | 255 | cacheStore.set(jsUrl, { content: jsContent, status: STATUS.LOADING }); 256 | expect(fetchMock).toHaveBeenCalledTimes(0); 257 | 258 | await expect(cacheStore.get(jsUrl)).resolves.toBe(jsContent); 259 | expect(fetchMock).toHaveBeenCalledTimes(1); 260 | }); 261 | 262 | it('should handle fetch errors', async () => { 263 | fetchMock.mockRejectOnce(new Error('Failed to fetch')); 264 | 265 | await expect(cacheStore.get(jsUrl)).rejects.toThrow('Failed to fetch'); 266 | expect(cacheStore.isCached(jsUrl)).toBeFalse(); 267 | expect(fetchMock).toHaveBeenCalledTimes(1); 268 | }); 269 | 270 | it('should handle caches.open errors', async () => { 271 | const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); 272 | 273 | mockReady.mockClear(); 274 | cachesOpenPromise = Promise.reject(new Error('The operation is insecure.')); 275 | 276 | const cacheStoreWithError = new CacheStore(); 277 | 278 | cacheStoreWithError.onReady(mockReady); 279 | 280 | await waitFor(() => { 281 | expect(consoleError).toHaveBeenCalledWith('Failed to open cache: The operation is insecure.'); 282 | }); 283 | expect(mockReady).toHaveBeenCalledTimes(1); 284 | 285 | consoleError.mockRestore(); 286 | }); 287 | }); 288 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | "noUnusedLocals": false, 5 | "module": "esnext" 6 | }, 7 | "include": ["**/*", "../src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /test/unsupported.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, waitFor } from '@testing-library/react'; 3 | 4 | import InlineSVG, { Props } from '../src'; 5 | 6 | declare let window: any; 7 | 8 | const mockOnError = vi.fn(); 9 | const mockOnLoad = vi.fn(); 10 | 11 | let mockCanUseDOM = false; 12 | let mockIsSupportedEnvironment = true; 13 | 14 | vi.mock('../src/modules/helpers', async () => { 15 | const utils = await vi.importActual any>>('../src/modules/helpers'); 16 | 17 | return { 18 | ...utils, 19 | canUseDOM: () => mockCanUseDOM, 20 | isSupportedEnvironment: () => mockIsSupportedEnvironment, 21 | }; 22 | }); 23 | 24 | function Loader() { 25 | return
; 26 | } 27 | 28 | function setup({ onLoad, src = 'http://localhost:1337/play.svg', ...rest }: Partial = {}) { 29 | return render( 30 | } onError={mockOnError} onLoad={mockOnLoad} src={src} {...rest} />, 31 | ); 32 | } 33 | 34 | describe('unsupported environments', () => { 35 | it("shouldn't break without DOM", async () => { 36 | const { container, rerender } = setup(); 37 | 38 | expect(mockOnLoad).not.toHaveBeenCalled(); 39 | expect(mockOnError).not.toHaveBeenCalled(); 40 | expect(container.firstChild).toMatchSnapshot(); 41 | 42 | rerender(} onError={mockOnError} onLoad={mockOnLoad} src="" />); 43 | }); 44 | 45 | it('should warn the user if fetch is missing', async () => { 46 | const globalFetch = fetch; 47 | 48 | window.fetch = undefined; 49 | 50 | mockCanUseDOM = true; 51 | mockIsSupportedEnvironment = true; 52 | 53 | const { container } = setup(); 54 | 55 | await waitFor(() => { 56 | expect(mockOnError).toHaveBeenCalledWith(new TypeError('fetch is not a function')); 57 | }); 58 | 59 | expect(container.firstChild).toMatchSnapshot(); 60 | 61 | window.fetch = globalFetch; 62 | }); 63 | 64 | it("shouldn't not render anything if is an unsupported browser", () => { 65 | mockCanUseDOM = true; 66 | mockIsSupportedEnvironment = false; 67 | 68 | const { container } = setup(); 69 | 70 | expect(mockOnError).toHaveBeenCalledWith(new Error('Browser does not support SVG')); 71 | expect(container.firstChild).toMatchSnapshot(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@gilbarbara/tsconfig", 3 | "compilerOptions": { 4 | "downlevelIteration": true, 5 | "jsx": "react", 6 | "noEmit": true, 7 | "target": "ES2020" 8 | }, 9 | "include": ["src/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | coverage: { 8 | all: true, 9 | include: ['src/**/*.ts?(x)'], 10 | exclude: ['src/global.d.ts', 'src/types.ts'], 11 | reporter: ['text', 'lcov'], 12 | thresholds: { 13 | statements: 90, 14 | branches: 90, 15 | functions: 90, 16 | lines: 90, 17 | }, 18 | }, 19 | environment: 'jsdom', 20 | globals: true, 21 | setupFiles: ['./test/__setup__/vitest.setup.ts'], 22 | }, 23 | }); 24 | --------------------------------------------------------------------------------